Compare commits
8 Commits
35c30bc39a
...
feature/ta
| Author | SHA1 | Date | |
|---|---|---|---|
| bb918a8839 | |||
| e57aa374ea | |||
| eb3f77f092 | |||
| b396e54080 | |||
| c4baf68595 | |||
| 0a1be647c4 | |||
| 01e5ae8b5c | |||
| adafab61ae |
@@ -1 +1 @@
|
||||
{"task_id": "2ff88f42-8544-8000-8314-c9013414d1d0", "token": "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", "session_start_time": "2026-02-19T08:32:53.260193"}
|
||||
{"task_id": "2f488f42-8544-81ac-a9f8-e373c4c18115", "token": "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", "session_start_time": "2026-01-26T18:39:11.157549"}
|
||||
33
.env.example
33
.env.example
@@ -1,33 +0,0 @@
|
||||
# GTM Engine - Environment Configuration Template
|
||||
# Copy this file to .env and fill in the actual values.
|
||||
|
||||
# --- Core API Keys ---
|
||||
GEMINI_API_KEY=AI...
|
||||
OPENAI_API_KEY=sk-...
|
||||
SERP_API=...
|
||||
|
||||
# --- Notion Integration ---
|
||||
NOTION_API_KEY=ntn_...
|
||||
|
||||
# --- SuperOffice API Credentials ---
|
||||
SO_CLIENT_ID=...
|
||||
SO_CLIENT_SECRET=...
|
||||
SO_REFRESH_TOKEN=...
|
||||
SO_ENVIRONMENT=online3
|
||||
SO_CONTEXT_IDENTIFIER=Cust...
|
||||
|
||||
# --- Application Settings ---
|
||||
API_USER=admin
|
||||
API_PASSWORD=gemini
|
||||
APP_BASE_URL=http://localhost:8090
|
||||
|
||||
# --- Infrastructure ---
|
||||
DUCKDNS_TOKEN=...
|
||||
# SUBDOMAINS=floke,floke-ai... (defined in docker-compose)
|
||||
|
||||
# --- Feature Flags ---
|
||||
ENABLE_WEBSITE_SYNC=False
|
||||
|
||||
# --- Advanced Mappings (Optional Overrides) ---
|
||||
# VERTICAL_MAP_JSON='{"Industry": ID, ...}'
|
||||
# PERSONA_MAP_JSON='{"Role": ID, ...}'
|
||||
@@ -1 +0,0 @@
|
||||
GEMINI_API_KEY=AIzaSyBNg5yQ-dezfDs6j9DGn8qJ8SImNCGm9Ds
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -68,6 +68,3 @@ Log_from_docker/
|
||||
# Node.js specific
|
||||
!package.json
|
||||
!package-lock.json
|
||||
|
||||
.gemini/.env
|
||||
gemini_api_key.txt
|
||||
|
||||
@@ -1 +1 @@
|
||||
admin:$6$un97dUtWx4rc/Qcr$Xo7oatwiWn8F7lPiSCHI56K3OK7k0rHztVp2kfl78Kk6juw5KTwWlwU07PGgDGY5mQiGZzDy4O0UhIVvR5HsC.
|
||||
admin:$1$RzTlC0sX$L2VQ31MyQ0Wefz1vNG7Yf1
|
||||
|
||||
@@ -1,277 +0,0 @@
|
||||
# Technisches Zielbild: GTM-Engine & Google Cloud Integration
|
||||
|
||||
Dieses Dokument beschreibt die Architektur und den Datenfluss der **GTM-Engine (Go-to-Market Engine)**.
|
||||
|
||||
## Executive Summary: Was wir tun
|
||||
Wir automatisieren die **Qualifizierung von B2B-Accounts** (Firmen), um den Vertrieb gezielter und effizienter zu steuern ("Whale Hunting").
|
||||
|
||||
Anstatt dass ein Mitarbeiter manuell 20 Minuten lang Webseiten liest, um herauszufinden, ob eine Firma relevant ist, übernimmt dies das System automatisiert:
|
||||
1. **Input:** Firmenname & Webseite (aus CRM oder Lead-Liste).
|
||||
2. **Analyse:** Wir aggregieren öffentlich verfügbare Daten (Website-Text, Impressum, Wikipedia).
|
||||
3. **KI-Verarbeitung:** Ein Sprachmodell (Gemini) agiert als "Lese-Assistent". Wir stellen ihm gezielte Fragen an den Kontext (z.B. *"Hat diese Firma mehr als 500 Mitarbeiter?", "Nutzen sie Roboter?", "Sind sie im Bereich Logistik tätig?"*).
|
||||
4. **Output:** Strukturierte Daten (Branche, Potential-Score, Summary) fließen zurück ins CRM zur Vertriebssteuerung.
|
||||
|
||||
**Wichtig:** Es findet **keine** automatisierte Entscheidung über natürliche Personen statt. Wir bewerten Firmen-Potentiale.
|
||||
|
||||
## Kern-Prinzipien
|
||||
1. **Trennung von Identität & Daten:** Nutzung von Unternehmens-Identitäten (Managed Google ID) statt privater Konten.
|
||||
2. **Datensparsamkeit:** KI-Verarbeitung erfolgt primär auf anonymen Firmendaten (B2B), nicht auf Personendaten.
|
||||
3. **Lokale Hoheit:** Die Business-Logik (Python/Docker) läuft kontrolliert lokal oder im Intranet, nicht "in der Cloud".
|
||||
|
||||
## Anleitung für IT & Setup (Schritt-für-Schritt)
|
||||
|
||||
Ziel: Bereitstellung von zwei GCP-Projekten für die Nutzung von Vertex AI / Gemini API durch Christian (Floke).
|
||||
|
||||
### Schritt 1: Projekte anlegen (Durch IT Admin)
|
||||
Bitte in der Google Cloud Console (`console.cloud.google.com`) zwei neue Projekte erstellen.
|
||||
* **Projekt 1 Name:** `roboplanet-ai-dev` (Sandbox/Entwicklung)
|
||||
* **Projekt 2 Name:** `roboplanet-ai-prod` (Live-System/Tools)
|
||||
* **Organisation:** `wackler-group.de` (oder entsprechende Root-Org).
|
||||
|
||||
### Schritt 2: Billing verknüpfen (Durch IT Admin)
|
||||
Beide Projekte müssen mit dem zentralen Firmen-Rechnungskonto (Billing Account) verknüpft werden.
|
||||
* In der Projektübersicht -> "Abrechnung" -> "Abrechnung verknüpfen".
|
||||
* Dies ist zwingend erforderlich, um kostenpflichtige APIs (Vertex AI) nutzen zu können.
|
||||
|
||||
### Schritt 3: Berechtigungen für Christian setzen (Durch IT Admin)
|
||||
Christian benötigt vollen Zugriff auf diese Projekte, um APIs zu aktivieren und Keys zu verwalten.
|
||||
* Gehe zu **IAM & Verwaltung** -> **IAM**.
|
||||
* Füge User `christian.floke@...` hinzu (bzw. deine exakte Mail).
|
||||
* **Rolle:** `Inhaber` (Owner) oder mindestens `Editor` + `Project IAM Admin` + `Service Usage Admin`.
|
||||
|
||||
### Schritt 4: API Aktivierung & Key-Erstellung (Durch Christian / Floke)
|
||||
Sobald die Projekte da sind, führe ich folgende Schritte durch:
|
||||
|
||||
1. **Login im Google AI Studio:**
|
||||
* Gehe auf [aistudio.google.com](https://aistudio.google.com).
|
||||
* Login mit dem Wackler-Konto.
|
||||
|
||||
2. **Projekt-Verknüpfung (Der "Enterprise Switch"):**
|
||||
* Klick auf "Settings" oder "API Key".
|
||||
* Wähle **"Link to Google Cloud Project"**.
|
||||
* Wähle `roboplanet-ai-dev` (oder `prod`) aus der Liste aus.
|
||||
* *Effekt:* Ab jetzt läuft die Abrechnung über GCP (Pay-per-Use) und es gelten die Enterprise-Datenschutzbedingungen (Kein Training).
|
||||
|
||||
3. **API Key erstellen:**
|
||||
* Klick auf **"Create API Key"**.
|
||||
* Wähle das verknüpfte GCP-Projekt.
|
||||
* Kopiere den Key (`AIza...`) sicher weg.
|
||||
|
||||
4. **Environment Variablen setzen (Lokal):**
|
||||
Damit wir Dev und Prod sauber trennen, nutzen wir standardisierte Variablennamen in `.env` Dateien:
|
||||
* `GEMINI_API_KEY_DEV` -> Für CLI, OpenClaw, lokale Tests (Projekt: `roboplanet-ai-dev`)
|
||||
* `GEMINI_API_KEY_PROD` -> Für Company Explorer, GTM-Engine (Projekt: `roboplanet-ai-prod`)
|
||||
|
||||
### Checklist für den Termin
|
||||
- [ ] Projekte `roboplanet-ai-dev` und `roboplanet-ai-prod` existieren.
|
||||
- [ ] Billing ist auf beiden Projekten aktiv (kein "Free Trial" Limit, sondern echtes Billing).
|
||||
- [ ] Mein User hat `Owner` Rechte auf den Projekten.
|
||||
|
||||
## Architektur-Übersicht
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
%% Subgraph: Corporate Environment (Wackler/RoboPlanet)
|
||||
subgraph Corporate_IT ["🏢 Wackler / RoboPlanet Umgebung"]
|
||||
|
||||
subgraph User_Layer ["🧑💻 User & Identity"]
|
||||
User[("Christian (User)")]
|
||||
CorpID["Corporate Google ID<br/>(@roboplanet.de / @wackler-group.de)"]
|
||||
User --> CorpID
|
||||
end
|
||||
|
||||
subgraph Local_Execution ["⚙️ Execution Layer (Local/Server)"]
|
||||
Docker["🐳 Docker Container<br/>(GTM-Engine / Python)"]
|
||||
|
||||
subgraph Data_Handling ["🛡️ Daten-Verarbeitung"]
|
||||
RawData[("Rohdaten<br/>(Websites, Listen)")]
|
||||
Anonymizer["⚙️ Pre-Processing<br/>(Filterung PII / Personendaten)"]
|
||||
end
|
||||
|
||||
ResultStorage[("Ergebnisse<br/>(Notion / CRM / Excel)")]
|
||||
end
|
||||
end
|
||||
|
||||
%% Subgraph: Google Cloud Platform (Managed)
|
||||
subgraph Google_Cloud ["☁️ Google Cloud Platform (Enterprise Tenant)"]
|
||||
|
||||
subgraph IAM_Security ["🔐 Security & Billing"]
|
||||
GCP_Project["GCP Projekt<br/>(z.B. 'gtm-engine-prod')"]
|
||||
ServiceAccount["🤖 Service Account<br/>(Technischer User für API)"]
|
||||
Billing["💳 Corporate Billing<br/>(Zentrale Abrechnung)"]
|
||||
end
|
||||
|
||||
subgraph AI_Services ["🧠 AI Services (Vertex AI / Gemini)"]
|
||||
GeminiAPI["⚡ Gemini API<br/>(Enterprise Mode: Zero Logging)"]
|
||||
end
|
||||
end
|
||||
|
||||
%% Data Flow Connections
|
||||
CorpID -.->|"Verwaltet"| GCP_Project
|
||||
Docker -->|"Nutzt API Key"| ServiceAccount
|
||||
ServiceAccount -->|"Authentifiziert"| GeminiAPI
|
||||
|
||||
RawData --> Anonymizer
|
||||
Anonymizer -->|"1. Anonymisierter Prompt<br/>(Nur Firmendaten)"| Docker
|
||||
Docker -->|"2. API Request (HTTPS/TLS)"| GeminiAPI
|
||||
GeminiAPI -->|"3. JSON Response<br/>(Strukturierte Daten)"| Docker
|
||||
Docker -->|"4. Speicherung"| ResultStorage
|
||||
|
||||
%% Styling
|
||||
style Corporate_IT fill:#f9f9f9,stroke:#333,stroke-width:2px
|
||||
style Google_Cloud fill:#e8f0fe,stroke:#4285f4,stroke-width:2px
|
||||
style Anonymizer fill:#fff3e0,stroke:#f57c00,stroke-dasharray: 5 5
|
||||
style GeminiAPI fill:#e8f0fe,stroke:#4285f4,stroke-width:4px
|
||||
```
|
||||
|
||||
## Erläuterung für die IT
|
||||
|
||||
1. **Identity (IAM):**
|
||||
* Es wird kein "Schatten-Account" genutzt. Christian authentifiziert sich mit seiner bestehenden Corporate Identity (`@roboplanet`).
|
||||
* Für die automatisierte Ausführung (Skripte) wird später ein **Service Account** beantragt, dessen Schlüssel (JSON Key) sicher im lokalen Container verwaltet wird (Secrets Management).
|
||||
|
||||
2. **Google Cloud Projekt:**
|
||||
* Wir benötigen ein dediziertes GCP-Projekt (z.B. `rp-marketing-intel`), das im Rechnungskreis der Firma hängt.
|
||||
* Vorteil: Volle Transparenz über Kosten und Nutzung im Admin-Dashboard der IT.
|
||||
|
||||
3. **Environment Strategie (Dev/Prod Trennung):**
|
||||
* Um Entwicklungskosten von Betriebskosten sauber zu trennen und die Stabilität zu gewährleisten, werden **zwei separate GCP-Projekte** empfohlen:
|
||||
* **`rp-marketing-intel-dev`**: Sandbox für Entwicklung (Gemini CLI, Tests). Hier können Budgets gedeckelt und Quotas flexibel genutzt werden, ohne den Betrieb zu gefährden ("Blast Radius" Minimierung).
|
||||
* **`rp-marketing-intel-prod`**: Stabile Umgebung für den Company Explorer. Exklusive Quotas und striktes Monitoring für den operativen Betrieb.
|
||||
|
||||
4. **Datenschutz (DSGVO):**
|
||||
* **Input:** Wir senden Webseiten-Texte und Firmennamen an die API. Wir senden *keine* Mitarbeiterlisten oder Kunden-Adressdaten zur Analyse.
|
||||
* **Enterprise-Garantie:** Durch Nutzung der Enterprise-Verträge (via GCP) ist vertraglich geregelt, dass Google die Daten **nicht** zum Training eigener Modelle verwendet (anders als bei der kostenlosen ChatGPT/Gemini-Consumer-Version).
|
||||
|
||||
## Datenschutz- & Lizenz-Architektur (Das Zwei-Wege-Modell)
|
||||
|
||||
Um maximale Sicherheit und Compliance zu gewährleisten, trennen wir technisch strikt zwischen **Automatisierung** (Massenverarbeitung) und **Assistenz** (Ad-hoc Arbeit).
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
User[User: Floke]
|
||||
|
||||
subgraph Safe_Space_GCP ["Pfad A: Die Engine (Automation)"]
|
||||
style Safe_Space_GCP fill:#e6f4ea,stroke:#137333
|
||||
API[Python Scripts / GTM-Engine] --> Vertex[Google Vertex AI API]
|
||||
Vertex --> Processing[Data Processing in EU]
|
||||
Processing -- "No Training / Zero Retention" --> Output_API[Strukturierte Daten]
|
||||
end
|
||||
|
||||
subgraph Safe_Space_Workspace ["Pfad B: Der Assistent (Custom Chat)"]
|
||||
style Safe_Space_Workspace fill:#e8f0fe,stroke:#1967d2
|
||||
Browser[Browser / Local App] --> PythonApp[Custom Python Chatbot]
|
||||
PythonApp -- "Nutzt API Key" --> Vertex
|
||||
end
|
||||
|
||||
User -- "Programming / CLI" --> API
|
||||
User -- "Manual / Chat" --> Browser
|
||||
```
|
||||
|
||||
### Pfad A: Die Engine (Google Cloud Platform / Vertex AI)
|
||||
* **Einsatzzweck:** Automatisierte Skripte, Massenanalyse (GTM-Engine), Coding.
|
||||
* **Lizenz:** Pay-per-Use (über GCP Projekt). Keine User-Lizenz erforderlich.
|
||||
* **Datenschutz:**
|
||||
* **GCP Enterprise Terms:** Standardmäßig **kein Training** auf Kundendaten.
|
||||
* **Region Lock:** Datenverarbeitung wird technisch auf `europe-west3` (Frankfurt) oder `europe-west4` gezwungen.
|
||||
* **Zero Retention:** API-Calls werden nach Verarbeitung gelöscht (stateless).
|
||||
|
||||
### Pfad B: Der Assistent (Lokaler Chatbot)
|
||||
* **Einsatzzweck:** Ad-hoc Chat und Textarbeit.
|
||||
* **Lösung:** Da wir keine Gemini-Lizenzen für 350 User kaufen, bauen wir ein **eigenes, leichtgewichtiges Chat-Interface** (Python Streamlit).
|
||||
* **Datenschutz:** Dieses Tool greift auf **Pfad A (GCP API)** zu.
|
||||
* Vorteil: Wir nutzen die sichere Enterprise-API, ohne Office-Lizenzen ändern zu müssen.
|
||||
* Daten bleiben im kontrollierten GCP-Bereich.
|
||||
|
||||
## Strategie zur Lizenzierung & Kosten (Der "Cloud Identity Free" Ansatz)
|
||||
|
||||
**Ausgangslage:**
|
||||
RoboPlanet nutzt aktuell den **"Cloud Identity Free"** Tarif (primär für Android-Geräte-Verwaltung). Ein Upgrade auf kostenpflichtige Workspace-Lizenzen für alle 350+ User würde immense Fixkosten verursachen.
|
||||
|
||||
**Lösung: Entkopplung von User-Lizenz und KI-Leistung**
|
||||
Wir vermeiden ein globales Lizenz-Upgrade. Stattdessen nutzen wir die **Google Cloud Platform (GCP)**.
|
||||
* **Technik:** GCP-Projekte sind technisch vom Office-Tarif entkoppelt.
|
||||
* **Kosten:** Wir zahlen rein nutzungsbasiert (Pay-per-Use) für die API-Aufrufe.
|
||||
* **Vorteil:** Keine Änderung am bestehenden "Free Tier" Vertrag notwendig. Enterprise-Security gilt im GCP-Projekt automatisch.
|
||||
|
||||
## Spickzettel für den Termin (Fragen & Argumente)
|
||||
|
||||
### 1. Zum Lizenz-Status ("Kostenvermeidung")
|
||||
**Argument:**
|
||||
"Wir wollen auf keinen Fall für 350 User neue Lizenzen kaufen müssen, nur damit ich KI nutzen kann. Da wir im 'Cloud Identity Free' Tarif sind, ist der Weg über die **Google Cloud Platform (GCP)** der einzig sinnvolle. Dort zahlen wir nur, was wir verbrauchen (Pay-per-Use), ohne den Hauptvertrag anzufassen."
|
||||
|
||||
### 2. Zur Architektur ("Safe Space GCP")
|
||||
**Argument:**
|
||||
"Im GCP-Projekt gelten automatisch die B2B-Enterprise-Terms (kein Training auf Daten), egal welchen Status mein User hat. Ich werde technisch erzwingen, dass die Datenverarbeitung in **Frankfurt (europe-west3)** stattfindet."
|
||||
|
||||
### 3. Zur Datennutzung
|
||||
**Angebot:**
|
||||
"Ich richte eine strikte Trennung ein:
|
||||
* **Entwicklung (Dev):** Hier testen wir.
|
||||
* **Produktion (Prod):** Hier laufen die Tools.
|
||||
Dadurch verhindern wir, dass Testdaten in produktive Systeme gelangen oder Kosten aus dem Ruder laufen."
|
||||
|
||||
## Vorlage: Nachricht an die IT (Teams/Mail)
|
||||
|
||||
Hi [Name],
|
||||
|
||||
ich habe mich nochmal tiefer in die Google-Lizenz-Thematik eingegraben. Da wir ja aktuell im 'Cloud Identity Free' Tarif sind (primär für die Android-Geräte), würde ein Upgrade oder Lizenz-Wechsel bei 350 Usern ja sofort massive Kosten verursachen. Das wollen wir auf keinen Fall auslösen, nur weil ich ein Tool brauche.
|
||||
|
||||
**Daher mein Vorschlag für den schlanken Weg:**
|
||||
Wir lassen an den User-Konten/Lizenzen (Free Tier) alles exakt so, wie es ist.
|
||||
Stattdessen nutzen wir für meine KI-Themen einfach die **Google Cloud Platform (GCP)**. Ich habe gesehen, dass ich darauf mit meinem User sogar schon Zugriff habe.
|
||||
|
||||
Das GCP-Projekt ist technisch komplett unabhängig vom Office-Tarif. Wir zahlen dort rein **Pay-per-Use** für die API-Aufrufe (oft nur Cent-Beträge im laufenden Betrieb).
|
||||
|
||||
**Was mir dafür noch fehlt, ist das 'Billing' (Rechnungskonto):**
|
||||
Aktuell kann ich keine APIs aktivieren, weil kein Zahlungsmittel hinterlegt ist.
|
||||
|
||||
**Meine Bitte:**
|
||||
Könntet ihr mir bitte **zwei Projekte** anlegen und mit dem zentralen Firmen-Rechnungskonto verknüpfen?
|
||||
|
||||
1. `roboplanet-ai-dev` (Für Entwicklung & Tests, Sandbox)
|
||||
2. `roboplanet-ai-prod` (Für den stabilen Betrieb der Tools)
|
||||
|
||||
Danach könnt ihr mir einfach **Owner-Rechte** auf diese beiden Projekte geben. Den Rest (API-Aktivierung, Service Accounts, Region-Lock auf Frankfurt) richte ich dann selbst ein.
|
||||
|
||||
Das wäre die sauberste Lösung: Keine Fixkosten durch Lizenz-Upgrades, klare Trennung von Spielwiese und Produktion, und volle Kostentransparenz.
|
||||
|
||||
## Backend API (Company Explorer)
|
||||
|
||||
Das System verfügt bereits über eine standardisierte, dokumentierte API (FastAPI) zur Datenverarbeitung. Dies ermöglicht eine saubere Trennung von Frontend und Backend sowie eine granulare Zugriffskontrolle.
|
||||
|
||||
**Core Endpoints:**
|
||||
|
||||
| Methode | Pfad | Beschreibung |
|
||||
| :--- | :--- | :--- |
|
||||
| `GET` | `/api/health` | System Status Check |
|
||||
| `GET` | `/api/companies` | Liste von Unternehmen (Filterbar, Sortierbar) |
|
||||
| `GET` | `/api/companies/{id}` | Detailansicht eines Unternehmens |
|
||||
| `POST` | `/api/companies` | Manuelle Anlage eines Unternehmens |
|
||||
| `POST` | `/api/companies/bulk` | Massenimport (Batch-Processing) |
|
||||
| `GET` | `/api/companies/export` | CSV Export der angereicherten Daten |
|
||||
|
||||
**Enrichment & KI-Analyse:**
|
||||
|
||||
| Methode | Pfad | Beschreibung |
|
||||
| :--- | :--- | :--- |
|
||||
| `POST` | `/api/enrich/discover` | Startet Discovery-Prozess (Website-Suche) |
|
||||
| `POST` | `/api/enrich/analyze` | Startet KI-Analyse (Scraping + Klassifizierung) |
|
||||
| `PUT` | `/api/companies/{id}/industry` | Manuelle Korrektur der KI-Branchenzuordnung |
|
||||
| `POST` | `/api/companies/{id}/override/*` | Manuelle Overrides für kritische Datenquellen (Website, Wikipedia, Impressum) |
|
||||
|
||||
**Quality Assurance:**
|
||||
|
||||
| Methode | Pfad | Beschreibung |
|
||||
| :--- | :--- | :--- |
|
||||
| `POST` | `/api/companies/{id}/report-mistake` | Melden von Datenfehlern ("Human in the Loop") |
|
||||
| `GET` | `/api/mistakes` | Übersicht gemeldeter Fehler zur Überprüfung |
|
||||
| `PUT` | `/api/mistakes/{id}` | Status-Update für Fehlermeldungen (Approved/Rejected) |
|
||||
|
||||
**Stammdaten & Kataloge:**
|
||||
|
||||
| Methode | Pfad | Beschreibung |
|
||||
| :--- | :--- | :--- |
|
||||
| `GET` | `/api/robotics/categories` | Katalog der Robotik-Kategorien |
|
||||
| `GET` | `/api/industries` | Katalog der Branchen |
|
||||
| `GET` | `/api/job_roles` | Katalog der Job-Rollen |
|
||||
@@ -1,467 +0,0 @@
|
||||
A) Überblick
|
||||
|
||||
* Kontext des Dialogs (2–5 Sätze): Der Chat dreht sich um den Aufbau eines stabilen “Dev-Betriebssystems” rund um deine Synology/Docker-Umgebung: zuverlässiger Zugriff auf dein Gitea-Repo (Brancheneinstufung2), funktionierende Notion-Integration (#task/#fertig inkl. Zeiterfassung/Status-Reports) und die Umsetzung eines Features im “Meeting Assistant / Transcription Tool” (Ordner + Tags inkl. Tag-Vorschläge). Parallel tauchen wiederholt Infrastruktur-Probleme auf (DNS/Timeouts beim Repo-Zugriff, falsche Branch-/Pfadannahmen, unklare Persistenz von Moltbot-Memories).
|
||||
* Ziel(e) des Users:
|
||||
|
||||
* Stabiler, vollständiger Repo-Zugriff (keine “Download-Ausreden”), inkl. DNS-Workaround.
|
||||
* Verlässlicher #task/#fertig-Workflow mit Notion-Sync, inkl. Zeiterfassung als Zahl (für Rollups), Status-Reports als Page-Content.
|
||||
* Feature: Transkripte in Ordnern organisieren + taggen; existierende Tags sollen beim Vertaggen vorgeschlagen werden; ohne API-Overengineering (“dead simple tool”).
|
||||
* Operatives Arbeitsmodell: “Planen vor Entwickeln”, schnelle Iteration, proaktive Updates/Heartbeat.
|
||||
* Datensicherheit: Moltbot-Workspace/Memories müssen persistent + backupped sein.
|
||||
* Ergebnisstand:
|
||||
|
||||
* Entschieden/umgesetzt: Einführung #task/#fertig (Konzept), Option “Neuen Task anlegen”, Notion-Task für “Ordner und Tags…” wurde am Ende sichtbar angelegt; Status-Block wurde nach “Go” in Notion geschrieben; Total Duration (h) wurde als Zahl gepflegt (2.5h) und der sichtbare Textblock wurde nachträglich bereinigt (ohne “ca. 02:30”).
|
||||
* Offen/unklar: Feature-Code ist zeitweise am falschen Ort gelandet (Streamlit root app.py vs. laufender Container uvicorn backend.app); Push/Branch-Ziel war falsch bzw. nicht sichtbar (“Already up to date”); Persistenzpfad der Moltbot-Memories ist widersprüchlich/unklar (clawd vs .clawdbot/persistent_clawd).
|
||||
|
||||
B) Themencluster mit Detail-Extraktion
|
||||
|
||||
Cluster 1: Initialer Kontext & “Memory-Absorption”
|
||||
|
||||
* Kernaussage: Zu Beginn wird behauptet, eine alte Datenbank (main.sqlite) sei “absorbiert” und daraus seien Memory-Artefakte rekonstruiert; später zeigt sich, dass der sichtbare Inhalt der Tageslogs sehr dünn ist.
|
||||
* Extrahierte Fakten (Detail-Ebene):
|
||||
|
||||
* Datenquelle: “main.sqlite” (alte DB) wurde verarbeitet/“absorbiert”.
|
||||
* Artefakte: MEMORY.md, tägliche Logs (z. B. 2026-02-13.md) werden als Ergebnis erwähnt.
|
||||
* Späterer Ist-Stand: Datei 2026-02-13.md enthält “nur” einen WhatsApp-Splitter; Begründung: mehr stand für den Tag nicht in der DB.
|
||||
* Anforderungen / Constraints / Kriterien:
|
||||
|
||||
* Keine Erfindungen: nur Daten, die tatsächlich in DB/Logs stehen.
|
||||
* Memory-Pflege soll zuverlässig erfolgen.
|
||||
* Entscheidungen & Begründungen:
|
||||
|
||||
* Entscheidung (implizit): Memory-Führung über MEMORY.md + Tageslogs als “Historie”.
|
||||
* Begründung: Sitzungshistorie/“Gedächtnisproblem” aus Gemini CLI soll durch Doku/Readmes gelöst werden.
|
||||
* Abhängigkeiten & Voraussetzungen:
|
||||
|
||||
* Zugriff auf die tatsächlichen persistierten Dateien (Host-Mount) muss stimmen (später strittig).
|
||||
* Risiken / Probleme / Streitpunkte:
|
||||
|
||||
* Widerspruch zwischen “vollständig absorbiert” und “sichtbar kaum Inhalt”; Risiko falscher Erwartung an “Memory-Wiederherstellung”.
|
||||
* Offene Fragen im Cluster:
|
||||
|
||||
* Welche Inhalte sollten wirklich in MEMORY.md “Ground Truth” vs. nur in Tageslogs landen?
|
||||
* Fundstellen:
|
||||
|
||||
* „…absorbed the database (main.sqlite)…“ (früh, Zeile 1)
|
||||
* „…steht dort nur:“ (nahe Ende, Zeile 2401)
|
||||
* „…nur ein einzelner … Splitter…“ (nahe Ende, Zeilen 2408–2410)
|
||||
|
||||
Cluster 2: Rollen-/Arbeitsmodus & Prozessregeln (Planen, Pragmatismus, Proaktivität)
|
||||
|
||||
* Kernaussage: Du definierst klar: du bist kein Entwickler; es wird geplant, bevor Code entsteht; schnell & pragmatisch; proaktive Kommunikation (Heartbeat) ist Pflicht.
|
||||
* Extrahierte Fakten (Detail-Ebene):
|
||||
|
||||
* User-Constraint: „ICH bin kein Entwickler… verstehe absolut nichts von Code.“
|
||||
* Prozessregel: „BEVOR wir entwickeln planen wir!“
|
||||
* Zielarchitektur: “viele kleine Tools” statt “ein Tool, was alles erschlägt”.
|
||||
* Iterationsprinzip: “schnell entwickeln um früh Ergebnisse zu sehen”.
|
||||
* Kommunikationspflicht: proaktive Rückmeldung; nicht “anhauen” müssen.
|
||||
* Heartbeat-Anforderung: alle 2 Minuten Mini-Update (3–5 Stichpunkte).
|
||||
* Anforderungen / Constraints / Kriterien:
|
||||
|
||||
* Keine “internal voice” / keine Tool-Artefakte oder Code-Rohblöcke in Antworten.
|
||||
* Proaktive Statusmeldungen nach Abschluss jeder Aufgabe.
|
||||
* Entscheidungen & Begründungen:
|
||||
|
||||
* Entscheidung: Einführung “Mini-Updates alle 2 Minuten” als Transparenzersatz für CLI-Logs.
|
||||
* Begründung: In Chat fehlt sichtbarer Fortschritt (“black box”); Gemini CLI bietet Live-Feedback.
|
||||
* Abhängigkeiten & Voraussetzungen:
|
||||
|
||||
* Disziplin im Kommunikationsrhythmus; klare Definition, was “fertig” bedeutet (Push + Hinweis “Restart Container X”).
|
||||
* Risiken / Probleme / Streitpunkte:
|
||||
|
||||
* Mehrfacher Verstoß: Update kam nach 7 Minuten statt 2.
|
||||
* Offene Fragen im Cluster:
|
||||
|
||||
* Welche Aktionen gelten als “done” (Code geändert vs. gepusht vs. deployed/tested)?
|
||||
* Fundstellen:
|
||||
|
||||
* „ICH bin kein Entwickler…“ (früh-mittig, Zeile 400)
|
||||
* „BEVOR wir entwickeln planen wir!“ (mittig, Zeile 415)
|
||||
* „alle 2 Minuten … mini-update…“ (mittig, Zeile 1027)
|
||||
* „letzte Rückmeldung ist 7 Minuten her…“ (mittig, Zeile 1071)
|
||||
|
||||
Cluster 3: Gitea/Repo-Zugriff, Auth & Stabilität (Token, DNS, Timeout)
|
||||
|
||||
* Kernaussage: Repo-Zugriff war instabil (Auth-Fehler, Gateways/Timeout, DNS-Resolution). Du verlangst vollständigen Zugriff ohne Ausreden; als Workaround wird /etc/hosts mit externer IP eingesetzt.
|
||||
* Extrahierte Fakten (Detail-Ebene):
|
||||
|
||||
* Gitea Host: floke-gitea.duckdns.org (öffentlich), intern 192.168.178.6:3000 (nicht erreichbar von extern).
|
||||
* DNS-Fehler: “Failed to resolve 'floke-gitea.duckdns.org'” tritt auf.
|
||||
* Repo-Download via Skript: fetch_repo_files.py, Zähler “937 Dateien”, später “über 700 von 937”.
|
||||
* Dynamische IP: aktuell 84.190.111.140; DuckDNS wegen wechselnder IP.
|
||||
* /etc/hosts Workaround: Eintrag 84.190.111.140 → floke-gitea.duckdns.org wurde gesetzt; gilt bis IP-Wechsel.
|
||||
* User-Constraint: „DU MUSST alle Dateien im Zugriff haben… Ausreden … gelten ab jetzt nicht mehr.“ (im Chat: Zeile 1895)
|
||||
* Anforderungen / Constraints / Kriterien:
|
||||
|
||||
* Repo vollständig verfügbar; Tools dürfen “die ganze Nacht” laufen.
|
||||
* Keine Abhängigkeit von instabilem DNS ohne Fallback.
|
||||
* Entscheidungen & Begründungen:
|
||||
|
||||
* Entscheidung: /etc/hosts Hard-Pinning als kurzfristiger Fix.
|
||||
* Begründung: DNS-NameResolutionError verhindert zuverlässigen Pull/Dateizugriff.
|
||||
* Abhängigkeiten & Voraussetzungen:
|
||||
|
||||
* Externe IP muss bekannt sein (oder aus DNS ableitbar); bei IP-Wechsel erneuern.
|
||||
* Risiken / Probleme / Streitpunkte:
|
||||
|
||||
* /etc/hosts ist fragil bei dynamischer IP (bekannt/benannt).
|
||||
* “Massen-Download” kann Dateien überspringen → Inkonsistenzen.
|
||||
* Offene Fragen im Cluster:
|
||||
|
||||
* Soll es einen dauerhaften Sync-Mechanismus geben (Cron) und wo läuft er tatsächlich (Host vs Container)?
|
||||
* Fundstellen:
|
||||
|
||||
* „Failed to resolve…“ (mittig, Zeile 601)
|
||||
* „Ausreden… gelten ab jetzt nicht mehr.“ (mittig, Zeile 1895)
|
||||
* „IP … 84.190.111.140… /etc/hosts…“ (mittig, Zeilen 1926–1933)
|
||||
|
||||
Cluster 4: Notion-Integration & #task/#fertig-Betriebssystem (inkl. Zeiterfassung)
|
||||
|
||||
* Kernaussage: #task/#fertig wird als Trigger-Protokoll etabliert, soll Notion-Projekte/Tasks synchronisieren und Zeit als numerisches Feld pro Task pflegen; mehrere Fehlversuche durch falsche DB-IDs; am Ende ist der Task sichtbar und Status-Reports werden korrekt als Page-Content geschrieben.
|
||||
* Extrahierte Fakten (Detail-Ebene):
|
||||
|
||||
* Trigger: #task startet, #fertig beendet.
|
||||
* Freigabe: Zusammenfassung/Todos vorformulieren, User gibt 👍/❤️/Go frei.
|
||||
* Projekte-Liste in Notion [UT] (23 Projekte), u. a. [15] “Meeting Assistant (Transkription Tool)”.
|
||||
* Notion-Fehler: 400 Bad Request wegen falscher IDs/Filter.
|
||||
* Projekt-ID für Meeting Assistant: 2f388f42-8544-80fb-ae36-f6879ec535a4.
|
||||
* Tasks-DB-ID war falsch; später “korrekte Datenbank-ID”: 2e888f42-8544-8153-beac-e604719029cf.
|
||||
* Task-Anlage am Ende erfolgreich: „task ist jetzt da!“.
|
||||
* Status-Reports als Page-Content: Beispiel-Format “## 🤖 Status-Update (YYYY-MM-DD HH:MM Berlin Time)” und YAML/Code-Block.
|
||||
* Zeit-Constraint: Zeit muss als Zahl/Integer am Task gespeichert werden (Rollup summiert Projekte automatisch).
|
||||
* Korrektur: Text “ca. 02:30” blieb sichtbar, Datenfeld wurde separat aktualisiert (2.5); danach wurde Textblock gelöscht/neu gepostet; User bestätigt “schaut gut aus”.
|
||||
* Anforderungen / Constraints / Kriterien:
|
||||
|
||||
* Option “Neuen Task anlegen” muss immer angeboten werden.
|
||||
* Keine Code-Snippets/“internal voice” in Chat-Antworten.
|
||||
* Zeit als numerisches Feld (Total Duration (h)), kein “ca.” im sichtbaren Report, Konsistenz zwischen Textblock und Feld.
|
||||
* Entscheidungen & Begründungen:
|
||||
|
||||
* Entscheidung: Cache der Project-Liste lokal, stündliche Aktualisierung (Cron-Idee).
|
||||
* Begründung: schneller Zugriff ohne Notion-Latenz.
|
||||
* Entscheidung: Status-Report wird erst nach “Go” geschrieben.
|
||||
* Abhängigkeiten & Voraussetzungen:
|
||||
|
||||
* Notion-Integration muss Zugriff auf DBs haben (Invite/Permissions implizit); korrekte DB-IDs.
|
||||
* Risiken / Probleme / Streitpunkte:
|
||||
|
||||
* Wiederholte Annahmen/“Raten” von IDs führte zu langem Stillstand.
|
||||
* Sichtbare Formatierungsartefakte durch Notion Code-Block Highlighting.
|
||||
* Offene Fragen im Cluster:
|
||||
|
||||
* Wie werden Task-IDs/Session state verlässlich gespeichert (current_task.json) und wo liegt sie (Host-Mount)?
|
||||
* Fundstellen:
|
||||
|
||||
* „…musste … erraten…“ (mittig, Zeile 549)
|
||||
* „task ist jetzt da!“ (spät, Zeile 2021)
|
||||
* „Uhrzeit … als Integer…“ (spät, Zeile 2104)
|
||||
* „jetzt schaut es gut aus!“ (spät, Zeile 2160)
|
||||
|
||||
Cluster 5: Feature-Anforderung “Ordner & Tags” (Scope, Simplifizierung, Tag-Vorschläge)
|
||||
|
||||
* Kernaussage: Du willst Ordner + Tags direkt im Transcription Tool; bestehende Tags sollen vorgeschlagen werden; kein API-Layer, vorher Doku/Code lesen, “dead simple”.
|
||||
* Extrahierte Fakten (Detail-Ebene):
|
||||
|
||||
* Feature-Beschreibung: „Transkripte in Ordner sortieren und vertaggen“ + „Bestehende Tags sollen … vorgeschlagen werden.“
|
||||
* Architektur-Constraint: „Wir benötigen hier keine API … dead simple tool.“
|
||||
* Plan-vor-Code wird eingefordert. (aus Cluster 2, gilt hier)
|
||||
* Erste (spätere) Minimal-Implementationsidee: folder + tags als Spalten in SQLite-Tabelle; UI Felder für Ordner/Tags.
|
||||
* Gap: Tag-Vorschläge waren zunächst nicht umgesetzt (nur freies Textfeld), wurde intern als fehlend erkannt.
|
||||
* Anforderungen / Constraints / Kriterien:
|
||||
|
||||
* Muss: Ordner setzen + Tags speichern.
|
||||
* Muss: existierende Tags vorschlagen (Autocomplete/Multiselect/Hint-Liste).
|
||||
* Soll: keine neue Tabellen/Overengineering, wenn nicht nötig.
|
||||
* Entscheidungen & Begründungen:
|
||||
|
||||
* Entscheidung (proposed): “radikal pragmatisch” → Spalten statt relationales Tag-Schema.
|
||||
* Begründung: “dead simple”, schnelle Iteration.
|
||||
* Abhängigkeiten & Voraussetzungen:
|
||||
|
||||
* Kenntnis des echten Persistenz-/DB-Pfads des Tools (später strittig: transcriptions.db vs meetings.db).
|
||||
* Risiken / Probleme / Streitpunkte:
|
||||
|
||||
* Wenn falsche Codebasis bearbeitet wird, wird Feature nicht im laufenden Tool sichtbar (ist passiert).
|
||||
* Offene Fragen im Cluster:
|
||||
|
||||
* Wo liegt die “Source of Truth” DB (meetings.db?) und welche Tabelle ist maßgeblich (meetings vs transcriptions)?
|
||||
* Fundstellen:
|
||||
|
||||
* „Ordner und Tags… Bestehende Tags… vorgeschlagen…“ (mittig, Zeile 791)
|
||||
* „…keine API… dead simple…“ (mittig, Zeile 864)
|
||||
|
||||
Cluster 6: Codebase-Verwechslung & Deployment-Realität (Streamlit app.py vs uvicorn backend.app)
|
||||
|
||||
* Kernaussage: Es wurde zunächst an einer root app.py (Streamlit) gearbeitet, aber der tatsächlich laufende Container “transcription-app” startet uvicorn backend.app (FastAPI). Dadurch sind Push/Restart-Schritte zunächst wirkungslos bzw. am falschen Artefakt.
|
||||
* Extrahierte Fakten (Detail-Ebene):
|
||||
|
||||
* Laufende Container: transcription-app (ID a74b0b2853ec), Image brancheneinstufung-transcription-app, Command “uvicorn backend.app…”, Port 8001:8001.
|
||||
* Streamlit-Container existiert: lead-engine (great_gauss) läuft “streamlit run app.py”.
|
||||
* Erkenntnis: Diskrepanz wurde explizit benannt; Hypothese: falsche Datei bearbeitet/Legacy.
|
||||
* User-Anforderung: exakten Containername/ID nennen, um Fehl-Restarts zu vermeiden.
|
||||
* Anforderungen / Constraints / Kriterien:
|
||||
|
||||
* Restart-Anweisung muss immer konkret sein: Container-Name + ID.
|
||||
* Vor Änderungen: Build/Entry-Point des Containers verifizieren (docker-compose/Dockerfile/WORKDIR).
|
||||
* Entscheidungen & Begründungen:
|
||||
|
||||
* Entscheidung (nachträglich): erst verifizieren, dann restart.
|
||||
* Begründung: Sonst “eine halbe Stunde das falsche” (dein Feedback-Constraint).
|
||||
* Abhängigkeiten & Voraussetzungen:
|
||||
|
||||
* Repo-Struktur: Es gibt Hinweis, dass das Projekt unter Brancheneinstufung2/transcription-tool liegt.
|
||||
* Risiken / Probleme / Streitpunkte:
|
||||
|
||||
* Falscher Branch/Pfad → Push “nicht sichtbar”; git pull zeigt “Already up to date”.
|
||||
* Offene Fragen im Cluster:
|
||||
|
||||
* Welche Dateien werden in das transcription-app Image kopiert/mounted (WORKDIR, build context)?
|
||||
* Fundstellen:
|
||||
|
||||
* „transcription-app … uvicorn backend.app…“ (mittig, Zeilen 1255–1257)
|
||||
* „Diskrepanz…“ (mittig, Zeilen 1331–1335)
|
||||
* „git pull … Already up to date.“ (spät, Zeilen 2186–2188)
|
||||
|
||||
Cluster 7: Git Branching/Push-Probleme & Repo-Größe
|
||||
|
||||
* Kernaussage: Änderungen wurden nicht dort sichtbar, wo du sie erwartest; es gab Branch-Verwirrung (feature/task… vs master/main) und Klon-Timeouts (Repo “zu groß”).
|
||||
* Extrahierte Fakten (Detail-Ebene):
|
||||
|
||||
* Symptom: git pull auf Synology zeigt “Already up to date”, obwohl Änderungen erwartet.
|
||||
* User-Hypothese: falscher Branch; “transcription-tool … der falsche Branch enthält wohl das komplette Projekt”.
|
||||
* Assistant-Annahme: aktiver Dev-Branch: feature/task-2f488f42-prepare-timetracking-for-projects-tasks-2.
|
||||
* Klon scheitert mit Timeout; Shallow clone (--depth 1) als Gegenmaßnahme.
|
||||
* Anforderungen / Constraints / Kriterien:
|
||||
|
||||
* Push muss verifiziert werden (nach dem Push: sichtbarer Commit/Hash + Remote-Branch).
|
||||
* Repo-Sync robust (DNS/Timeout-resilient).
|
||||
* Entscheidungen & Begründungen:
|
||||
|
||||
* Entscheidung (Plan): lokales kaputtes Verzeichnis löschen, sauber neu klonen, Änderungen neu anwenden, korrekt pushen.
|
||||
* Abhängigkeiten & Voraussetzungen:
|
||||
|
||||
* Stabile Gitea-Erreichbarkeit; korrekter Branch-Name; korrekter Build-Context fürs Deployment.
|
||||
* Risiken / Probleme / Streitpunkte:
|
||||
|
||||
* Wenn der Build auf “transcription-tool/” basiert, sind Änderungen im Root per se irrelevant.
|
||||
* Offene Fragen im Cluster:
|
||||
|
||||
* Welcher Branch ist “deployment-relevant” (main vs feature vs transcription-tool)?
|
||||
* Fundstellen:
|
||||
|
||||
* „I believe you have a wrong branch… transcription-tool…“ (spät, Zeilen 2212–2216)
|
||||
* „…Repo … zu groß … Timeout… shallow clone…“ (spät, Zeilen 2237–2241)
|
||||
|
||||
Cluster 8: Moltbot-Persistenz & Backup (update.sh vs backup.sh; Speicherorte)
|
||||
|
||||
* Kernaussage: Dein bisheriger Job ist ein Update, kein Backup; es wird ein backup.sh vorgeschlagen; gleichzeitig ist unklar/widersprüchlich, wo Moltbot-Memories tatsächlich persistent liegen (clawd vs .clawdbot/persistent_clawd) und wie du sie auf dem Host siehst.
|
||||
* Extrahierte Fakten (Detail-Ebene):
|
||||
|
||||
* Cron/Job: alle zwei Tage nächtliches Update/“Backup”: `/volume1/docker/moltbot/update.sh >> .../backups/update.log`.
|
||||
* update.sh Inhalte: docker compose pull → up -d --force-recreate → image prune -f.
|
||||
* Aussage: Update ≠ Backup; Workspace-Ordner muss separat gesichert werden (tar.gz), Beispielpfad SOURCE_DIR="/volume1/docker/moltbot/moltbot/clawd".
|
||||
* User-Frage: Läuft im Container – sind Memories sicher auf Festplatte?
|
||||
* Assistant-Annahme: docker-compose Volume `./clawd:/home/node/clawd` sei “Lebensversicherung”. (als Annahme formuliert)
|
||||
* Spätere Persistenz-Diskussion: Kopie von /home/node/clawd (flüchtig) nach /home/node/.clawdbot/persistent_clawd (sicher) + inbound media unter /home/node/.clawdbot/media/inbound.
|
||||
* User-Beobachtung: inbound media waren “vorhin” in anderer Ordnerstruktur, jetzt in moltbot/storage/media; Frage “wo ist heutige Sitzung gespeichert”.
|
||||
* Nach docker cp: 2026-02-13.md ist kurz; angeblich normal.
|
||||
* Anforderungen / Constraints / Kriterien:
|
||||
|
||||
* Muss: echtes Backup des persistenten Workspace vor Updates.
|
||||
* Muss: Transparenz, welcher Pfad “Source of Truth” ist und wie er auf Synology sichtbar ist.
|
||||
* Entscheidungen & Begründungen:
|
||||
|
||||
* Entscheidung (User): backup.sh sinnvoll, soll angelegt werden.
|
||||
* Entscheidung (Assistant): “kopieren, nicht löschen” beim Umzug in persistent_clawd, um Risiko zu vermeiden.
|
||||
* Abhängigkeiten & Voraussetzungen:
|
||||
|
||||
* Korrektes Volume-Mapping in docker-compose (unklar, weil angenommen).
|
||||
* Zugriff auf Container-FS vs Host-FS (Synology Pfade).
|
||||
* Risiken / Probleme / Streitpunkte:
|
||||
|
||||
* Widerspruch: Einerseits wird behauptet, persistent_clawd sei “sicher auf Diskstation”; andererseits sieht der User im Host-Pfad zunächst nur main.sqlite bzw. kaum Memories (Interpretation: Persistenzpfad/Mapping stimmt evtl. nicht).
|
||||
* Offene Fragen im Cluster:
|
||||
|
||||
* Welches Verzeichnis ist tatsächlich gemountet: /home/node/clawd oder /home/node/.clawdbot/* ?
|
||||
* Fundstellen:
|
||||
|
||||
* „Nein, … kritisches Missverständnis. … Update … kein Backup.“ (mittig, Zeilen 677–683)
|
||||
* „…Volume… ./clawd:/home/node/clawd“ (mittig, Zeilen 730–746)
|
||||
* „…kopiere … nach … persistent_clawd…“ (spät, Zeilen 2254–2256)
|
||||
|
||||
Cluster 9: “Fehlende Datei / Arbeitsgrundlage”
|
||||
|
||||
* Kernaussage: Du erwartest eine Datei big_Fortschritt_marketingautomation.txt als zentrale Grundlage; sie ist im Container/Repo nicht auffindbar.
|
||||
* Extrahierte Fakten (Detail-Ebene):
|
||||
|
||||
* User-Claim: Datei sei “Blaupause unserer Zusammenarbeit”.
|
||||
* Assistant-Status: Datei im Arbeitsverzeichnis nicht vorhanden.
|
||||
* Anforderungen / Constraints / Kriterien:
|
||||
|
||||
* Muss: Datei bereitstellen oder Alternativquelle nennen.
|
||||
* Entscheidungen & Begründungen:
|
||||
|
||||
* Keine finale Entscheidung; nur Hinweis “nicht gefunden”.
|
||||
* Abhängigkeiten & Voraussetzungen:
|
||||
|
||||
* Datei muss entweder im Repo liegen oder separat hochgeladen werden.
|
||||
* Risiken / Probleme / Streitpunkte:
|
||||
|
||||
* Ohne diese Grundlage ist unklar, welche “bekannten Regeln/Blueprint” bereits als verbindlich gelten.
|
||||
* Offene Fragen im Cluster:
|
||||
|
||||
* Wo liegt die Datei (Repo-Pfad, Synology-Pfad)?
|
||||
* Fundstellen:
|
||||
|
||||
* „…big_Fortschritt_marketingautomation.txt … Blaupause…“ (nahe Ende, Zeilen 2421–2424)
|
||||
|
||||
C) Konsolidierte Artefakte
|
||||
|
||||
1. Glossar / Entitäten-Liste
|
||||
|
||||
* Systeme/Tools
|
||||
|
||||
* Synology DiskStation (“Diskstation2”): Host, auf dem Docker + Repos liegen.
|
||||
* Docker / docker compose: Deployment/Restart/Update-Mechanik.
|
||||
* Gitea: self-hosted Git (Domain floke-gitea.duckdns.org; Container gitea-gitea-pub-1-2).
|
||||
* DuckDNS: DynDNS-Service; Container “duckdns”.
|
||||
* Notion: Project/Task-DBs (“Projects [UT]”, “Tasks [UT]”), Rollups auf Projektebene.
|
||||
* Moltbot (OpenClaw): Container “openclaw-gateway”, Image ghcr.io/openclaw/moltbot:main.
|
||||
* Projekte/Repos
|
||||
|
||||
* Brancheneinstufung2: zentrales Repo; enthält u. a. “transcription-tool”.
|
||||
* Meeting Assistant (Transkription Tool): Notion-Projekt [15].
|
||||
* Lead Engine: Container “great_gauss” (Streamlit).
|
||||
* Skripte/Dateien
|
||||
|
||||
* update.sh: führt docker compose pull/up/prune aus.
|
||||
* backup.sh (vorgeschlagen): tar.gz Backup des Workspace.
|
||||
* dev_session.py: Referenz für Notion-Sync/Status-Reports (als bestehende Logik erwähnt).
|
||||
* fetch_repo_files.py: Datei-für-Datei Download (Resumable).
|
||||
* current_task.json: Session-State (Task-ID, Startzeit) – als Konzept erwähnt.
|
||||
* Personen/Benennungen
|
||||
|
||||
* User: “Floke” (Host-Pfad /volume1/homes/Floke/…, Gitea user).
|
||||
* “Axel”: als Referenz im SystemPrompt (“Seniorität vor Axel”) erwähnt.
|
||||
* Sensible Daten (maskiert)
|
||||
|
||||
* Notion API Token wurde im Chat genannt (beginnt mit „ntn_…“).
|
||||
* Gitea Token wurden im Chat genannt (alt/neu), mehrfach gewechselt (maskiert; konkrete Werte nicht wiederholt).
|
||||
|
||||
2. Timeline (Datums-/Zeitbezüge in Reihenfolge)
|
||||
|
||||
* 2026-01-31 09:31 (Berlin Time): Beispiel eines Status-Update-Blocks (YAML/Code-Block) aus früherer Nutzung.
|
||||
* 2026-02-13: Tageslog 2026-02-13.md enthält nur einen “Splitter” aus main.sqlite.
|
||||
* 2026-02-14 21:00 (Berlin Time): Status-Update für “gestern” wird im gewünschten Format erstellt und nach “Go” gepostet; danach Format/Zeiterfassung bereinigt.
|
||||
* 2026-02-15: Referenz auf “heutige Sitzung” und Pfade 2026-02-15.md (flüchtig vs sicher) wird diskutiert.
|
||||
|
||||
3. Entscheidungslog (Entscheidung → Datum/Phase → Begründung → Auswirkungen)
|
||||
|
||||
* #task/#fertig als Arbeitsprotokoll → Phase “Workflow-Etablierung” → Kontext/History wie Gemini dev_session → Grundlage für Notion-Sync, Zeitmessung, Freigabeprozess.
|
||||
* Option “Neuen Task anlegen” in Taskliste → Phase “Task-UX” → schneller Task-Capture → ermöglicht “Ordner und Tags…” Task-Anlage.
|
||||
* Zeit als numerischer Wert am Task (Total Duration (h)) → Phase “Notion-Qualität” → Rollups brauchen Zahl, kein “ca.” → Textblock + Feld wurden getrennt korrigiert.
|
||||
* /etc/hosts Hard-Pinning auf 84.190.111.140 → Phase “DNS-Stabilisierung” → Download-/Sync-Stabilität kurzfristig → bricht bei IP-Wechsel.
|
||||
* Backup.sh vor update.sh → Phase “Datensicherheit” → Update ist kein Backup → Reduziert Risiko totalen Memory-Verlusts.
|
||||
* “Dead simple, keine API” für Feature → Phase “Scope-Kontrolle” → Minimiert Overengineering → verlangt korrekte Codebase/DB-Pfad-Klärung.
|
||||
|
||||
4. To-do Liste (Aufgabe → Owner → Deadline → Priorität)
|
||||
|
||||
* Verifizieren, welche Codebase der transcription-app Container nutzt (WORKDIR/Build Context; Ordner Brancheneinstufung2/transcription-tool) → Owner: Assistent+User → Prio: Hoch.
|
||||
* Feature “Ordner & Tags” im *tatsächlich laufenden* Tool implementieren (inkl. Tag-Vorschläge) → Owner: Assistent → Prio: Hoch.
|
||||
* Push/Branch-Strategie klären (welcher Branch deployt wird; Push verifizieren) → Owner: Assistent+User → Prio: Hoch.
|
||||
* Backup.sh auf Synology platzieren + Cronjob: backup.sh vor update.sh → Owner: User (Datei verschieben, Cron) → Prio: Hoch.
|
||||
* Persistenzpfad Moltbot endgültig klären (clawd vs .clawdbot/persistent_clawd; Host-Sichtbarkeit) → Owner: Assistent+User → Prio: Hoch.
|
||||
* Datei big_Fortschritt_marketingautomation.txt lokalisieren oder bereitstellen → Owner: User → Prio: Mittel.
|
||||
* Kommunikationsregel operationalisieren (2-Minuten Heartbeat, Abschluss-Ping) → Owner: Assistent → Prio: Hoch.
|
||||
|
||||
5. Anforderungskatalog: Muss / Soll / Kann
|
||||
|
||||
* Muss
|
||||
|
||||
* Planen vor Code.
|
||||
* Proaktive Rückmeldung + Heartbeat alle 2 Minuten während Arbeit.
|
||||
* #task/#fertig Workflow inkl. “Neuen Task anlegen”.
|
||||
* Zeiterfassung als numerischer Wert am Task (für Rollups).
|
||||
* Feature: Ordner + Tags + Vorschläge bestehender Tags.
|
||||
* Repo-Zugriff vollständig und stabil (keine Download-Ausreden).
|
||||
* Backup vor Update (update.sh ist kein Backup).
|
||||
* Soll
|
||||
|
||||
* “Dead simple tool”, keine zusätzliche API-Schicht.
|
||||
* Konkrete Restart-Anweisung inkl. Container-ID/Name.
|
||||
* Keine “internal voice” und keine Roh-Toolausgaben.
|
||||
* Kann
|
||||
|
||||
* Tool-Index/PROJECTS.md als “Kartei”, um Entry-Points/Readmes nicht neu suchen zu müssen.
|
||||
* Automatischer Repo-Sync-Cron (nachts) zur Vermeidung von DNS-/Timeout-Friktion.
|
||||
|
||||
6. Zahlen & Parameter (Bulletliste: Parameter → Wert → Kontext)
|
||||
|
||||
* Gitea interne IP → 192.168.178.6:3000 → lokal erreichbar, extern nicht.
|
||||
* Gitea externe IP (aktuell) → 84.190.111.140 → dyn. IP; /etc/hosts Fix.
|
||||
* Repo Download Count → 937 Dateien (700+ erreicht) → fetch_repo_files.py Fortschritt.
|
||||
* Notion Projekt-ID (Meeting Assistant) → 2f388f42-8544-80fb-ae36-f6879ec535a4 → Task-Filterversuche.
|
||||
* Notion Tasks DB-ID (korrekt) → 2e888f42-8544-8153-beac-e604719029cf → Fix für Task-Creation.
|
||||
* Zeiterfassung (Beispiel) → 2.5 Stunden → Total Duration (h) als Zahl am Task.
|
||||
* Docker transcription-app → ID a74b0b2853ec, Port 8001→8001, Command uvicorn backend.app → tatsächliche Laufzeitbasis.
|
||||
* Docker openclaw-gateway → Port 18789→18789 → Moltbot Gateway.
|
||||
* Update-Frequenz → “alle zwei Tage ein nächtliches Update/Backup” → via update.sh.
|
||||
|
||||
D) Widersprüche & Inkonsistenzen
|
||||
|
||||
Konflikt 1: “Feature in app.py fertig + DB migriert + gepusht” vs “git pull Already up to date / keine Änderungen sichtbar”
|
||||
|
||||
* Aussage A: „…Alles ist … auf dem main-Branch gepusht.“
|
||||
* Aussage B: User: „git pull … Already up to date.“
|
||||
* Warum Konflikt: Entweder wurde nicht gepusht, auf falschen Branch gepusht, oder in falschem Repo/Verzeichnis gearbeitet.
|
||||
* Klärung nötig: Welcher Branch ist “Deployment-Branch”? Wurde überhaupt ein Commit erzeugt? (commit hash/remote branch prüfen).
|
||||
|
||||
Konflikt 2: “Meeting Assistant ist Streamlit (root app.py)” vs “laufender Container transcription-app ist FastAPI (uvicorn backend.app)”
|
||||
|
||||
* Aussage A: „…app.py … Streamlit…“
|
||||
* Aussage B: docker ps zeigt “uvicorn backend.app” für transcription-app.
|
||||
* Warum Konflikt: Es existieren offenbar mehrere Implementationen/Generationen; die bearbeitete Datei war nicht der aktive Entry-Point.
|
||||
* Klärung nötig: Build context/WORKDIR des transcription-app Images; Pfad zu backend/app.py (vermutlich unter Brancheneinstufung2/transcription-tool).
|
||||
|
||||
Konflikt 3: “Volume-Mapping ./clawd:/home/node/clawd” als Lebensversicherung (Annahme) vs User-Beobachtung/Unklarheit über echte Persistenzpfade
|
||||
|
||||
* Aussage A: „…docker-compose… volumes … ./clawd:/home/node/clawd …“ (explizit als Ableitung/Annahme).
|
||||
* Aussage B: User sieht Dateien/Medien an wechselnden Orten (“vorhin … inbound Media … jetzt … moltbot/storage/media”) und fragt nach Speicherort der Sitzung.
|
||||
* Warum Konflikt: tatsächliche Mounts/Pfade sind nicht eindeutig verifiziert; es wird zwischen /home/node/clawd und /home/node/.clawdbot/persistent_clawd unterschieden.
|
||||
* Klärung nötig: docker-compose.yml prüfen: welche Host-Pfade sind gemountet, wohin schreibt Moltbot tatsächlich?
|
||||
|
||||
Konflikt 4: “Alle 2 Minuten Mini-Update” Vorgabe vs reale Kommunikationsabstände
|
||||
|
||||
* Aussage A: Anforderung: „alle 2 Minuten … mini-update“.
|
||||
* Aussage B: User: „deine letzte Rückmeldung ist 7 Minuten her…“
|
||||
* Warum Konflikt: Prozessregel wird nicht eingehalten.
|
||||
* Klärung nötig: Konkretes Protokoll: “Update immer nach X Tool-Schritten” statt Zeit (falls Zeit nicht verlässlich).
|
||||
|
||||
E) Verdichtete Zusammenfassung (max. 12 Bulletpoints)
|
||||
|
||||
* #task/#fertig wurde als zentrales Arbeitsprotokoll definiert; du willst nur “#task”/“#fertig” (ohne Unterstrich) und Freigaben via 👍/❤️/Go.
|
||||
* Notion-Integration war mehrfach blockiert (400 Bad Request), Ursache war u. a. falsche Tasks-DB-ID; korrigiert auf 2e888f42-8544-8153-beac-e604719029cf; danach war der Task sichtbar („task ist jetzt da!“).
|
||||
* Status-Reports sollen als Page-Content (nicht Kommentar) im Task erscheinen; Format “## 🤖 Status-Update (… Berlin Time)” wurde verwendet und nach “Go” gepostet.
|
||||
* Zeiterfassung muss numerisch am Task gepflegt werden (für Rollups); Text “ca. 02:30” wurde als inkonsistent identifiziert und bereinigt; User bestätigt “jetzt schaut es gut aus!”.
|
||||
* Feature-Anforderung: Transkripte in Ordner sortieren + taggen; bestehende Tags müssen vorgeschlagen werden.
|
||||
* Architektur-Constraint: “dead simple tool”, keine API; erst Readme/Code analysieren, dann umsetzen.
|
||||
* Es gab massive Reibung durch fehlende Transparenz: du verlangst proaktive Rückmeldungen und alle 2 Minuten Mini-Updates (3–5 Stichpunkte).
|
||||
* Gitea-Zugriff war instabil (DNS “Failed to resolve…”), Repo-Download via Script (937 Dateien) und /etc/hosts-Fix auf 84.190.111.140; aber dynamische IP macht das fragil.
|
||||
* Push/Deployment war inkonsistent: obwohl “gepusht” behauptet, zeigte git pull “Already up to date”; zudem Branch/Pfad-Verwechslung (“transcription-tool”/feature branch).
|
||||
* Kritische Codebase-Diskrepanz: bearbeitete root app.py (Streamlit) passt nicht zum laufenden transcription-app Container (uvicorn backend.app).
|
||||
* Datensicherheit: dein update.sh ist kein Backup; backup.sh (tar.gz) soll vor Updates laufen.
|
||||
* Persistenzpfade Moltbot sind nicht abschließend geklärt (clawd vs .clawdbot/persistent_clawd); User sieht Medien/Logs an wechselnden Orten.
|
||||
|
||||
F) Rückfragen (max. 10, höchste Hebelwirkung)
|
||||
|
||||
1. Welcher Branch ist der “Deployment-Branch” für transcription-app: main oder ein feature/task-Branch (oder ein eigener “transcription-tool” Branch/Ordner)?
|
||||
2. Was ist der Build-Context/WORKDIR von brancheneinstufung-transcription-app (Dockerfile/docker-compose): wird Brancheneinstufung2/ transcrption-tool/ oder Repo-Root ins Image kopiert?
|
||||
3. Welche DB nutzt das live Tool tatsächlich: meetings.db oder transcriptions.db, und wie heißt die Tabelle (meetings vs transcriptions)?
|
||||
4. Wo soll die Ordner-/Tag-Metadatenstruktur final gespeichert werden: in SQLite (lokal) oder in Notion (zusätzlich/parallel) — oder strikt nur lokal?
|
||||
5. Wie genau sollen Tag-Vorschläge im UI aussehen (Dropdown/Multiselect/Autocomplete) – reicht eine “Suggested Tags” Liste + freies Eingabefeld, oder muss es echtes Autocomplete sein?
|
||||
6. Kannst du die relevante docker-compose.yml (für transcription-app) bzw. den Teil mit volumes zeigen, um Persistenz und Codepfade eindeutig zu machen?
|
||||
7. Wo möchtest du den “Source of Truth” Workspace für Moltbot haben: /volume1/docker/moltbot/moltbot/clawd oder unter /volume1/docker/moltbot/storage/… ?
|
||||
8. Soll backup.sh nur den clawd-Workspace sichern oder zusätzlich storage/media (inbound) und/oder Notion-Configs?
|
||||
9. Wo liegt big_Fortschritt_marketingautomation.txt (Repo-Pfad oder Synology-Pfad), oder kannst du den Inhalt hier einfügen/hochladen?
|
||||
10. Für das Kommunikationsprotokoll: sollen Mini-Updates strikt zeitbasiert (2 Minuten) sein, oder “nach jedem relevanten Tool-Schritt” (z. B. nach git/ls/migration/push), falls Zeitintervalle nicht zuverlässig haltbar sind?
|
||||
@@ -1,36 +0,0 @@
|
||||
# Use Node.js v20 as the base image to match the Synology host environment
|
||||
FROM node:20-slim
|
||||
|
||||
# Install git and pnpm as root
|
||||
USER root
|
||||
RUN apt-get update && apt-get install -y git && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Switch to the non-privileged node user for all subsequent operations
|
||||
USER node
|
||||
|
||||
# Set the working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Clone the Moltbot repository
|
||||
RUN git clone https://github.com/moltbot/moltbot.git .
|
||||
|
||||
# HACK: Use a brute-force find/sed to patch the Node.js version check in ANY file
|
||||
RUN find . -type f -exec sed -i 's/.*requires Node >=.*/\/\/ Version check disabled by Gemini for Synology compatibility/' {} +
|
||||
|
||||
# Install pnpm locally as a project dependency
|
||||
RUN npm install pnpm
|
||||
|
||||
# Install project dependencies using the local pnpm
|
||||
RUN npx pnpm install
|
||||
|
||||
# Build the project
|
||||
RUN npx pnpm build
|
||||
|
||||
# Expose the gateway port
|
||||
EXPOSE 18789
|
||||
|
||||
# Set the entrypoint to the clawdbot executable
|
||||
ENTRYPOINT ["/app/packages/clawdbot/node_modules/.bin/clawdbot"]
|
||||
|
||||
# The default command will be provided by docker-compose
|
||||
CMD ["--help"]
|
||||
18
GEMINI.md
18
GEMINI.md
@@ -20,24 +20,6 @@ Dies ist in der Vergangenheit mehrfach passiert und hat zu massivem Datenverlust
|
||||
- **Git-Repository:** Dieses Projekt wird über ein Git-Repository verwaltet. Alle Änderungen am Code werden versioniert. Beachten Sie den Abschnitt "Git Workflow & Conventions" für unsere Arbeitsregeln.
|
||||
- **WICHTIG:** Der AI-Agent kann Änderungen committen, aber aus Sicherheitsgründen oft nicht `git push` ausführen. Bitte führen Sie `git push` manuell aus, wenn der Agent dies meldet.
|
||||
|
||||
## Git Workflow & Conventions
|
||||
|
||||
### Den Arbeitstag abschließen mit `#fertig`
|
||||
|
||||
Um einen Arbeitsschritt oder einen Task abzuschließen, verwenden Sie den Befehl `#fertig`.
|
||||
|
||||
**WICHTIG:** Verwenden Sie **nicht** `/fertig` oder nur `fertig`. Nur der Befehl mit der Raute (`#`) wird korrekt erkannt.
|
||||
|
||||
Wenn Sie `#fertig` eingeben, führt der Agent folgende Schritte aus:
|
||||
1. **Analyse:** Der Agent prüft, ob seit dem letzten Commit Änderungen am Code vorgenommen wurden.
|
||||
2. **Zusammenfassung:** Er generiert eine automatische Arbeitszusammenfassung basierend auf den Code-Änderungen.
|
||||
3. **Status-Update:** Der Agent führt das Skript `python3 dev_session.py --report-status` im Hintergrund aus.
|
||||
- Die in der aktuellen Session investierte Zeit wird berechnet und in Notion gespeichert.
|
||||
- Ein neuer Statusbericht mit der Zusammenfassung wird an den Notion-Task angehängt.
|
||||
- Der Status des Tasks in Notion wird auf "Done" (oder einen anderen passenden Status) gesetzt.
|
||||
4. **Commit & Push:** Wenn Code-Änderungen vorhanden sind, wird ein Commit erstellt und ein `git push` interaktiv angefragt.
|
||||
|
||||
|
||||
## Project Overview
|
||||
|
||||
This project is a Python-based system for automated company data enrichment and lead generation. It focuses on identifying B2B companies with high potential for robotics automation (Cleaning, Transport, Security, Service).
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
# Konver.ai Integration: Strategie & Architektur
|
||||
|
||||
**Status:** Vertrag unterzeichnet (Fokus: Telefon-Enrichment).
|
||||
**Risiko:** Wegfall von Dealfront (Lead Gen) ohne adäquaten, automatisierten Ersatz.
|
||||
**Ziel:** Nutzung von Konver.ai nicht nur als manuelles "Telefonbuch", sondern als **skalierbare Quelle** für die Lead-Fabrik (Company Explorer).
|
||||
|
||||
## 1. Das Zielszenario (The "Golden Flow")
|
||||
|
||||
Wir integrieren Konver.ai via API direkt in den Company Explorer. Der CE fungiert als Gatekeeper, um Credits zu sparen und Dubletten zu verhindern.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
subgraph "RoboPlanet Ecosystem"
|
||||
Notion[("Notion Strategy\n(Verticals/Pains)")]
|
||||
SO[("SuperOffice CRM\n(Bestand)")]
|
||||
CE["Company Explorer\n(The Brain)"]
|
||||
end
|
||||
|
||||
subgraph "External Sources"
|
||||
Konver["Konver.ai API"]
|
||||
Web["Web / Google / Wiki"]
|
||||
end
|
||||
|
||||
%% Data Flow
|
||||
Notion -->|1. Sync Strategy| CE
|
||||
SO -->|2. Import Existing (Blocklist)| CE
|
||||
|
||||
CE -->|3. Search Query + Exclusion List| Konver
|
||||
Note right of Konver: "Suche: Altenheime > 10 Mio\nExclude: Domain-Liste aus SO"
|
||||
|
||||
Konver -->|4. Net New Candidates| CE
|
||||
|
||||
CE -->|5. Deep Dive (Robotik-Check)| Web
|
||||
|
||||
CE -->|6. Enrich Contact (Phone/Mail)| Konver
|
||||
Note right of CE: "Nur für Firmen mit\nhohem Robotik-Score!"
|
||||
|
||||
CE -->|7. Export Qualified Lead| SO
|
||||
```
|
||||
|
||||
## 2. Die kritische Lücke: "Exclusion List"
|
||||
|
||||
Da Dealfront (unser bisheriges "Fischnetz") abgeschaltet wird, müssen wir Konver zur **Neukunden-Generierung** nutzen.
|
||||
Ohne eine **Ausschluss-Liste (Exclusion List)** bei der Suche verbrennen wir Geld und Zeit:
|
||||
|
||||
1. **Kosten:** Wir zahlen Credits für Firmen/Kontakte, die wir schon haben.
|
||||
2. **Daten-Hygiene:** Wir importieren Dubletten, die wir mühsam bereinigen müssen.
|
||||
3. **Blindflug:** Wir wissen vor dem Kauf nicht, ob der Datensatz "netto neu" ist.
|
||||
|
||||
### Forderung an Konver (Technisches Onboarding)
|
||||
|
||||
*"Um Konver.ai als strategischen Nachfolger für Dealfront in unserer Marketing-Automation nutzen zu können, benötigen wir zwingend API-Funktionen zur **Deduplizierung VOR dem Datenkauf**."*
|
||||
|
||||
**Konkrete Features:**
|
||||
* **Domain-Exclusion:** Upload einer Liste (z.B. 5.000 Domains), die in der API-Suche *nicht* zurückgegeben werden.
|
||||
* **Contact-Check:** Prüfung (z.B. Hash-Abgleich), ob eine E-Mail-Adresse bereits "bekannt" ist, bevor Kontaktdaten enthüllt (und berechnet) werden.
|
||||
|
||||
## 3. Workflow-Varianten
|
||||
|
||||
### A. Der "Smart Enricher" (Wirtschaftlich)
|
||||
Wir nutzen Konver nur für Firmen, die **wirklich** relevant sind.
|
||||
|
||||
1. **Scraping:** Company Explorer findet 100 Altenheime (Web-Suche).
|
||||
2. **Filterung:** KI prüft Websites -> 40 davon sind relevant (haben große Flächen).
|
||||
3. **Enrichment:** Nur für diese 40 fragen wir Konver via API: *"Gib mir den Facility Manager + Handy"*.
|
||||
4. **Ergebnis:** Wir zahlen 40 Credits statt 100. Hohe Effizienz.
|
||||
|
||||
### B. Der "Mass Loader" (Teuer & Dumm - zu vermeiden)
|
||||
1. Wir laden "Alle Altenheime" aus Konver direkt nach SuperOffice.
|
||||
2. Wir zahlen 100 Credits.
|
||||
3. Der Vertrieb ruft an -> 60 davon sind ungeeignet (zu klein, kein Bedarf).
|
||||
4. **Ergebnis:** 60 Credits verbrannt, Vertrieb frustriert.
|
||||
|
||||
## 4. Fazit & Next Steps
|
||||
|
||||
Wir müssen im Onboarding-Gespräch klären:
|
||||
1. **API-Doku:** Wo ist die Dokumentation für `Search` und `Enrich` Endpoints?
|
||||
2. **Exclusion:** Wie filtern wir Bestandskunden im API-Call?
|
||||
3. **Bulk-Enrichment:** Können wir Listen (Domains) zum Anreichern hochladen?
|
||||
|
||||
Ohne diese Features ist Konver ein Rückschritt in die manuelle Einzelbearbeitung.
|
||||
@@ -1,4 +1,4 @@
|
||||
# Migrations-Plan: Legacy GSheets -> Company Explorer (Robotics Edition v0.8.5)
|
||||
# Migrations-Plan: Legacy GSheets -> Company Explorer (Robotics Edition v0.7.4)
|
||||
|
||||
**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.
|
||||
@@ -29,25 +29,12 @@ Das System wird in `company-explorer/` neu aufgebaut. Wir lösen Abhängigkeiten
|
||||
| **Classification Service** | **NEU (v0.7.0).** Zweistufige Logik: <br> 1. Strict Industry Classification. <br> 2. Metric Extraction Cascade (Web -> Wiki -> SerpAPI). | 1 |
|
||||
| **Marketing Engine** | Ersetzt `generate_marketing_text.py`. Nutzt neue `marketing_wissen_robotics.yaml`. | 3 |
|
||||
|
||||
**Identifizierte Hauptdatei:** `company-explorer/backend/app.py`
|
||||
|
||||
### 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.
|
||||
* **Identifizierte Komponente:** `company-explorer/frontend/src/components/Inspector.tsx`
|
||||
* **View 3: "List Matcher":** Upload einer Excel-Liste -> Anzeige von Duplikaten -> Button "Neue importieren".
|
||||
* **View 4: "Settings":** Konfiguration von Branchen, Rollen und Robotik-Logik.
|
||||
* **Frontend "Settings" Komponente:** `company-explorer/frontend/src/components/RoboticsSettings.tsx`
|
||||
|
||||
### C. Architekturmuster für die Client-Integration
|
||||
|
||||
Um externen Diensten (wie der `lead-engine`) eine einfache und robuste Anbindung an den `company-explorer` zu ermöglichen, wurde ein standardisiertes Client-Connector-Muster implementiert.
|
||||
|
||||
| Komponente | Aufgabe & Neue Logik |
|
||||
| :--- | :--- |
|
||||
| **`company_explorer_connector.py`** | **NEU:** Ein zentrales Python-Skript, das als "offizieller" Client-Wrapper für die API des Company Explorers dient. Es kapselt die Komplexität der asynchronen Enrichment-Prozesse. |
|
||||
| **`handle_company_workflow()`** | Die Kernfunktion des Connectors. Sie implementiert den vollständigen "Find-or-Create-and-Enrich"-Workflow: <br> 1. **Prüfen:** Stellt fest, ob ein Unternehmen bereits existiert. <br> 2. **Erstellen:** Legt das Unternehmen an, falls es neu ist. <br> 3. **Anstoßen:** Startet den asynchronen `discover`-Prozess. <br> 4. **Warten (Polling):** Überwacht den Status des Unternehmens, bis eine Website gefunden wurde. <br> 5. **Analysieren:** Startet den asynchronen `analyze`-Prozess. <br> **Vorteil:** Bietet dem aufrufenden Dienst eine einfache, quasi-synchrone Schnittstelle und stellt sicher, dass die Prozessschritte in der korrekten Reihenfolge ausgeführt werden. |
|
||||
|
||||
## 3. Umgang mit Shared Code (`helpers.py` & Co.)
|
||||
|
||||
@@ -108,54 +95,142 @@ Wir kapseln das neue Projekt vollständig ab ("Fork & Clean").
|
||||
## 7. Historie & Fixes (Jan 2026)
|
||||
|
||||
* **[CRITICAL] v0.7.4: Service Restoration & Logic Fix (Jan 24, 2026)**
|
||||
* **Summary:** Identified and resolved a critical issue where `ClassificationService` contained empty placeholder methods (`pass`), leading to "Others" classification and missing metrics.
|
||||
* **Fixes Implemented:**
|
||||
* **Service Restoration:** Completely re-implemented `classify_company_potential`, `_run_llm_classification_prompt`, and `_run_llm_metric_extraction_prompt` to restore AI functionality using `call_gemini_flash`.
|
||||
* **Standardization Logic:** Connected the `standardization_logic` formula parser (e.g., "Values * 100m²") into the metric extraction cascade. It now correctly computes `standardized_metric_value` (e.g., 352 beds -> 35,200 m²).
|
||||
* **Verification:** Confirmed end-to-end flow from "New Company" -> "Healthcare - Hospital" -> "352 Betten" -> "35.200 m²" via the UI "Play" button.
|
||||
|
||||
* **[STABILITY] v0.7.3: Hardening Metric Parser & Regression Testing (Jan 23, 2026)**
|
||||
* **Summary:** A series of critical fixes were applied to the `MetricParser` to handle complex real-world scenarios, and a regression test suite was created to prevent future issues.
|
||||
* **Specific Bug Fixes:**
|
||||
* **Wolfra Bug ("802020"):** Logic to detect and remove trailing years from concatenated numbers (e.g., "Mitarbeiter: 802020" -> "80").
|
||||
* **Erding Bug ("Year Prefix"):** Logic to ignore year-like prefixes appearing before the actual metric (e.g., "Seit 2022 ... 200.000 Besucher").
|
||||
* **Greilmeier Bug ("Truncation"):** Removed aggressive sentence splitting on hyphens that was truncating text and causing the parser to miss numbers at the end of a phrase.
|
||||
* **Expected Value Cleaning:** The parser now aggressively strips units (like "m²") from the LLM's `expected_value` to ensure it can find the correct numeric target even if the source text contains multiple numbers.
|
||||
* **Regression Test Suite:** Created `/backend/tests/test_metric_parser.py` to lock in these fixes.
|
||||
|
||||
* **[STABILITY] v0.7.2: Robust Metric Parsing (Jan 23, 2026)**
|
||||
* **Legacy Logic Restored:** Re-implemented the robust, regex-based number parsing logic (formerly in legacy helpers) as `MetricParser`.
|
||||
* **German Formats:** Correctly handles "1.000" (thousands) vs "1,5" (decimal) and mixed formats.
|
||||
* **Citation Cleaning:** Filters out Wikipedia citations like `[3]` and years in parentheses (e.g. "80 (2020)" -> 80).
|
||||
* **Hybrid Extraction:** The ClassificationService now asks the LLM for the *text segment* and parses the number deterministically, fixing "LLM Hallucinations" (e.g. "1.005" -> 1).
|
||||
|
||||
* **[STABILITY] v0.7.1: AI Robustness & UI Fixes (Jan 21, 2026)**
|
||||
* **SDK Stabilität:** Umstellung auf `gemini-2.0-flash` im Legacy-SDK zur Behebung von `404 Not Found` Fehlern.
|
||||
* **API-Key Management:** Robustes Laden des Keys aus `/app/gemini_api_key.txt`.
|
||||
* **Classification Prompt:** Schärfung auf "Best-Fit"-Entscheidungen (kein vorzeitiges "Others").
|
||||
* **Scraping:** Wechsel auf `BeautifulSoup` nach Problemen mit `trafilatura`.
|
||||
|
||||
* **[MAJOR] v0.7.0: Quantitative Potential Analysis (Jan 20, 2026)**
|
||||
* **Zweistufige Analyse:**
|
||||
1. **Strict Classification:** Ordnet Firmen einer Notion-Branche zu (oder "Others").
|
||||
2. **Metric Cascade:** Sucht gezielt nach der branchenspezifischen Metrik ("Scraper Search Term").
|
||||
* **Fallback-Kaskade:** Website -> Wikipedia -> SerpAPI (Google Search).
|
||||
* **Standardisierung:** Berechnet vergleichbare Werte (z.B. m²) aus Rohdaten mit der `Standardization Logic`.
|
||||
* **Datenbank:** Erweiterung der `companies`-Tabelle um Metrik-Felder.
|
||||
|
||||
* **[UPGRADE] v0.6.x: Notion Integration & UI Improvements**
|
||||
* **Notion SSoT:** Umstellung der Branchenverwaltung (`Industries`) auf Notion.
|
||||
* **Sync Automation:** `backend/scripts/sync_notion_industries.py`.
|
||||
* **Contacts Management:** Globale Kontaktliste, Bulk-Import, Marketing-Status.
|
||||
* **UI Overhaul:** Light/Dark Mode, Grid View, Responsive Design.
|
||||
|
||||
## 14. Upgrade v2.0 (Feb 18, 2026): "Lead-Fabrik" Erweiterung
|
||||
## 8. Eingesetzte Prompts (Account-Analyse v0.7.4)
|
||||
|
||||
Dieses Upgrade transformiert den Company Explorer in das zentrale Gehirn der Lead-Generierung (Vorratskammer).
|
||||
### 8.1 Strict Industry Classification
|
||||
|
||||
### 14.1 Detaillierte Logik der neuen Datenfelder
|
||||
Ordnet das Unternehmen einer definierten Branche zu.
|
||||
|
||||
Um Gemini CLI (dem Bautrupp) die Umsetzung zu ermöglichen, hier die semantische Bedeutung der neuen Spalten:
|
||||
```python
|
||||
prompt = f"""
|
||||
Act as a strict B2B Industry Classifier.
|
||||
Company: {company_name}
|
||||
Context: {website_text[:3000]}
|
||||
|
||||
#### Tabelle `companies` (Qualitäts- & Abgleich-Metriken)
|
||||
Available Industries:
|
||||
{json.dumps(industry_definitions, indent=2)}
|
||||
|
||||
* **`confidence_score` (FLOAT, 0.0 - 1.0):** Indikator für die Sicherheit der KI-Klassifizierung. `> 0.8` = Grün.
|
||||
* **`data_mismatch_score` (FLOAT, 0.0 - 1.0):** Abweichung zwischen CRM-Bestand und Web-Recherche (z.B. Umzug).
|
||||
* **`crm_name`, `crm_address`, `crm_website`, `crm_vat`:** Read-Only Snapshot aus SuperOffice zum Vergleich.
|
||||
* **Status-Flags:** `website_scrape_status` und `wiki_search_status`.
|
||||
Task: Select the ONE industry that best matches the company.
|
||||
If the company is a Hospital/Klinik, select 'Healthcare - Hospital'.
|
||||
If none match well, select 'Others'.
|
||||
|
||||
#### Tabelle `industries` (Strategie-Parameter)
|
||||
Return ONLY the exact name of the industry.
|
||||
"""
|
||||
```
|
||||
|
||||
* **`pains` / `gains`:** Strukturierte Textblöcke (getrennt durch `[Primary Product]` und `[Secondary Product]`).
|
||||
* **`ops_focus_secondary` (BOOLEAN):** Steuerung für rollenspezifische Produkt-Priorisierung.
|
||||
### 8.2 Metric Extraction
|
||||
|
||||
---
|
||||
Extrahiert den spezifischen Zahlenwert ("Scraper Search Term") und liefert JSON für den `MetricParser`.
|
||||
|
||||
## 15. Offene Arbeitspakete (Bauleitung)
|
||||
```python
|
||||
prompt = f"""
|
||||
Extract the following metric for the company in industry '{industry_name}':
|
||||
Target Metric: "{search_term}"
|
||||
|
||||
Anweisungen für den "Bautrupp" (Gemini CLI).
|
||||
Source Text:
|
||||
{text_content[:6000]}
|
||||
|
||||
### Task 1: UI-Anpassung - Side-by-Side CRM View & Settings
|
||||
(In Arbeit / Teilweise erledigt durch Gemini CLI)
|
||||
Return a JSON object with:
|
||||
- "raw_value": The number found (e.g. 352 or 352.0). If text says "352 Betten", extract 352. If not found, null.
|
||||
- "raw_unit": The unit found (e.g. "Betten", "m²").
|
||||
- "proof_text": A short quote from the text proving this value.
|
||||
|
||||
### Task 2: Intelligenter CRM-Importer (Bestandsdaten)
|
||||
JSON ONLY.
|
||||
"""
|
||||
```
|
||||
|
||||
**Ziel:** Importieren der `demo_100.xlsx` in die SQLite-Datenbank.
|
||||
## 9. Notion Integration (Single Source of Truth)
|
||||
|
||||
**Anforderungen:**
|
||||
1. **PLZ-Handling:** Zwingend als **String** einlesen (führende Nullen erhalten).
|
||||
2. **Normalisierung:** Website bereinigen (kein `www.`, `https://`).
|
||||
3. **Matching:** Kaskade über CRM-ID, VAT, Domain, Fuzzy Name.
|
||||
4. **Isolierung:** Nur `crm_` Spalten updaten, Golden Records unberührt lassen.
|
||||
Das System nutzt Notion als zentrales Steuerungselement für strategische Definitionen.
|
||||
|
||||
---
|
||||
### 9.1 Datenfluss
|
||||
1. **Definition:** Branchen und Robotik-Kategorien werden in Notion gepflegt (Whale Thresholds, Keywords, Definitionen).
|
||||
2. **Synchronisation:** Das Skript `sync_notion_industries.py` zieht die Daten via API und führt einen Upsert in die lokale SQLite-Datenbank aus.
|
||||
3. **App-Nutzung:** Das Web-Interface zeigt diese Daten schreibgeschützt an. Der `ClassificationService` nutzt sie als "System-Anweisung" für das LLM.
|
||||
|
||||
## 16. Deployment-Referenz (NAS)
|
||||
* **Pfad:** `/volume1/homes/Floke/python/brancheneinstufung/company-explorer`
|
||||
* **DB:** `/app/companies_v3_fixed_2.db`
|
||||
* **Sync:** `docker exec -it company-explorer python backend/scripts/sync_notion_to_ce_enhanced.py`
|
||||
### 9.2 Technische Details
|
||||
* **Notion Token:** Muss in `/app/notion_token.txt` (Container-Pfad) hinterlegt sein.
|
||||
* **DB-Mapping:** Die Zuordnung erfolgt primär über die `notion_id`, sekundär über den Namen, um Dubletten bei der Migration zu vermeiden.
|
||||
|
||||
## 10. Database Migration
|
||||
|
||||
Wenn die `industries`-Tabelle in einer bestehenden Datenbank aktualisiert werden muss (z.B. um neue Felder aus Notion zu unterstützen), darf die Datenbankdatei **nicht** gelöscht werden. Stattdessen muss das Migrations-Skript ausgeführt werden.
|
||||
|
||||
**Prozess:**
|
||||
|
||||
1. **Sicherstellen, dass die Zieldatenbank vorhanden ist:** Die `companies_v3_fixed_2.db` muss im `company-explorer`-Verzeichnis liegen (bzw. via Volume gemountet sein).
|
||||
2. **Migration ausführen:** Dieser Befehl fügt die fehlenden Spalten hinzu, ohne Daten zu löschen.
|
||||
```bash
|
||||
docker exec -it company-explorer python3 backend/scripts/migrate_db.py
|
||||
```
|
||||
3. **Container neu starten:** Damit der Server das neue Schema erkennt.
|
||||
```bash
|
||||
docker-compose restart company-explorer
|
||||
```
|
||||
4. **Notion-Sync ausführen:** Um die neuen Spalten mit Daten zu befüllen.
|
||||
```bash
|
||||
docker exec -it company-explorer python3 backend/scripts/sync_notion_industries.py
|
||||
```
|
||||
|
||||
## 11. Lessons Learned (Retrospektive Jan 24, 2026)
|
||||
|
||||
1. **API-Routing-Reihenfolge (FastAPI):** Ein spezifischer Endpunkt (z.B. `/api/companies/export`) muss **vor** einem dynamischen Endpunkt (z.B. `/api/companies/{company_id}`) deklariert werden. Andernfalls interpretiert FastAPI "export" als eine `company_id`, was zu einem `422 Unprocessable Entity` Fehler führt.
|
||||
2. **Nginx `proxy_pass` Trailing Slash:** Das Vorhandensein oder Fehlen eines `/` am Ende der `proxy_pass`-URL in Nginx ist kritisch. Für Dienste wie FastAPI, die mit einem `root_path` (z.B. `/ce`) laufen, darf **kein** Trailing Slash verwendet werden (`proxy_pass http://company-explorer:8000;`), damit der `root_path` in der an das Backend weitergeleiteten Anfrage erhalten bleibt.
|
||||
3. **Docker-Datenbank-Persistenz:** Das Fehlen eines expliziten Volume-Mappings für die Datenbankdatei in `docker-compose.yml` führt dazu, dass der Container eine interne, ephemere Kopie der Datenbank verwendet. Alle Änderungen, die außerhalb des Containers an der "Host"-DB vorgenommen werden, sind für die Anwendung unsichtbar. Es ist zwingend erforderlich, ein Mapping wie `./companies_v3_fixed_2.db:/app/companies_v3_fixed_2.db` zu definieren.
|
||||
4. **Code-Integrität & Platzhalter:** Es ist kritisch, bei Datei-Operationen sicherzustellen, dass keine Platzhalter (wie `pass` oder `# omitted`) in den produktiven Code gelangen. Eine "Zombie"-Datei, die äußerlich korrekt aussieht aber innerlich leer ist, kann schwer zu debuggende Logikfehler verursachen.
|
||||
5. **Formel-Robustheit:** Formeln aus externen Quellen müssen vor der Auswertung bereinigt werden (Entfernung von Einheiten, Kommentaren), um Syntax-Fehler zu vermeiden.
|
||||
|
||||
## 12. Deployment & Access Notes
|
||||
|
||||
**Wichtiger Hinweis zum Deployment-Setup:**
|
||||
|
||||
Dieses Projekt läuft in einer Docker-Compose-Umgebung, typischerweise auf einer Synology Diskstation. Der Zugriff auf die einzelnen Microservices erfolgt über einen zentralen Nginx-Reverse-Proxy (`proxy`-Service), der auf Port `8090` des Host-Systems lauscht.
|
||||
|
||||
**Zugriffs-URLs für `company-explorer`:**
|
||||
|
||||
* **Intern (im Docker-Netzwerk):** `http://company-explorer:8000`
|
||||
* **Extern (über Proxy):** `https://floke-ai.duckdns.org/ce/` (bzw. lokal `http://192.168.x.x:8090/ce/`)
|
||||
|
||||
**Datenbank-Persistenz:**
|
||||
* Die SQLite-Datenbankdatei (`companies_v3_fixed_2.db`) muss mittels Docker-Volume-Mapping vom Host-Dateisystem in den `company-explorer`-Container gemountet werden (`./companies_v3_fixed_2.db:/app/companies_v3_fixed_2.db`). Dies stellt sicher, dass Datenänderungen persistent sind und nicht verloren gehen, wenn der Container neu gestartet oder neu erstellt wird.
|
||||
@@ -1,210 +0,0 @@
|
||||
# Moltbot auf Synology NAS installieren
|
||||
|
||||
**Status (Jan 2026):** Erfolgreich installiert und betriebsbereit.
|
||||
|
||||
Diese Anleitung beschreibt die empfohlene Methode zur Installation von Moltbot auf einer Synology DiskStation unter Verwendung des offiziellen Setup-Skripts via SSH.
|
||||
|
||||
---
|
||||
|
||||
## 1. Voraussetzungen
|
||||
|
||||
* **DSM 7.x** mit installiertem **Container Manager**.
|
||||
* **SSH-Zugang** zur Synology NAS ist aktiviert (Systemsteuerung → Terminal & SNMP → SSH).
|
||||
|
||||
---
|
||||
|
||||
## 2. Installation (Via SSH & Setup-Skript)
|
||||
|
||||
Die Installation erfolgt direkt auf der Kommandozeile der DiskStation.
|
||||
|
||||
### Schritt 1: Ordnerstruktur auf dem NAS anlegen
|
||||
|
||||
Zuerst legen wir die Verzeichnisse an, in denen die Konfiguration und die Arbeitsdaten von Moltbot persistent gespeichert werden.
|
||||
|
||||
```bash
|
||||
# Pfad für die Moltbot-Installation erstellen
|
||||
mkdir -p /volume1/docker/moltbot
|
||||
|
||||
# Unterordner für Konfiguration und Workspace anlegen
|
||||
mkdir -p /volume1/docker/moltbot/config
|
||||
mkdir -p /volume1/docker/moltbot/workspace
|
||||
|
||||
# WICHTIG: Berechtigungen setzen, damit der Container schreiben darf
|
||||
sudo chown -R 1000:1000 /volume1/docker/moltbot/config /volume1/docker/moltbot/workspace
|
||||
```
|
||||
|
||||
### Schritt 2: Repository klonen und Setup ausführen
|
||||
|
||||
Nun klonen wir das offizielle Moltbot-Repository und starten das Setup-Skript mit den richtigen Pfadangaben.
|
||||
|
||||
```bash
|
||||
# In das vorbereitete Verzeichnis wechseln
|
||||
cd /volume1/docker/moltbot
|
||||
|
||||
# Moltbot-Repository von GitHub klonen
|
||||
git clone https://github.com/moltbot/moltbot.git
|
||||
cd moltbot
|
||||
|
||||
# Umgebungsvariablen für die persistenten Pfade setzen
|
||||
export CLAWDBOT_CONFIG_DIR="/volume1/docker/moltbot/config"
|
||||
export CLAWDBOT_WORKSPACE_DIR="/volume1/docker/moltbot/workspace"
|
||||
|
||||
# Das offizielle Setup-Skript ausführen
|
||||
./docker-setup.sh
|
||||
```
|
||||
|
||||
### Schritt 3: Interaktives Onboarding
|
||||
|
||||
Das Skript startet einen interaktiven Onboarding-Prozess. Folgen Sie den Anweisungen. Die Standardwerte sind in der Regel eine gute Wahl. Am Ende startet der Moltbot-Gateway-Container automatisch.
|
||||
|
||||
---
|
||||
|
||||
## 3. Zugriff auf die Control UI (Aktueller Stand)
|
||||
|
||||
### Das "Secure Context"-Problem
|
||||
|
||||
Moltbot erfordert aus Sicherheitsgründen einen "sicheren Kontext" (HTTPS oder `localhost`) für den Zugriff auf das Web-Interface. Ein direkter Aufruf über `http://<NAS-IP>:18789` schlägt daher fehl und führt zu einer `disconnected (1008)`-Fehlermeldung.
|
||||
|
||||
### Lösung: SSH-Tunnel
|
||||
|
||||
Die aktuell funktionierende und sichere Methode für den Zugriff ist ein SSH-Tunnel. Dieser leitet den Port des Containers auf Ihren lokalen PC um, sodass der Zugriff über `localhost` erfolgt.
|
||||
|
||||
**Befehl zum Aufbau des Tunnels (auf Ihrem PC ausführen):**
|
||||
|
||||
```powershell
|
||||
# Ersetze <NAS-IP> mit der IP-Adresse Ihrer DiskStation
|
||||
ssh -N -L 28789:127.0.0.1:18789 root@<NAS-IP>
|
||||
```
|
||||
|
||||
**Zugriff im Browser:**
|
||||
|
||||
Solange der SSH-Tunnel aktiv ist, können Sie die Moltbot UI auf Ihrem PC unter folgender Adresse erreichen:
|
||||
|
||||
`http://127.0.0.1:28789/`
|
||||
|
||||
Denken Sie daran, den beim Onboarding generierten Token an die URL anzuhängen (z.B. `?token=...`), falls erforderlich.
|
||||
|
||||
---
|
||||
|
||||
## 4. Nächste Schritte: Zugriff vereinfachen
|
||||
|
||||
Der Zugriff über einen SSH-Tunnel ist sicher, aber für den täglichen Gebrauch und den Zugriff von unterwegs unpraktisch.
|
||||
|
||||
**Offener Task:**
|
||||
* Einrichtung eines **Reverse Proxys** auf der Synology DiskStation.
|
||||
* **Ziel:** Moltbot über eine sichere **HTTPS**-URL (z.B. `https://moltbot.meine-domain.de`) erreichbar zu machen. Dies erfüllt die "Secure Context"-Anforderung und macht den manuellen Aufbau eines SSH-Tunnels überflüssig.
|
||||
|
||||
[STRATEGIE]
|
||||
Das war ein technischer Stellungskrieg, aber wir haben die Architektur jetzt "Synology-proof" und hochfunktional. Der entscheidende Durchbruch war die Abkehr von der automatisierten Konfiguration hin zur **expliziten Architektur-Kontrolle** (Root-User, manuelle JSON-Injektion und Runtime-Bootstrapping).
|
||||
|
||||
Hier ist der destillierte **Setup-Guide 2026** für dein Gitea-Repository.
|
||||
|
||||
---
|
||||
|
||||
# 🦞 Moltbot Synology Deployment Guide (Feb 2026)
|
||||
|
||||
## 1. Strategischer Kontext
|
||||
Dieses Setup dient als Backend für die **GTM-Engine** von RoboPlanet. Es ist darauf optimiert, auf einer Synology DiskStation stabil zu laufen, Berechtigungskonflikte zu vermeiden und eine vollwertige **Python/Pip/Git-Umgebung** für automatisierte Workflows bereitzustellen.
|
||||
|
||||
## 2. Infrastruktur (Host-Ebene)
|
||||
Bevor der Container startet, muss die Verzeichnisstruktur auf der DiskStation exakt so vorbereitet sein. Dies verhindert "Bind mount"-Fehler und sichert die Datenpersistenz.
|
||||
|
||||
```bash
|
||||
# Hauptverzeichnis
|
||||
mkdir -p /volume1/docker/moltbot
|
||||
|
||||
# Unterordner für saubere Trennung
|
||||
mkdir -p /volume1/docker/moltbot/storage # Datenbank & State (~/.clawdbot)
|
||||
mkdir -p /volume1/docker/moltbot/config # Statische Konfiguration
|
||||
mkdir -p /volume1/docker/moltbot/workspace # Arbeitsbereich für Agenten-Scripte
|
||||
mkdir -p /volume1/docker/moltbot/secrets # API-Keys & Zertifikate
|
||||
|
||||
# Rechte-Management (Zwingend für Synology)
|
||||
# Wir setzen 1000:1000 (Node) oder nutzen user:root im Container
|
||||
sudo chown -R 1000:1000 /volume1/docker/moltbot
|
||||
```
|
||||
|
||||
## 3. Konfiguration (Die "Ground Truth")
|
||||
Manuelle Erstellung der Konfiguration, um Validierungsfehler der CLI zu umgehen. Erstelle die Datei `/volume1/docker/moltbot/config/moltbot.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"gateway": {
|
||||
"mode": "local",
|
||||
"bind": "lan",
|
||||
"port": 18789,
|
||||
"auth": {
|
||||
"token": "DEIN_GATEWAY_TOKEN"
|
||||
}
|
||||
},
|
||||
"channels": {
|
||||
"telegram": {
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 4. Docker-Compose (Die Engine)
|
||||
Die finale `docker-compose.yml`. Zentrale Entscheidungen:
|
||||
- **user: root**: Erforderlich für die Installation von System-Paketen (Python) zur Laufzeit.
|
||||
- **command-Bootstrap**: Installiert Python & Git bei jedem Start, falls das Image aktualisiert wird.
|
||||
- **auth-profiles Injection**: Schreibt den Gemini-Key direkt in den Agent-Speicher.
|
||||
|
||||
```yaml
|
||||
services:
|
||||
openclaw-gateway:
|
||||
image: ghcr.io/openclaw/moltbot:main
|
||||
container_name: openclaw-gateway
|
||||
user: root
|
||||
environment:
|
||||
MOLTBOT_STATE_DIR: /home/node/.clawdbot
|
||||
OPENCLAW_GATEWAY_TOKEN: ${OPENCLAW_GATEWAY_TOKEN}
|
||||
TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN}
|
||||
GEMINI_API_KEY: ${GEMINI_API_KEY}
|
||||
HOME: /home/node
|
||||
volumes:
|
||||
- /volume1/docker/moltbot/storage:/home/node/.clawdbot
|
||||
- /volume1/docker/moltbot/workspace:/home/node/.clawdbot/workspace
|
||||
- /volume1/docker/moltbot/config/moltbot.json:/home/node/.clawdbot/moltbot.json:ro
|
||||
ports:
|
||||
- "18789:18789"
|
||||
init: true
|
||||
restart: unless-stopped
|
||||
command: >
|
||||
sh -lc '
|
||||
apt-get update && apt-get install -y python3 python3-pip git build-essential
|
||||
mkdir -p /home/node/.clawdbot/agents/main/agent
|
||||
echo "{\"google\": {\"apiKey\": \"$${GEMINI_API_KEY}\"}}" > /home/node/.clawdbot/agents/main/agent/auth-profiles.json
|
||||
exec node dist/index.js gateway --bind lan
|
||||
'
|
||||
|
||||
openclaw-cli:
|
||||
image: ghcr.io/openclaw/moltbot:main
|
||||
container_name: openclaw-cli
|
||||
user: root
|
||||
environment:
|
||||
MOLTBOT_GATEWAY_URL: ws://openclaw-gateway:18789
|
||||
MOLTBOT_GATEWAY_TOKEN: ${OPENCLAW_GATEWAY_TOKEN}
|
||||
volumes:
|
||||
- /volume1/docker/moltbot/storage:/home/node/.clawdbot
|
||||
- /volume1/docker/moltbot/workspace:/home/node/.clawdbot/workspace
|
||||
entrypoint: ["node", "dist/index.js", "--gateway", "ws://openclaw-gateway:18789"]
|
||||
stdin_open: true
|
||||
tty: true
|
||||
```
|
||||
|
||||
## 5. Operative Key-Entscheidungen (Review)
|
||||
1. **Pfad-Konsistenz:** Umstellung von `.openclaw` auf `.clawdbot` (Projekt-Migration 2026 gefolgt).
|
||||
2. **Bind-Parameter:** Wechsel von `0.0.0.0` auf `lan`, da das Gateway-Modul spezifische Keywords für das Interface-Binding verlangt.
|
||||
3. **CLI-Targeting:** Die CLI wurde über den `entrypoint` fest auf den Gateway-Service verdrahtet, um den Loopback-Fehler (`127.0.0.1`) innerhalb des Docker-Netzwerks permanent zu beheben.
|
||||
4. **Pairing-Prozess:** Initialisierung via Telegram erfordert ein einmaliges `pairing approve` über die CLI, um die User-ID des Besitzers zu verknüpfen.
|
||||
|
||||
## 6. Wartung
|
||||
- **Python-Module:** Können über `docker exec -it openclaw-gateway pip install <modul>` nachinstalliert werden.
|
||||
- **Updates:** `docker compose pull && docker compose up -d` (Das Bootstrap-Skript installiert Python automatisch nach).
|
||||
|
||||
---
|
||||
|
||||
[STATUS]
|
||||
Das System läuft. Python ist installiert. Gemini ist autorisiert. Die GTM-Engine ist bereit für den ersten "Whale Hunting" Task. Was ist die erste operative Aufgabe, die der Bot für dich erledigen soll? Gitea-Clone oder Prospecting-Analyse? 🦞
|
||||
102
RELOCATION.md
102
RELOCATION.md
@@ -1,102 +0,0 @@
|
||||
### **Anforderungsdokument (Version 2): Docker-Migration von Synology nach Ubuntu VM (`docker1`)**
|
||||
|
||||
**Zweck:** Dieses Dokument listet alle notwendigen Port-Freigaben und Netzwerk-Anforderungen für den Umzug des internen Docker-Anwendungsstacks auf die neue VM `docker1` (IP: `10.10.81.2`). Diese Version basiert auf der Analyse aller aktiven Docker-Container vom Quellsystem.
|
||||
|
||||
#### **Teil 1: Externe Port-Freigaben (Firewall)**
|
||||
|
||||
Die folgenden Ports müssen auf der Firewall für den eingehenden Verkehr zur VM `10.10.81.2` geöffnet werden.
|
||||
|
||||
| Host-Port | Ziel-Dienst (Container) | Zweck / Beschreibung | Kritikalität |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| **3000** | `gitea` | **Gitea Web UI.** Zugriff auf die Weboberfläche des Git-Servers. | **Hoch** |
|
||||
| **2222** | `gitea` | **Gitea Git via SSH.** Ermöglicht `git clone`, `push`, `pull` über SSH. (Auf dem Altsystem Port `32768`, wird hier auf einen Standard-Port gelegt). | **Hoch** |
|
||||
| **8090** | `gateway_proxy` (Nginx) | **Zentraler App-Hub.** Reverse-Proxy, der den Zugriff auf alle Web-Anwendungen (Dashboard, B2B, Market-Intel etc.) steuert und mit Passwort schützt. | **Hoch** |
|
||||
| **8003** | `connector-superoffice` | **SuperOffice Webhook-Empfänger.** Muss aus dem Internet (von SuperOffice-Servern) erreichbar sein. | **Hoch** |
|
||||
| **8501** | `lead-engine` | **Lead Engine UI.** Web-Interface der Lead Engine (Streamlit). | **Mittel** |
|
||||
| **8004** | `lead-engine` (API) | **Lead Engine API.** Für externe Integrationen wie Kalender/Buchungs-Links. | **Mittel** |
|
||||
| **8002** | `heatmap-tool-backend` | **Heatmap Tool Backend.** API für das Heatmap-Visualisierungs-Tool. | **Niedrig** |
|
||||
| **5173** | `heatmap-tool-frontend` | **Heatmap Tool Frontend.** Direktzugriff auf die UI des Heatmap-Tools (typ. für Entwicklung). | **Niedrig** |
|
||||
|
||||
**Empfehlung:** Der gesamte externe Zugriff sollte idealerweise über einen vorgeschalteten, von der IT verwalteten Reverse-Proxy mit SSL-Terminierung (HTTPS) laufen, um die Sicherheit zu erhöhen.
|
||||
|
||||
---
|
||||
|
||||
#### **Teil 2: Interne Port-Nutzung (Host-System)**
|
||||
|
||||
Die folgenden Ports werden von den Docker-Containern auf dem Host-System (`docker1`) belegt. Sie müssen **nicht extern** freigegeben werden, aber sie dürfen auf dem Host nicht von anderen Diensten belegt sein.
|
||||
|
||||
| Host-Port | Ziel-Dienst (Container) | Zweck |
|
||||
| :--- | :--- | :--- |
|
||||
| `8000` | `company-explorer` | Haupt-API für Unternehmensdaten, wird vom App-Hub (`gateway_proxy`) genutzt. |
|
||||
| `8001` | `transcription-app` | API und UI für das Transkriptions-Tool, wird vom App-Hub genutzt. |
|
||||
|
||||
---
|
||||
|
||||
#### **Teil 3: Externe Service-Abhängigkeiten (Ausgehender Verkehr)**
|
||||
|
||||
Die VM muss in der Lage sein, ausgehende Verbindungen zu den folgenden externen Diensten aufzubauen.
|
||||
|
||||
| Dienst | Zweck | Protokoll/Port |
|
||||
| :--- | :--- | :--- |
|
||||
| **SuperOffice API** | CRM-Synchronisation | HTTPS / 443 |
|
||||
| **Google APIs** | SerpAPI, Gemini AI | HTTPS / 443 |
|
||||
| **Notion API** | Wissensdatenbank-Sync | HTTPS / 443 |
|
||||
| **DuckDNS** | Dynamisches DNS | HTTPS / 443 |
|
||||
| **GitHub / Docker Hub / etc.** | Herunterladen von Docker-Images, Software-Paketen | HTTPS / 443 |
|
||||
| **Öffentliche DNS-Server** | Namensauflösung (z.B. 8.8.8.8) | DNS / 53 (UDP/TCP) |
|
||||
|
||||
---
|
||||
|
||||
#### **Teil 4: Netzwerk-Architektur & Kommunikation (Intern)**
|
||||
|
||||
* **Inter-Container-Kommunikation:** Alle Container sind über ein internes Docker-Bridge-Netzwerk verbunden. Sie können sich gegenseitig über ihre Dienstnamen erreichen (z.B. `connector-superoffice` kann `company-explorer` über `http://company-explorer:8000` erreichen).
|
||||
* **Keine speziellen Netzwerkregeln:** Es sind keine komplexen internen Firewall-Regeln zwischen den Containern erforderlich. Die Kommunikation innerhalb des Docker-Netzwerks sollte uneingeschränkt möglich sein.
|
||||
|
||||
---
|
||||
|
||||
# ⚠️ Post-Mortem & Kritische Lehren (März 2026)
|
||||
|
||||
**Hintergrund:**
|
||||
Am 07.03.2026 führte ein Versuch, das Legacy-System auf der Synology für die Migration "aufzuräumen", zu massiver Instabilität und temporärem Datenverlust.
|
||||
|
||||
**Identifizierte Fehlerquellen ("Root Causes"):**
|
||||
1. **Destruktive Bereinigung:** `git clean -fdx` löschte untracked Datenbanken.
|
||||
2. **Dateisystem-Inkompatibilität:** Bind Mounts auf Synology führten zu Berechtigungsfehlern ("Database Locked").
|
||||
3. **Fehlende Isolation:** Änderungen wurden am "lebenden Objekt" vorgenommen.
|
||||
|
||||
**Erfolgreiche Stabilisierung (Status Quo):**
|
||||
* **Docker Volumes:** Alle Datenbanken laufen jetzt auf benannten Docker-Volumes (`connector_db_data`, `explorer_db_data`). Bind Mounts sind Geschichte.
|
||||
* **Healthchecks:** Nginx wartet nun korrekt auf die Gesundheit der Backend-Services.
|
||||
* **Environment:** Alle Secrets kommen aus der `.env`, keine Key-Files mehr im Repo.
|
||||
* **Daten:** Die `companies_v3_fixed_2.db` konnte via Synology Drive Versioning wiederhergestellt und per `docker cp` injiziert werden.
|
||||
|
||||
**Neue Richtlinien für die Migration ("Never Again Rules"):**
|
||||
|
||||
1. **Immutable Infrastructure:** Wir ändern **NICHTS** mehr an der Konfiguration auf der Synology. Der dortige Stand ist eingefroren.
|
||||
2. **Code-Only Transfer:** Die neue Umgebung auf `docker1` wird ausschließlich durch `git clone` aufgebaut.
|
||||
3. **Docker Volumes Only:** Datenbanken werden **niemals** mehr direkt auf das Host-Dateisystem gemountet.
|
||||
4. **Environment Isolation:** Secrets existieren ausschließlich in der `.env` Datei.
|
||||
|
||||
---
|
||||
|
||||
### **Revidierter Migrationsplan (Clean Slate)**
|
||||
|
||||
**Phase 1: Code & Config Freeze (Abgeschlossen)**
|
||||
- [x] `docker-compose.yml` reparieren (Named Volumes, Healthchecks, Env Vars).
|
||||
- [x] Dockerfiles reparieren (PostCSS/Tailwind Build-Fehler behoben).
|
||||
- [x] `.env.example` erstellt.
|
||||
- [x] Nginx Konfiguration stabilisiert.
|
||||
|
||||
**Phase 2: Deployment auf `docker1`**
|
||||
1. Repository klonen: `git clone <repo-url> /opt/gtm-engine`
|
||||
2. Environment einrichten: `cp .env.example .env` und echte Werte eintragen.
|
||||
3. Starten: `docker compose up -d --build`
|
||||
4. **Datenimport (Optional):** Falls nötig, Datenbanken via `docker cp` in die Volumes kopieren.
|
||||
|
||||
---
|
||||
|
||||
### **Aktuelle Offene Todos (Priorisiert)**
|
||||
|
||||
1. **UI Styling:** Das Frontend des Company Explorers hat keine Stylesheets (PostCSS temporär deaktiviert, um Build zu ermöglichen). Muss auf der neuen VM sauber gelöst werden.
|
||||
2. **Datenverifizierung:** Prüfen, ob die wiederhergestellte Datenbank vollständig ist.
|
||||
3. **Migration:** Den Umzug auf `docker1` nach dem neuen Plan durchführen.
|
||||
@@ -1,18 +0,0 @@
|
||||
# SKILL: Task Manager
|
||||
|
||||
## Commands
|
||||
- `#task`: Start a new task session.
|
||||
1. Run `python3 scripts/list_projects.py`
|
||||
2. Ask user to choose project number.
|
||||
3. Run `python3 scripts/list_tasks.py <project_id_from_selection>`
|
||||
4. Ask user to choose task number (or 'new' for new task - not impl yet, ask for manual ID if needed).
|
||||
5. Run `python3 scripts/select_task.py <task_id>`
|
||||
|
||||
- `#fertig`: Finish current task.
|
||||
1. Ask user for short summary of work.
|
||||
2. Run `python3 scripts/finish_task.py "<summary>"`
|
||||
3. Ask user if they want to push (`git push`).
|
||||
|
||||
## Notes
|
||||
- Requires `.env.notion` with `NOTION_API_KEY`.
|
||||
- Stores state in `.dev_session/SESSION_INFO`.
|
||||
@@ -1,180 +0,0 @@
|
||||
# Technisches Konzept: SuperOffice CRM Integration
|
||||
|
||||
Dieses Dokument beschreibt die Integrationsstrategie zwischen **SuperOffice CRM** (führendes System für Stammdaten) und dem **Company Explorer** (KI-gestützte Anreicherungs-Engine).
|
||||
|
||||
## Zielsetzung
|
||||
Automatisierte Anreicherung von B2B-Firmendaten im CRM mit KI-generierten Insights (z.B. Robotik-Affinität, Branchen-Einstufung), ohne die Hoheit über die Stammdaten zu gefährden.
|
||||
|
||||
---
|
||||
|
||||
## 1. Architektur: "Event-Driven Messaging" (v2.0)
|
||||
|
||||
Wir haben die Architektur von einem Polling-Modell auf ein **Event-gesteuertes Webhook-Modell** umgestellt. Dies entspricht modernen Best Practices und ist Voraussetzung für eine Skalierung auf 16.000+ Firmen.
|
||||
|
||||
### Das Prinzip: "Gehirn & Muskel"
|
||||
|
||||
* **Das Gehirn (Company Explorer):** Hier liegt die gesamte Intelligenz. Die Datenbank speichert die Firmendaten, die Signale und die **Marketing-Matrix** (Textbausteine). Er entscheidet, *welcher* Text für *welchen* Kontakt generiert wird.
|
||||
* **Der Muskel (Connector):** Er ist ein "dummer" Bote. Er nimmt Events entgegen, fragt das Gehirn nach Anweisungen und führt diese in SuperOffice aus.
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
subgraph "SuperOffice CRM"
|
||||
SO_Contact["👤 Contact / Person"]
|
||||
SO_Webhook["🚀 Webhook<br/>(contact.changed)"]
|
||||
end
|
||||
|
||||
subgraph "Connector (Docker)"
|
||||
WebhookApp["📥 Webhook Receiver<br/>(FastAPI :8002)"]
|
||||
Queue[("📦 Job Queue<br/>(SQLite/Redis)")]
|
||||
Worker["👷♂️ Worker Process"]
|
||||
end
|
||||
|
||||
subgraph "Company Explorer (Docker)"
|
||||
CE_API["🧠 Provisioning API<br/>(:8000)"]
|
||||
MatrixDB[("📚 Marketing Matrix<br/>(SQLite)")]
|
||||
end
|
||||
|
||||
SO_Contact -->|1. Änderung| SO_Webhook
|
||||
SO_Webhook -->|2. POST Event| WebhookApp
|
||||
WebhookApp -->|3. Enqueue Job| Queue
|
||||
Worker -->|4. Fetch Job| Queue
|
||||
Worker -->|5. Request: 'Provision Me'| CE_API
|
||||
CE_API -->|6. Lookup Logic| MatrixDB
|
||||
CE_API -->|7. Return: Final Texts| Worker
|
||||
Worker -->|8. Write-Back UDFs| SO_Contact
|
||||
```
|
||||
|
||||
**Technische Kommunikation:**
|
||||
Da beide Services (`connector-superoffice` und `company-explorer`) im selben Docker-Netzwerk laufen, erfolgt die Kommunikation direkt und latenzfrei über den internen Docker-DNS (`http://company-explorer:8000`). Es gibt keinen Umweg über das öffentliche Internet.
|
||||
|
||||
## 2. Datenmodell & Erweiterung
|
||||
|
||||
## 2. Datenmodell & Erweiterung
|
||||
|
||||
Um die CRM-Daten sauber zu halten, schreiben wir **niemals** in Standardfelder (wie `Name`, `Department`), sondern ausschließlich in dedizierte, benutzerdefinierte Felder (**User Defined Fields - UDFs**).
|
||||
|
||||
### Benötigte Felder in SuperOffice (Anforderung an IT/Admin)
|
||||
Folgende Felder sollten am Objekt `Company` (bzw. `Contact` in SuperOffice-Terminologie) angelegt werden:
|
||||
|
||||
| Feldname (Label) | Typ | Zweck |
|
||||
| :--- | :--- | :--- |
|
||||
| `AI Robotics Potential` | List/Select | High / Medium / Low / None |
|
||||
| `AI Industry` | Text (Short) | KI-ermittelte Branche (z.B. "Logistik - Intralogistik") |
|
||||
| `AI Summary` | Text (Long/Memo) | Kurze Zusammenfassung der Analyse |
|
||||
| `AI Last Update` | Date | Zeitstempel der letzten Anreicherung |
|
||||
| `AI Status` | List/Select | Pending / Enriched / Error |
|
||||
|
||||
### Benötigtes Feld im Company Explorer
|
||||
| Feldname | Typ | Zweck |
|
||||
| :--- | :--- | :--- |
|
||||
| `external_crm_id` | String/Int | Speichert die `ContactId` aus SuperOffice zur eindeutigen Zuordnung (Primary Key Mapping). |
|
||||
|
||||
### 2.2. Data Integrity: "The Double Truth"
|
||||
|
||||
Um die Datenqualität zu sichern, pflegen wir für Stammdaten (Name, Website) zwei Wahrheiten:
|
||||
1. **CRM Truth:** Was aktuell in SuperOffice steht (oft manuell gepflegt, potenziell veraltet).
|
||||
2. **Explorer Truth:** Was der Company Explorer im Web gefunden hat.
|
||||
|
||||
**Synchronisation:**
|
||||
* Bei jedem Webhook-Event sendet der Connector die aktuellen CRM-Werte (`crm_name`, `crm_website`) an den Company Explorer.
|
||||
* Der Company Explorer speichert diese und berechnet einen **`data_mismatch_score`** (0.0 = Match, 1.0 = Mismatch).
|
||||
* **UI:** Im Frontend wird dieser Score visualisiert, sodass Abweichungen sofort erkennbar sind.
|
||||
|
||||
## 2.1. Mapping of CRM Concepts (SuperOffice vs. D365)
|
||||
|
||||
Um die Integration effizient zu gestalten, wurde eine strategische Entscheidung bezüglich der Abbildung von Kern-CRM-Konzepten getroffen:
|
||||
|
||||
| D365 Konzept | SuperOffice Entität | Zweck & Begründung |
|
||||
| :--- | :--- | :--- |
|
||||
| **Opportunity** | `Sale` | Die `Sale`-Entität in SuperOffice ist das direkte Äquivalent zu einer Opportunity. Hier werden potenzielle Umsätze, Vertriebsphasen und Wahrscheinlichkeiten erfasst. Dies ist das primäre Zielobjekt, sobald eine konkrete Verkaufschance durch den Company Explorer identifiziert wird. |
|
||||
| **Campaign** | `Project` | Für Marketing-Automatisierung und die Bündelung von Kontakten für Kampagnen dient die `Project`-Entität als idealer Container. Sie ermöglicht es, Kampagnen-Teilnehmer zu gruppieren, Aktivitäten zuzuordnen und den ROI durch Verknüpfung mit `Sale`-Objekten zu messen. |
|
||||
|
||||
---
|
||||
|
||||
## 3. Phasenplan
|
||||
|
||||
### Phase 1: Initial Load (Snapshot)
|
||||
*Ziel: Bestandskunden und aktive Leads einmalig bewerten.*
|
||||
|
||||
1. **Filter:** Der Connector lädt alle Firmen mit Status "Kunde" oder "Prospect".
|
||||
2. **Import:** Daten werden via `POST /api/companies/bulk` an den Explorer gesendet. Die `ContactId` wird mitgegeben.
|
||||
3. **Verarbeitung:** Der Explorer arbeitet die Queue ab (Web-Scraping -> Klassifizierung).
|
||||
|
||||
### Phase 2: Write-Back (Ergebnisse speichern)
|
||||
*Ziel: Ergebnisse im CRM sichtbar machen.*
|
||||
|
||||
1. Der Connector prüft regelmäßig (`GET /api/companies?status=ENRICHED`) auf fertige Analysen.
|
||||
2. Für jeden Treffer sendet er ein Update an die SuperOffice API (`PUT /Contact/{id}`).
|
||||
3. Es werden nur die oben definierten UDFs aktualisiert.
|
||||
|
||||
### Phase 3: Continuous Sync (Event-Driven)
|
||||
*Ziel: Skalierbare, echtzeitnahe Verarbeitung.*
|
||||
|
||||
1. **Auslöser:** Ein `contact.changed` oder `person.changed` Event in SuperOffice (z.B. User setzt Status auf `Init`).
|
||||
2. **Transport:** SuperOffice Webhook -> Connector `POST /webhook` -> Queue.
|
||||
3. **Verarbeitung:** Der `Worker` holt den Job, ruft die **Provisioning API** des Company Explorer auf.
|
||||
4. **Vorteil:**
|
||||
* **Kein unnötiger Traffic:** Wir verarbeiten nur Kontakte, bei denen wirklich etwas passiert.
|
||||
* **Echtzeit:** Änderungen sind sofort wirksam.
|
||||
* **SO-Konformität:** Wir nutzen den offiziellen, effizienten Weg für Integrationen.
|
||||
|
||||
## 4. Sicherheit & Authentifizierung
|
||||
|
||||
* **Authentifizierung:** Nutzung eines **System User Tokens** (Machine-to-Machine). Dies verhindert, dass Passwörter von persönlichen Accounts im Code hinterlegt werden müssen.
|
||||
* **Scope:** Der API-User benötigt Lesezugriff auf `Contact` und Schreibzugriff auf die `UDFs`.
|
||||
* **Datenschutz:** Es werden nur Firmendaten (Name, Webseite, Stadt) übertragen. Personenbezogene Ansprechpartner bleiben im CRM und werden nicht an die KI gesendet.
|
||||
|
||||
### 4.1. POC Ergebnisse & Finale Authentifizierungs-Strategie (Feb 2026)
|
||||
|
||||
Der Proof of Concept (POC) wurde erfolgreich abgeschlossen. Dabei wurde die Authentifizierungs-Strategie für maximale Stabilität und Einfachheit angepasst.
|
||||
|
||||
**Ergebnis:**
|
||||
* **Erfolg:** Die Verbindung zum SOD-Tenant (`Cust55774`) steht. Der Connector kann Daten lesen und ist bereit zum Schreiben.
|
||||
* **Strategiewechsel:** Statt des komplexen RSA-S2S-Flows wird der **OAuth 2.0 Refresh Token Flow** genutzt. Dies umgeht Lizenz- und UI-Einschränkungen in der SOD-Umgebung und bietet dieselbe Automatisierungsqualität für den Docker-Service.
|
||||
* **Subdomain-Handling:** Es wurde festgestellt, dass SuperOffice Online (SOD) mandantenabhängige Subdomains nutzt. Für den Test-Tenant wurde **`https://app-sod.superoffice.com`** als valide API-Basis identifiziert.
|
||||
|
||||
**Technische Umsetzung:**
|
||||
1. **Einmalige Autorisierung:** Ein langlebiger `refresh_token` wurde über einen manuellen Consent-Step generiert.
|
||||
2. **Automatisierung:** Der `AuthHandler` tauscht diesen Token vollautomatisch gegen kurzlebige `access_tokens` (Bearer) aus.
|
||||
3. **Caching:** Tokens werden lokal in `token_cache.json` gespeichert, um API-Limits zu schonen.
|
||||
|
||||
**Aktueller Status & Nächste Schritte:**
|
||||
* **Blocker gelöst:** Die Authentifizierung und das URL-Routing sind stabil.
|
||||
* **Nächster Schritt:** Manuelle Anlage der UDF-Felder (siehe Abschnitt 2) in der SuperOffice Administration durch den Admin. Erst danach kann der "Write-Back" (Phase 2) im Code final gemappt werden.
|
||||
|
||||
## 5. Vorbereitung für die IT
|
||||
|
||||
Um den Connector in Betrieb zu nehmen, benötigen wir:
|
||||
1. **API Zugangsdaten:** `Client ID`, `Client Secret`, `Customer ID` (Tenant) und einen `System User Token`.
|
||||
2. **UDF Definitionen:** Die `ProgId` (technischen Namen) der neu angelegten Felder (z.B. `userdef_id_123`).
|
||||
|
||||
## 6. Fragen an Manuel
|
||||
|
||||
1. Die "Lizenz-Gretchenfrage" (Development Tools)
|
||||
Frage: "Manuel, du sagtest, bei Wackler sind die 'Development Tools' aktiv. Weißt du, ob das eine globale Konzern-Lizenz ist oder ob wir für den RoboPlanet-Mandanten eine eigene Subskription brauchen? Mein Dev-Portal blockiert S2S aktuell mit dem Hinweis auf fehlende Lizenzen in Production."
|
||||
Ziel: Klären, ob es nur ein "Klick" im Admin-Panel ist oder ob Webkom (oder ein anderer Partner) eine neue Rechnung schreiben muss.
|
||||
|
||||
2. Der App-Registrierungs-Pfad
|
||||
Frage: "Hast du eure App als 'Custom Application' (privat für Wackler) oder als 'Standard Application' (über einen Partner-Account) registriert? Falls ihr einen Partner-Account nutzt: Könnten wir unsere GTM-Engine darüber mitlaufen lassen, um die Lizenz-Hürde zu umgehen?"
|
||||
Ziel: Prüfen, ob Manuel über ein Partner-Portal arbeitet. Partner-Apps brauchen manchmal keine Dev-Tools beim Kunden, weil der Partner die Validierung übernimmt.
|
||||
|
||||
3. Identifikation des "Gatekeepers"
|
||||
Frage: "Wer hat bei euch den Tenant technisch aufgesetzt? War das die Webkom? Ich muss herausfinden, wer die administrative Hoheit hat, um den 'System User' für S2S freizuschalten."
|
||||
Ziel: Den Namen des Ansprechpartners bei der Webkom oder intern bei Wackler-IT herausfinden.
|
||||
|
||||
4. Authentifizierungs-Deep-Dive (RSA vs. Token)
|
||||
Frage: "Nutzt ihr für eure S2S-App den RSA-Key-Flow (JWT) oder arbeitet ihr mit einem statischen System-User-Token? Ich bereite gerade den RSA-Handshake vor und wollte wissen, was in der SuperOffice Cloud stabiler läuft."
|
||||
Ziel: Fachsimpelei, um Manuel zu zeigen, dass du auf seinem Level spielst. Das öffnet Türen für Code-Sharing oder Tipps.
|
||||
|
||||
5. Das "Y-Tabellen" Problem
|
||||
Frage: "Nutzt ihr für eure Verkaufs-App auch Zusatztabellen (Y-Tabellen) oder schreibt ihr nur in Standardfelder? Ich plane eine Tabelle für dynamische Marketing-Texte (Rolle x Branche) – gab es bei euch Probleme mit dem Cache nach Strukturänderungen?"
|
||||
Ziel: Bestätigung einholen, dass Y-Tabellen mit ihrer Lizenz funktionieren. Das ist dein Beweis, dass du die Dev-Tools zwingend brauchst.
|
||||
|
||||
6. Authentifizierung beim S2S Call
|
||||
Wie authentifiziert ihr euch beim S2S-Call? Nutzt ihr den RSA-Flow mit Zertifikaten oder habt ihr einen Partner-Proxy dazwischen?" (Wenn er "Partner-Proxy" sagt, arbeitet er über Webkom-Infrastruktur).
|
||||
|
||||
7. Nutzung System User
|
||||
Wo habt ihr den 'System User' im CRM autorisiert?" (Er soll dir den Pfad in Einstellungen & Verwaltung zeigen).
|
||||
|
||||
8. Header API-Call
|
||||
Könntest du mir den Header eines eurer API-Calls zeigen (natürlich ohne den echten Token)?" (Daran sehen wir sofort, ob sie die v1 REST API oder die alte SOAP-Schnittstelle nutzen).
|
||||
@@ -1,27 +0,0 @@
|
||||
# Task-Statusbericht: [2f388f42] Report mistakes
|
||||
|
||||
**Status:** ✅ Abgeschlossen
|
||||
**Bearbeitungszeit (ca.):** 02:00 Stunden (Bitte in Notion aktualisieren)
|
||||
|
||||
**Zusammenfassung:**
|
||||
Das "Report mistakes"-Feature wurde erfolgreich im Company Explorer implementiert. Benutzer können nun Datenfehler auf Unternehmensseiten markieren und Korrekturen vorschlagen. Diese werden in einer neuen Datenbanktabelle gesammelt und können im Einstellungsbereich eingesehen und genehmigt/abgelehnt werden.
|
||||
|
||||
**Implementierte Features:**
|
||||
* **Backend:**
|
||||
* Neue SQLite-Tabelle `reported_mistakes` für Fehlerberichte.
|
||||
* FastAPI-Endpunkte: `POST /api/companies/{company_id}/report-mistake`, `GET /api/mistakes`, `PUT /api/mistakes/{mistake_id}`.
|
||||
* SQLAlchemy-Modell und DB-Migration für `reported_mistakes`.
|
||||
* **Frontend:**
|
||||
* "Fehler melden"-Button mit Modalfenster in `Inspector.tsx`.
|
||||
* Dynamisches Dropdown für Feldnamen im Meldeformular (mit Vor-Ausfüllfunktion).
|
||||
* Neuer Tab "Reported Mistakes" in `RoboticsSettings.tsx` mit einer Übersichtstabelle.
|
||||
* Buttons zum Genehmigen/Ablehnen von Fehlermeldungen für `PENDING`-Einträge.
|
||||
* **Dokumentation:** `MIGRATION_PLAN.md` aktualisiert mit Plan und Dateipfaden.
|
||||
|
||||
**Nächste Schritte (Konzept für zukünftige Verbesserungen):**
|
||||
Die gesammelten und genehmigten Korrekturen bilden eine wertvolle Basis für die kontinuierliche Verbesserung der Datenqualität. Sie können genutzt werden für:
|
||||
* LLM Fine-Tuning oder Prompt-Verbesserung zur Steigerung der Extraktionsgenauigkeit.
|
||||
* Anpassung von Scraping-Regeln oder Parser-Logik zur Behebung systematischer Fehler.
|
||||
* Potenzielle automatisierte Datenkorrektur bei hoher Konfidenz.
|
||||
|
||||
---
|
||||
@@ -57,8 +57,6 @@ const App: React.FC = () => {
|
||||
const [generationStep, setGenerationStep] = useState<number>(0); // 0: idle, 1-6: step X is complete
|
||||
const [selectedIndustry, setSelectedIndustry] = useState<string>('');
|
||||
const [batchStatus, setBatchStatus] = useState<{ current: number; total: number; industry: string } | null>(null);
|
||||
const [isEnriching, setIsEnriching] = useState<boolean>(false);
|
||||
|
||||
|
||||
// Project Persistence
|
||||
const [projectId, setProjectId] = useState<string | null>(null);
|
||||
@@ -71,43 +69,6 @@ const App: React.FC = () => {
|
||||
const STEP_TITLES = t.stepTitles;
|
||||
const STEP_KEYS: (keyof AnalysisData)[] = ['offer', 'targetGroups', 'personas', 'painPoints', 'gains', 'messages', 'customerJourney'];
|
||||
|
||||
const handleEnrichRow = async (productName: string, productUrl?: string) => {
|
||||
setIsEnriching(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/enrich-product`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
productName,
|
||||
productUrl,
|
||||
language: inputData.language
|
||||
}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.details || `HTTP error! status: ${response.status}`);
|
||||
}
|
||||
const newRow = await response.json();
|
||||
setAnalysisData(prev => {
|
||||
const currentOffer = prev.offer || { headers: [], rows: [], summary: [] };
|
||||
return {
|
||||
...prev,
|
||||
offer: {
|
||||
...currentOffer,
|
||||
rows: [...currentOffer.rows, newRow]
|
||||
}
|
||||
};
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setError(e instanceof Error ? `Fehler beim Anreichern: ${e.message}` : 'Unbekannter Fehler beim Anreichern.');
|
||||
} finally {
|
||||
setIsEnriching(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// --- AUTO-SAVE EFFECT ---
|
||||
useEffect(() => {
|
||||
if (generationStep === 0 || !inputData.companyUrl) return;
|
||||
@@ -546,10 +507,9 @@ const App: React.FC = () => {
|
||||
const canAdd = ['offer', 'targetGroups'].includes(stepKey);
|
||||
const canDelete = ['offer', 'targetGroups', 'personas'].includes(stepKey);
|
||||
|
||||
const handleManualAdd = () => {
|
||||
const newEmptyRow = Array(step.headers.length).fill('');
|
||||
const handleManualAdd = (newRow: string[]) => {
|
||||
const currentRows = step.rows || [];
|
||||
handleDataChange(stepKey, { ...step, rows: [...currentRows, newEmptyRow] });
|
||||
handleDataChange(stepKey, { ...step, rows: [...currentRows, newRow] });
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -561,8 +521,8 @@ const App: React.FC = () => {
|
||||
rows={step.rows}
|
||||
onDataChange={(newRows) => handleDataChange(stepKey, { ...step, rows: newRows })}
|
||||
canAddRows={canAdd}
|
||||
onEnrichRow={stepKey === 'offer' ? handleEnrichRow : handleManualAdd}
|
||||
isEnriching={isEnriching}
|
||||
onEnrichRow={canAdd ? handleManualAdd : undefined}
|
||||
isEnriching={false}
|
||||
canDeleteRows={canDelete}
|
||||
onRestart={() => handleStepRestart(stepKey)}
|
||||
t={t}
|
||||
|
||||
@@ -15,6 +15,6 @@ View your app in AI Studio: https://ai.studio/apps/drive/1ZPnGbhaEnyhIyqs2rYhcPX
|
||||
|
||||
1. Install dependencies:
|
||||
`npm install`
|
||||
2. Set the `GEMINI_API_KEY` in the central `.env` file in the project's root directory.
|
||||
2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
|
||||
3. Run the app:
|
||||
`npm run dev`
|
||||
|
||||
@@ -12,7 +12,7 @@ interface StepDisplayProps {
|
||||
onDataChange: (newRows: string[][]) => void;
|
||||
canAddRows?: boolean;
|
||||
canDeleteRows?: boolean;
|
||||
onEnrichRow?: (productName: string, productUrl?: string) => void;
|
||||
onEnrichRow?: (productName: string, productUrl?: string) => Promise<void>;
|
||||
isEnriching?: boolean;
|
||||
onRestart?: () => void;
|
||||
t: typeof translations.de;
|
||||
@@ -106,7 +106,12 @@ export const StepDisplay: React.FC<StepDisplayProps> = ({ title, summary, header
|
||||
};
|
||||
|
||||
const handleAddRowClick = () => {
|
||||
setIsAddingRow(true);
|
||||
if (onEnrichRow) {
|
||||
setIsAddingRow(true);
|
||||
} else {
|
||||
const newEmptyRow = Array(headers.length).fill('');
|
||||
onDataChange([...rows, newEmptyRow]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirmAddRow = () => {
|
||||
|
||||
@@ -89,15 +89,6 @@ router.post('/next-step', (req, res) => {
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
router.post('/enrich-product', (req, res) => {
|
||||
const { productName, productUrl, language } = req.body;
|
||||
const args = [SCRIPT_PATH, '--mode', 'enrich_product', '--product_name', productName, '--language', language];
|
||||
if (productUrl) {
|
||||
args.push('--product_url', productUrl);
|
||||
}
|
||||
runPythonScript(args, res);
|
||||
});
|
||||
|
||||
router.get('/projects', (req, res) => runPythonScript([dbScript, 'list'], res));
|
||||
router.get('/projects/:id', (req, res) => runPythonScript([dbScript, 'load', req.params.id], res));
|
||||
router.delete('/projects/:id', (req, res) => runPythonScript([dbScript, 'delete', req.params.id], res));
|
||||
|
||||
@@ -3,7 +3,7 @@ import { defineConfig, loadEnv } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, '../', '');
|
||||
const env = loadEnv(mode, '.', '');
|
||||
return {
|
||||
base: '/b2b/',
|
||||
server: {
|
||||
|
||||
@@ -622,40 +622,6 @@ def next_step(language, context_file, generation_step, channels, focus_industry=
|
||||
summary = [re.sub(r'^\*\s*|^-\s*|^\d+\.\s*', '', s.strip()) for s in summary_match[1].split('\n') if s.strip()] if summary_match else []
|
||||
return {step_key: {"summary": summary, "headers": table_data['headers'], "rows": table_data['rows']}}
|
||||
|
||||
def enrich_product(product_name, product_url, language):
|
||||
logging.info(f"Enriching product: {product_name} ({product_url})")
|
||||
api_key = load_api_key()
|
||||
if not api_key: raise ValueError("Gemini API key is missing.")
|
||||
|
||||
grounding_text = ""
|
||||
if product_url:
|
||||
grounding_text = get_text_from_url(product_url)
|
||||
|
||||
prompt_text = f"""
|
||||
# ANWEISUNG
|
||||
Du bist ein B2B-Marketing-Analyst. Deine Aufgabe ist es, die Daten für EIN Produkt zu generieren.
|
||||
Basierend auf dem Produktnamen und (optional) dem Inhalt der Produkt-URL, fülle die Spalten einer Markdown-Tabelle aus.
|
||||
Die Ausgabe MUSS eine einzelne, kommaseparierte Zeile sein, die in eine Tabelle passt. KEINE Header, KEIN Markdown, nur die Werte.
|
||||
|
||||
# PRODUKT
|
||||
- Name: "{product_name}"
|
||||
- URL-Inhalt: "{grounding_text[:3000]}..."
|
||||
|
||||
# SPALTEN
|
||||
Produkt/Lösung | Beschreibung (1-2 Sätze) | Kernfunktionen | Differenzierung | Primäre Quelle (URL)
|
||||
|
||||
# BEISPIEL-OUTPUT
|
||||
Saugroboter NR1500,Ein professioneller Saugroboter für große Büroflächen.,Autonome Navigation;Intelligente Kartierung;Lange Akkulaufzeit,Fokus auf B2B-Markt;Datenschutzkonform,https://nexaro.com/products/nr1500
|
||||
|
||||
# DEINE AUFGABE
|
||||
Erstelle jetzt die kommaseparierte Zeile für das Produkt "{product_name}".
|
||||
"""
|
||||
|
||||
response_text = call_gemini_api(prompt_text, api_key)
|
||||
|
||||
# Return as a simple list of strings
|
||||
return [cell.strip() for cell in response_text.split(',')]
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('--mode', required=True)
|
||||
@@ -667,13 +633,10 @@ def main():
|
||||
parser.add_argument('--channels')
|
||||
parser.add_argument('--language', required=True)
|
||||
parser.add_argument('--focus_industry') # New argument
|
||||
parser.add_argument('--product_name')
|
||||
parser.add_argument('--product_url')
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
if args.mode == 'start_generation': result = start_generation(args.url, args.language, args.regions, args.focus)
|
||||
elif args.mode == 'next_step': result = next_step(args.language, args.context_file, args.generation_step, args.channels, args.focus_industry)
|
||||
elif args.mode == 'enrich_product': result = enrich_product(args.product_name, args.product_url, args.language)
|
||||
sys.stdout.write(json.dumps(result, ensure_ascii=False))
|
||||
except Exception as e:
|
||||
logging.error(f"Error: {e}", exc_info=True)
|
||||
|
||||
@@ -1,51 +1,29 @@
|
||||
# --- STAGE 1: Build Frontend ---
|
||||
# This stage uses a more robust, standard pattern for building Node.js apps.
|
||||
# It creates a dedicated 'frontend' directory inside the container to avoid potential
|
||||
# file conflicts in the root directory.
|
||||
FROM node:20-slim AS frontend-builder
|
||||
WORKDIR /app
|
||||
# Copy the entire frontend project into a 'frontend' subdirectory
|
||||
COPY frontend ./frontend
|
||||
# Set the working directory to the new subdirectory
|
||||
WORKDIR /app/frontend
|
||||
# Install dependencies and build the project from within its own directory
|
||||
RUN npm install --no-audit --no-fund
|
||||
WORKDIR /build
|
||||
COPY frontend/package*.json ./
|
||||
RUN npm install
|
||||
COPY frontend/ ./
|
||||
RUN grep "ROBOTICS EDITION" src/App.tsx || echo "Version string not found in App.tsx"
|
||||
RUN npm run build
|
||||
|
||||
# --- STAGE 2: Backend Builder ---
|
||||
FROM python:3.11-slim AS backend-builder
|
||||
WORKDIR /app
|
||||
|
||||
# Install only the bare essentials for building Python wheels
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends build-essential gcc && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt .
|
||||
# Install to /install to easily copy to final stage
|
||||
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt
|
||||
|
||||
# --- STAGE 3: Final Runtime ---
|
||||
# --- STAGE 2: Backend & Runtime ---
|
||||
FROM python:3.11-slim
|
||||
WORKDIR /app
|
||||
|
||||
# Set non-interactive to avoid prompts
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
# System Dependencies
|
||||
RUN apt-get update && apt-get install -y \
|
||||
build-essential \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Minimal runtime system dependencies (if any are ever needed)
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends curl && \
|
||||
apt-get clean && \
|
||||
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
|
||||
# Copy Requirements & Install
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy only the installed Python packages
|
||||
COPY --from=backend-builder /install /usr/local
|
||||
ENV PATH=/usr/local/bin:$PATH
|
||||
# Copy Built Frontend from Stage 1 (To a safe location outside /app)
|
||||
COPY --from=frontend-builder /build/dist /frontend_static
|
||||
|
||||
# Copy Built Frontend from the new, correct location
|
||||
COPY --from=frontend-builder /app/frontend/dist /frontend_static
|
||||
|
||||
# Copy only necessary Backend Source
|
||||
# Copy Backend Source
|
||||
COPY backend ./backend
|
||||
|
||||
# Environment Variables
|
||||
@@ -55,5 +33,5 @@ ENV PYTHONUNBUFFERED=1
|
||||
# Expose Port
|
||||
EXPOSE 8000
|
||||
|
||||
# Start FastAPI (Production mode without --reload)
|
||||
CMD ["uvicorn", "backend.app:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
# Start FastAPI
|
||||
CMD ["uvicorn", "backend.app:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
|
||||
@@ -1,178 +0,0 @@
|
||||
# Migrations-Plan: Legacy GSheets -> Company Explorer (Robotics Edition v0.8.5)
|
||||
|
||||
**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.
|
||||
|
||||
## 1. Strategische Neuausrichtung
|
||||
|
||||
| Bereich | Alt (Legacy) | Neu (Robotics Edition) |
|
||||
| :--- | :--- | :--- |
|
||||
| **Daten-Basis** | Google Sheets | **SQLite** (Lokal, performant, filterbar). |
|
||||
| **Ziel-Daten** | Allgemein / Kundenservice | **Quantifizierbares Potenzial** (z.B. 4500m² Fläche, 120 Betten). |
|
||||
| **Branchen** | KI-Vorschlag (Freitext) | **Strict Mode:** Mapping auf definierte Notion-Liste (z.B. "Hotellerie", "Automotive"). |
|
||||
| **Bewertung** | 0-100 Score (Vage) | **Data-Driven:** Rohwert (Scraper/Search) -> Standardisierung (Formel) -> Potenzial. |
|
||||
| **Analytics** | Techniker-ML-Modell | **Deaktiviert**. Fokus auf harte Fakten. |
|
||||
| **Operations** | D365 Sync (Broken) | **Excel-Import & Deduplizierung**. Fokus auf Matching externer Listen gegen Bestand. |
|
||||
|
||||
## 2. Architektur & Komponenten-Mapping
|
||||
|
||||
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 |
|
||||
| **Classification Service** | **NEU (v0.7.0).** Zweistufige Logik: <br> 1. Strict Industry Classification. <br> 2. Metric Extraction Cascade (Web -> Wiki -> SerpAPI). | 1 |
|
||||
| **Marketing Engine** | Ersetzt `generate_marketing_text.py`. Nutzt neue `marketing_wissen_robotics.yaml`. | 3 |
|
||||
|
||||
**Identifizierte Hauptdatei:** `company-explorer/backend/app.py`
|
||||
|
||||
### 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.
|
||||
* **Identifizierte Komponente:** `company-explorer/frontend/src/components/Inspector.tsx`
|
||||
* **View 3: "List Matcher":** Upload einer Excel-Liste -> Anzeige von Duplikaten -> Button "Neue importieren".
|
||||
* **View 4: "Settings":** Konfiguration von Branchen, Rollen und Robotik-Logik.
|
||||
* **Frontend "Settings" Komponente:** `company-explorer/frontend/src/components/RoboticsSettings.tsx`
|
||||
|
||||
### C. Architekturmuster für die Client-Integration
|
||||
|
||||
Um externen Diensten (wie der `lead-engine`) eine einfache und robuste Anbindung an den `company-explorer` zu ermöglichen, wurde ein standardisiertes Client-Connector-Muster implementiert.
|
||||
|
||||
| Komponente | Aufgabe & Neue Logik |
|
||||
| :--- | :--- |
|
||||
| **`company_explorer_connector.py`** | **NEU:** Ein zentrales Python-Skript, das als "offizieller" Client-Wrapper für die API des Company Explorers dient. Es kapselt die Komplexität der asynchronen Enrichment-Prozesse. |
|
||||
| **`handle_company_workflow()`** | Die Kernfunktion des Connectors. Sie implementiert den vollständigen "Find-or-Create-and-Enrich"-Workflow: <br> 1. **Prüfen:** Stellt fest, ob ein Unternehmen bereits existiert. <br> 2. **Erstellen:** Legt das Unternehmen an, falls es neu ist. <br> 3. **Anstoßen:** Startet den asynchronen `discover`-Prozess. <br> 4. **Warten (Polling):** Überwacht den Status des Unternehmens, bis eine Website gefunden wurde. <br> 5. **Analysieren:** Startet den asynchronen `analyze`-Prozess. <br> **Vorteil:** Bietet dem aufrufenden Dienst eine einfache, quasi-synchrone Schnittstelle und stellt sicher, dass die Prozessschritte in der korrekten Reihenfolge ausgeführt werden. |
|
||||
|
||||
### D. Provisioning API (Internal)
|
||||
|
||||
Für die nahtlose Integration mit dem SuperOffice Connector wurde ein dedizierter Endpunkt geschaffen:
|
||||
|
||||
| Endpunkt | Methode | Zweck |
|
||||
| :--- | :--- | :--- |
|
||||
| `/api/provision/superoffice-contact` | POST | Liefert "Enrichment-Pakete" (Texte, Status) für einen gegebenen CRM-Kontakt. Greift auf `MarketingMatrix` zu. |
|
||||
|
||||
## 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 relevante Teile und ergänzen sie (z.B. `safe_eval_math`, `run_serp_search`).
|
||||
|
||||
## 4. Datenstruktur (SQLite Schema)
|
||||
|
||||
### Tabelle `companies` (Stammdaten & Analyse)
|
||||
* `id` (PK)
|
||||
* `name` (String)
|
||||
* `website` (String)
|
||||
* `crm_id` (String, nullable - Link zum D365)
|
||||
* `industry_crm` (String - Die "erlaubte" Branche aus Notion)
|
||||
* `city` (String)
|
||||
* `country` (String - Standard: "DE" oder aus Impressum)
|
||||
* `status` (Enum: NEW, IMPORTED, ENRICHED, QUALIFIED)
|
||||
* **NEU (v0.7.0):**
|
||||
* `calculated_metric_name` (String - z.B. "Anzahl Betten")
|
||||
* `calculated_metric_value` (Float - z.B. 180)
|
||||
* `calculated_metric_unit` (String - z.B. "Betten")
|
||||
* `standardized_metric_value` (Float - z.B. 4500)
|
||||
* `standardized_metric_unit` (String - z.B. "m²")
|
||||
* `metric_source` (String - "website", "wikipedia", "serpapi")
|
||||
|
||||
### Tabelle `signals` (Deprecated)
|
||||
* *Veraltet ab v0.7.0. Wird durch quantitative Metriken in `companies` ersetzt.*
|
||||
|
||||
### Tabelle `contacts` (Ansprechpartner)
|
||||
* `id` (PK)
|
||||
* `account_id` (FK -> companies.id)
|
||||
* `gender`, `title`, `first_name`, `last_name`, `email`
|
||||
* `job_title` (Visitenkarte)
|
||||
* `role` (Standardisierte Rolle: "Operativer Entscheider", etc.)
|
||||
* `status` (Marketing Status)
|
||||
|
||||
### Tabelle `industries` (Branchen-Fokus - Synced from Notion)
|
||||
* `id` (PK)
|
||||
* `notion_id` (String, Unique)
|
||||
* `name` (String - "Vertical" in Notion)
|
||||
* `description` (Text - "Definition" in Notion)
|
||||
* `metric_type` (String - "Metric Type")
|
||||
* `min_requirement` (Float - "Min. Requirement")
|
||||
* `whale_threshold` (Float - "Whale Threshold")
|
||||
* `proxy_factor` (Float - "Proxy Factor")
|
||||
* `scraper_search_term` (String - "Scraper Search Term")
|
||||
* `scraper_keywords` (Text - "Scraper Keywords")
|
||||
* `standardization_logic` (String - "Standardization Logic")
|
||||
|
||||
### Tabelle `job_role_mappings` (Rollen-Logik)
|
||||
* `id` (PK)
|
||||
* `pattern` (String - Regex für Jobtitles)
|
||||
* `role` (String - Zielrolle)
|
||||
|
||||
### Tabelle `marketing_matrix` (NEU v2.1)
|
||||
* **Zweck:** Speichert statische, genehmigte Marketing-Texte (Notion Sync).
|
||||
* `id` (PK)
|
||||
* `industry_id` (FK -> industries.id)
|
||||
* `role_id` (FK -> job_role_mappings.id)
|
||||
* `subject` (Text)
|
||||
* `intro` (Text)
|
||||
* `social_proof` (Text)
|
||||
|
||||
## 7. Historie & Fixes (Jan 2026)
|
||||
|
||||
* **[CRITICAL] v0.7.4: Service Restoration & Logic Fix (Jan 24, 2026)**
|
||||
* **[STABILITY] v0.7.3: Hardening Metric Parser & Regression Testing (Jan 23, 2026)**
|
||||
* **[STABILITY] v0.7.2: Robust Metric Parsing (Jan 23, 2026)**
|
||||
* **[STABILITY] v0.7.1: AI Robustness & UI Fixes (Jan 21, 2026)**
|
||||
* **[MAJOR] v0.7.0: Quantitative Potential Analysis (Jan 20, 2026)**
|
||||
* **[UPGRADE] v0.6.x: Notion Integration & UI Improvements**
|
||||
|
||||
## 14. Upgrade v2.0 (Feb 18, 2026): "Lead-Fabrik" Erweiterung
|
||||
|
||||
Dieses Upgrade transformiert den Company Explorer in das zentrale Gehirn der Lead-Generierung (Vorratskammer).
|
||||
|
||||
### 14.1 Detaillierte Logik der neuen Datenfelder
|
||||
|
||||
Um Gemini CLI (dem Bautrupp) die Umsetzung zu ermöglichen, hier die semantische Bedeutung der neuen Spalten:
|
||||
|
||||
#### Tabelle `companies` (Qualitäts- & Abgleich-Metriken)
|
||||
|
||||
* **`confidence_score` (FLOAT, 0.0 - 1.0):** Indikator für die Sicherheit der KI-Klassifizierung. `> 0.8` = Grün.
|
||||
* **`data_mismatch_score` (FLOAT, 0.0 - 1.0):** Abweichung zwischen CRM-Bestand und Web-Recherche (z.B. Umzug).
|
||||
* **`crm_name`, `crm_address`, `crm_website`, `crm_vat`:** Read-Only Snapshot aus SuperOffice zum Vergleich.
|
||||
* **Status-Flags:** `website_scrape_status` und `wiki_search_status`.
|
||||
|
||||
#### Tabelle `industries` (Strategie-Parameter)
|
||||
|
||||
* **`pains` / `gains`:** Strukturierte Textblöcke (getrennt durch `[Primary Product]` und `[Secondary Product]`).
|
||||
* **`ops_focus_secondary` (BOOLEAN):** Steuerung für rollenspezifische Produkt-Priorisierung.
|
||||
|
||||
---
|
||||
|
||||
## 15. Offene Arbeitspakete (Bauleitung)
|
||||
|
||||
Anweisungen für den "Bautrupp" (Gemini CLI).
|
||||
|
||||
### Task 1: UI-Anpassung - Side-by-Side CRM View & Settings
|
||||
(In Arbeit / Teilweise erledigt durch Gemini CLI)
|
||||
|
||||
### Task 2: Intelligenter CRM-Importer (Bestandsdaten)
|
||||
|
||||
**Ziel:** Importieren der `demo_100.xlsx` in die SQLite-Datenbank.
|
||||
|
||||
**Anforderungen:**
|
||||
1. **PLZ-Handling:** Zwingend als **String** einlesen (führende Nullen erhalten).
|
||||
2. **Normalisierung:** Website bereinigen (kein `www.`, `https://`).
|
||||
3. **Matching:** Kaskade über CRM-ID, VAT, Domain, Fuzzy Name.
|
||||
4. **Isolierung:** Nur `crm_` Spalten updaten, Golden Records unberührt lassen.
|
||||
|
||||
---
|
||||
|
||||
## 16. Deployment-Referenz (NAS)
|
||||
* **Pfad:** `/volume1/homes/Floke/python/brancheneinstufung/company-explorer`
|
||||
* **DB:** `/app/companies_v3_fixed_2.db`
|
||||
* **Sync:** `docker exec -it company-explorer python backend/scripts/sync_notion_to_ce_enhanced.py`
|
||||
@@ -8,21 +8,6 @@ from pydantic import BaseModel
|
||||
from datetime import datetime
|
||||
import os
|
||||
import sys
|
||||
from fastapi.security import HTTPBasic, HTTPBasicCredentials
|
||||
import secrets
|
||||
|
||||
security = HTTPBasic()
|
||||
|
||||
async def authenticate_user(credentials: HTTPBasicCredentials = Depends(security)):
|
||||
correct_username = secrets.compare_digest(credentials.username, os.getenv("API_USER", "default_user"))
|
||||
correct_password = secrets.compare_digest(credentials.password, os.getenv("API_PASSWORD", "default_password"))
|
||||
if not (correct_username and correct_password):
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Incorrect username or password",
|
||||
headers={"WWW-Authenticate": "Basic"},
|
||||
)
|
||||
return credentials.username
|
||||
|
||||
from .config import settings
|
||||
from .lib.logging_setup import setup_logging
|
||||
@@ -32,7 +17,7 @@ setup_logging()
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
from .database import init_db, get_db, Company, Signal, EnrichmentData, RoboticsCategory, Contact, Industry, JobRoleMapping, ReportedMistake, MarketingMatrix
|
||||
from .database import init_db, get_db, Company, Signal, EnrichmentData, RoboticsCategory, Contact, Industry, JobRoleMapping
|
||||
from .services.deduplication import Deduplicator
|
||||
from .services.discovery import DiscoveryService
|
||||
from .services.scraping import ScraperService
|
||||
@@ -42,7 +27,8 @@ from .services.classification import ClassificationService
|
||||
app = FastAPI(
|
||||
title=settings.APP_NAME,
|
||||
version=settings.VERSION,
|
||||
description="Backend for Company Explorer (Robotics Edition)"
|
||||
description="Backend for Company Explorer (Robotics Edition)",
|
||||
root_path="/ce"
|
||||
)
|
||||
|
||||
app.add_middleware(
|
||||
@@ -64,7 +50,6 @@ class CompanyCreate(BaseModel):
|
||||
city: Optional[str] = None
|
||||
country: str = "DE"
|
||||
website: Optional[str] = None
|
||||
crm_id: Optional[str] = None
|
||||
|
||||
class BulkImportRequest(BaseModel):
|
||||
names: List[str]
|
||||
@@ -76,28 +61,6 @@ class AnalysisRequest(BaseModel):
|
||||
class IndustryUpdateModel(BaseModel):
|
||||
industry_ai: str
|
||||
|
||||
class ReportMistakeRequest(BaseModel):
|
||||
field_name: str
|
||||
wrong_value: Optional[str] = None
|
||||
corrected_value: Optional[str] = None
|
||||
source_url: Optional[str] = None
|
||||
quote: Optional[str] = None
|
||||
user_comment: Optional[str] = None
|
||||
|
||||
class ProvisioningRequest(BaseModel):
|
||||
so_contact_id: int
|
||||
so_person_id: Optional[int] = None
|
||||
crm_name: Optional[str] = None
|
||||
crm_website: Optional[str] = None
|
||||
|
||||
class ProvisioningResponse(BaseModel):
|
||||
status: str
|
||||
company_name: str
|
||||
website: Optional[str] = None
|
||||
vertical_name: Optional[str] = None
|
||||
role_name: Optional[str] = None
|
||||
texts: Dict[str, Optional[str]] = {}
|
||||
|
||||
# --- Events ---
|
||||
@app.on_event("startup")
|
||||
def on_startup():
|
||||
@@ -111,152 +74,16 @@ def on_startup():
|
||||
# --- Routes ---
|
||||
|
||||
@app.get("/api/health")
|
||||
def health_check(username: str = Depends(authenticate_user)):
|
||||
def health_check():
|
||||
return {"status": "ok", "version": settings.VERSION, "db": settings.DATABASE_URL}
|
||||
|
||||
@app.post("/api/provision/superoffice-contact", response_model=ProvisioningResponse)
|
||||
def provision_superoffice_contact(
|
||||
req: ProvisioningRequest,
|
||||
background_tasks: BackgroundTasks,
|
||||
db: Session = Depends(get_db),
|
||||
username: str = Depends(authenticate_user)
|
||||
):
|
||||
# 1. Find Company (via SO ID)
|
||||
company = db.query(Company).filter(Company.crm_id == str(req.so_contact_id)).first()
|
||||
|
||||
if not company:
|
||||
# AUTO-CREATE Logic
|
||||
if not req.crm_name:
|
||||
# Cannot create without name. Should ideally not happen if Connector does its job.
|
||||
raise HTTPException(400, "Cannot create company: crm_name missing")
|
||||
|
||||
company = Company(
|
||||
name=req.crm_name,
|
||||
crm_id=str(req.so_contact_id),
|
||||
crm_name=req.crm_name,
|
||||
crm_website=req.crm_website,
|
||||
status="NEW"
|
||||
)
|
||||
db.add(company)
|
||||
db.commit()
|
||||
db.refresh(company)
|
||||
logger.info(f"Auto-created company {company.name} from SuperOffice request.")
|
||||
|
||||
# Trigger Discovery
|
||||
background_tasks.add_task(run_discovery_task, company.id)
|
||||
|
||||
return ProvisioningResponse(
|
||||
status="processing",
|
||||
company_name=company.name
|
||||
)
|
||||
|
||||
# 1b. Check Status & Progress
|
||||
# If NEW or DISCOVERED, we are not ready to provide texts.
|
||||
if company.status in ["NEW", "DISCOVERED"]:
|
||||
# If we have a website, ensure analysis is triggered
|
||||
if company.status == "DISCOVERED" or (company.website and company.website != "k.A."):
|
||||
background_tasks.add_task(run_analysis_task, company.id)
|
||||
elif company.status == "NEW":
|
||||
# Ensure discovery runs
|
||||
background_tasks.add_task(run_discovery_task, company.id)
|
||||
|
||||
return ProvisioningResponse(
|
||||
status="processing",
|
||||
company_name=company.name
|
||||
)
|
||||
|
||||
# 1c. Update CRM Snapshot Data (The Double Truth)
|
||||
changed = False
|
||||
if req.crm_name:
|
||||
company.crm_name = req.crm_name
|
||||
changed = True
|
||||
if req.crm_website:
|
||||
company.crm_website = req.crm_website
|
||||
changed = True
|
||||
|
||||
# Simple Mismatch Check
|
||||
if company.website and company.crm_website:
|
||||
def norm(u): return str(u).lower().replace("https://", "").replace("http://", "").replace("www.", "").strip("/")
|
||||
if norm(company.website) != norm(company.crm_website):
|
||||
company.data_mismatch_score = 0.8 # High mismatch
|
||||
changed = True
|
||||
else:
|
||||
if company.data_mismatch_score != 0.0:
|
||||
company.data_mismatch_score = 0.0
|
||||
changed = True
|
||||
|
||||
if changed:
|
||||
company.updated_at = datetime.utcnow()
|
||||
db.commit()
|
||||
|
||||
# 2. Find Contact (Person)
|
||||
if req.so_person_id is None:
|
||||
# Just a company sync, no texts needed
|
||||
return ProvisioningResponse(
|
||||
status="success",
|
||||
company_name=company.name,
|
||||
website=company.website,
|
||||
vertical_name=company.industry_ai
|
||||
)
|
||||
|
||||
person = db.query(Contact).filter(Contact.so_person_id == req.so_person_id).first()
|
||||
|
||||
# 3. Determine Role
|
||||
role_name = None
|
||||
if person and person.role:
|
||||
role_name = person.role
|
||||
elif req.job_title:
|
||||
# Simple classification fallback
|
||||
mappings = db.query(JobRoleMapping).all()
|
||||
for m in mappings:
|
||||
# Check pattern type (Regex vs Simple) - simplified here
|
||||
pattern_clean = m.pattern.replace("%", "").lower()
|
||||
if pattern_clean in req.job_title.lower():
|
||||
role_name = m.role
|
||||
break
|
||||
|
||||
# 4. Determine Vertical (Industry)
|
||||
vertical_name = company.industry_ai
|
||||
|
||||
# 5. Fetch Texts from Matrix
|
||||
texts = {"subject": None, "intro": None, "social_proof": None}
|
||||
|
||||
if vertical_name and role_name:
|
||||
industry_obj = db.query(Industry).filter(Industry.name == vertical_name).first()
|
||||
|
||||
if industry_obj:
|
||||
# Find any mapping for this role to query the Matrix
|
||||
# (Assuming Matrix is linked to *one* canonical mapping for this role string)
|
||||
role_ids = [m.id for m in db.query(JobRoleMapping).filter(JobRoleMapping.role == role_name).all()]
|
||||
|
||||
if role_ids:
|
||||
matrix_entry = db.query(MarketingMatrix).filter(
|
||||
MarketingMatrix.industry_id == industry_obj.id,
|
||||
MarketingMatrix.role_id.in_(role_ids)
|
||||
).first()
|
||||
|
||||
if matrix_entry:
|
||||
texts["subject"] = matrix_entry.subject
|
||||
texts["intro"] = matrix_entry.intro
|
||||
texts["social_proof"] = matrix_entry.social_proof
|
||||
|
||||
return ProvisioningResponse(
|
||||
status="success",
|
||||
company_name=company.name,
|
||||
website=company.website,
|
||||
vertical_name=vertical_name,
|
||||
role_name=role_name,
|
||||
texts=texts
|
||||
)
|
||||
|
||||
@app.get("/api/companies")
|
||||
def list_companies(
|
||||
skip: int = 0,
|
||||
limit: int = 50,
|
||||
skip: int = 0,
|
||||
limit: int = 50,
|
||||
search: Optional[str] = None,
|
||||
sort_by: Optional[str] = Query("name_asc"),
|
||||
db: Session = Depends(get_db),
|
||||
username: str = Depends(authenticate_user)
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
try:
|
||||
query = db.query(Company)
|
||||
@@ -272,29 +99,13 @@ def list_companies(
|
||||
query = query.order_by(Company.name.asc())
|
||||
|
||||
items = query.offset(skip).limit(limit).all()
|
||||
|
||||
# Efficiently check for pending mistakes
|
||||
company_ids = [c.id for c in items]
|
||||
if company_ids:
|
||||
pending_mistakes = db.query(ReportedMistake.company_id).filter(
|
||||
ReportedMistake.company_id.in_(company_ids),
|
||||
ReportedMistake.status == 'PENDING'
|
||||
).distinct().all()
|
||||
companies_with_pending_mistakes = {row[0] for row in pending_mistakes}
|
||||
else:
|
||||
companies_with_pending_mistakes = set()
|
||||
|
||||
# Add the flag to each company object
|
||||
for company in items:
|
||||
company.has_pending_mistakes = company.id in companies_with_pending_mistakes
|
||||
|
||||
return {"total": total, "items": items}
|
||||
except Exception as e:
|
||||
logger.error(f"List Companies Error: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@app.get("/api/companies/export")
|
||||
def export_companies_csv(db: Session = Depends(get_db), username: str = Depends(authenticate_user)):
|
||||
def export_companies_csv(db: Session = Depends(get_db)):
|
||||
"""
|
||||
Exports a CSV of all companies with their key metrics.
|
||||
"""
|
||||
@@ -336,44 +147,17 @@ def export_companies_csv(db: Session = Depends(get_db), username: str = Depends(
|
||||
)
|
||||
|
||||
@app.get("/api/companies/{company_id}")
|
||||
def get_company(company_id: int, db: Session = Depends(get_db), username: str = Depends(authenticate_user)):
|
||||
def get_company(company_id: int, db: Session = Depends(get_db)):
|
||||
company = db.query(Company).options(
|
||||
joinedload(Company.enrichment_data),
|
||||
joinedload(Company.contacts)
|
||||
).filter(Company.id == company_id).first()
|
||||
if not company:
|
||||
raise HTTPException(404, detail="Company not found")
|
||||
|
||||
# Enrich with Industry Details (Strategy)
|
||||
industry_details = None
|
||||
if company.industry_ai:
|
||||
ind = db.query(Industry).filter(Industry.name == company.industry_ai).first()
|
||||
if ind:
|
||||
industry_details = {
|
||||
"pains": ind.pains,
|
||||
"gains": ind.gains,
|
||||
"priority": ind.priority,
|
||||
"notes": ind.notes,
|
||||
"ops_focus_secondary": ind.ops_focus_secondary
|
||||
}
|
||||
|
||||
# HACK: Attach to response object (Pydantic would be cleaner, but this works for fast prototyping)
|
||||
# We convert to dict and append
|
||||
resp = company.__dict__.copy()
|
||||
resp["industry_details"] = industry_details
|
||||
# Handle SQLAlchemy internal state
|
||||
if "_sa_instance_state" in resp: del resp["_sa_instance_state"]
|
||||
# Handle relationships manually if needed, or let FastAPI encode the SQLAlchemy model + extra dict
|
||||
# Better: return a custom dict merging both
|
||||
|
||||
# Since we use joinedload, relationships are loaded.
|
||||
# Let's rely on FastAPI's ability to serialize the object, but we need to inject the extra field.
|
||||
# The safest way without changing Pydantic schemas everywhere is to return a dict.
|
||||
|
||||
return {**resp, "enrichment_data": company.enrichment_data, "contacts": company.contacts, "signals": company.signals}
|
||||
return company
|
||||
|
||||
@app.post("/api/companies")
|
||||
def create_company(company: CompanyCreate, db: Session = Depends(get_db), username: str = Depends(authenticate_user)):
|
||||
def create_company(company: CompanyCreate, db: Session = Depends(get_db)):
|
||||
db_company = db.query(Company).filter(Company.name == company.name).first()
|
||||
if db_company:
|
||||
raise HTTPException(status_code=400, detail="Company already registered")
|
||||
@@ -383,7 +167,6 @@ def create_company(company: CompanyCreate, db: Session = Depends(get_db), userna
|
||||
city=company.city,
|
||||
country=company.country,
|
||||
website=company.website,
|
||||
crm_id=company.crm_id,
|
||||
status="NEW"
|
||||
)
|
||||
db.add(new_company)
|
||||
@@ -392,7 +175,7 @@ def create_company(company: CompanyCreate, db: Session = Depends(get_db), userna
|
||||
return new_company
|
||||
|
||||
@app.post("/api/companies/bulk")
|
||||
def bulk_import_companies(req: BulkImportRequest, background_tasks: BackgroundTasks, db: Session = Depends(get_db), username: str = Depends(authenticate_user)):
|
||||
def bulk_import_companies(req: BulkImportRequest, background_tasks: BackgroundTasks, db: Session = Depends(get_db)):
|
||||
imported_count = 0
|
||||
for name in req.names:
|
||||
name = name.strip()
|
||||
@@ -410,7 +193,7 @@ def bulk_import_companies(req: BulkImportRequest, background_tasks: BackgroundTa
|
||||
return {"status": "success", "imported": imported_count}
|
||||
|
||||
@app.post("/api/companies/{company_id}/override/wikipedia")
|
||||
def override_wikipedia(company_id: int, url: str, background_tasks: BackgroundTasks, db: Session = Depends(get_db), username: str = Depends(authenticate_user)):
|
||||
def override_wikipedia(company_id: int, url: str, background_tasks: BackgroundTasks, db: Session = Depends(get_db)):
|
||||
company = db.query(Company).filter(Company.id == company_id).first()
|
||||
if not company:
|
||||
raise HTTPException(404, detail="Company not found")
|
||||
@@ -446,73 +229,26 @@ def override_wikipedia(company_id: int, url: str, background_tasks: BackgroundTa
|
||||
return {"status": "updated"}
|
||||
|
||||
@app.get("/api/robotics/categories")
|
||||
def list_robotics_categories(db: Session = Depends(get_db), username: str = Depends(authenticate_user)):
|
||||
def list_robotics_categories(db: Session = Depends(get_db)):
|
||||
return db.query(RoboticsCategory).all()
|
||||
|
||||
@app.get("/api/industries")
|
||||
def list_industries(db: Session = Depends(get_db), username: str = Depends(authenticate_user)):
|
||||
def list_industries(db: Session = Depends(get_db)):
|
||||
return db.query(Industry).all()
|
||||
|
||||
@app.get("/api/job_roles")
|
||||
def list_job_roles(db: Session = Depends(get_db), username: str = Depends(authenticate_user)):
|
||||
def list_job_roles(db: Session = Depends(get_db)):
|
||||
return db.query(JobRoleMapping).order_by(JobRoleMapping.pattern.asc()).all()
|
||||
|
||||
@app.get("/api/mistakes")
|
||||
def list_reported_mistakes(
|
||||
status: Optional[str] = Query(None),
|
||||
company_id: Optional[int] = Query(None),
|
||||
skip: int = 0,
|
||||
limit: int = 50,
|
||||
db: Session = Depends(get_db),
|
||||
username: str = Depends(authenticate_user)
|
||||
):
|
||||
query = db.query(ReportedMistake).options(joinedload(ReportedMistake.company))
|
||||
|
||||
if status:
|
||||
query = query.filter(ReportedMistake.status == status.upper())
|
||||
|
||||
if company_id:
|
||||
query = query.filter(ReportedMistake.company_id == company_id)
|
||||
|
||||
total = query.count()
|
||||
items = query.order_by(ReportedMistake.created_at.desc()).offset(skip).limit(limit).all()
|
||||
|
||||
return {"total": total, "items": items}
|
||||
|
||||
class MistakeUpdateStatusRequest(BaseModel):
|
||||
status: str # PENDING, APPROVED, REJECTED
|
||||
|
||||
@app.put("/api/mistakes/{mistake_id}")
|
||||
def update_reported_mistake_status(
|
||||
mistake_id: int,
|
||||
request: MistakeUpdateStatusRequest,
|
||||
db: Session = Depends(get_db),
|
||||
username: str = Depends(authenticate_user)
|
||||
):
|
||||
mistake = db.query(ReportedMistake).filter(ReportedMistake.id == mistake_id).first()
|
||||
if not mistake:
|
||||
raise HTTPException(404, detail="Reported mistake not found")
|
||||
|
||||
if request.status.upper() not in ["PENDING", "APPROVED", "REJECTED"]:
|
||||
raise HTTPException(400, detail="Invalid status. Must be PENDING, APPROVED, or REJECTED.")
|
||||
|
||||
mistake.status = request.status.upper()
|
||||
mistake.updated_at = datetime.utcnow()
|
||||
db.commit()
|
||||
db.refresh(mistake)
|
||||
|
||||
logger.info(f"Updated status for mistake {mistake_id} to {mistake.status}")
|
||||
return {"status": "success", "mistake": mistake}
|
||||
|
||||
@app.post("/api/enrich/discover")
|
||||
def discover_company(req: AnalysisRequest, background_tasks: BackgroundTasks, db: Session = Depends(get_db), username: str = Depends(authenticate_user)):
|
||||
def discover_company(req: AnalysisRequest, background_tasks: BackgroundTasks, db: Session = Depends(get_db)):
|
||||
company = db.query(Company).filter(Company.id == req.company_id).first()
|
||||
if not company: raise HTTPException(404, "Company not found")
|
||||
background_tasks.add_task(run_discovery_task, company.id)
|
||||
return {"status": "queued"}
|
||||
|
||||
@app.post("/api/enrich/analyze")
|
||||
def analyze_company(req: AnalysisRequest, background_tasks: BackgroundTasks, db: Session = Depends(get_db), username: str = Depends(authenticate_user)):
|
||||
def analyze_company(req: AnalysisRequest, background_tasks: BackgroundTasks, db: Session = Depends(get_db)):
|
||||
company = db.query(Company).filter(Company.id == req.company_id).first()
|
||||
if not company: raise HTTPException(404, "Company not found")
|
||||
|
||||
@@ -524,11 +260,10 @@ def analyze_company(req: AnalysisRequest, background_tasks: BackgroundTasks, db:
|
||||
|
||||
@app.put("/api/companies/{company_id}/industry")
|
||||
def update_company_industry(
|
||||
company_id: int,
|
||||
data: IndustryUpdateModel,
|
||||
company_id: int,
|
||||
data: IndustryUpdateModel,
|
||||
background_tasks: BackgroundTasks,
|
||||
db: Session = Depends(get_db),
|
||||
username: str = Depends(authenticate_user)
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
company = db.query(Company).filter(Company.id == company_id).first()
|
||||
if not company:
|
||||
@@ -546,7 +281,7 @@ def update_company_industry(
|
||||
|
||||
|
||||
@app.post("/api/companies/{company_id}/reevaluate-wikipedia")
|
||||
def reevaluate_wikipedia(company_id: int, background_tasks: BackgroundTasks, db: Session = Depends(get_db), username: str = Depends(authenticate_user)):
|
||||
def reevaluate_wikipedia(company_id: int, background_tasks: BackgroundTasks, db: Session = Depends(get_db)):
|
||||
company = db.query(Company).filter(Company.id == company_id).first()
|
||||
if not company:
|
||||
raise HTTPException(404, detail="Company not found")
|
||||
@@ -556,7 +291,7 @@ def reevaluate_wikipedia(company_id: int, background_tasks: BackgroundTasks, db:
|
||||
|
||||
|
||||
@app.delete("/api/companies/{company_id}")
|
||||
def delete_company(company_id: int, db: Session = Depends(get_db), username: str = Depends(authenticate_user)):
|
||||
def delete_company(company_id: int, db: Session = Depends(get_db)):
|
||||
company = db.query(Company).filter(Company.id == company_id).first()
|
||||
if not company:
|
||||
raise HTTPException(404, detail="Company not found")
|
||||
@@ -571,7 +306,7 @@ def delete_company(company_id: int, db: Session = Depends(get_db), username: str
|
||||
return {"status": "deleted"}
|
||||
|
||||
@app.post("/api/companies/{company_id}/override/website")
|
||||
def override_website(company_id: int, url: str, db: Session = Depends(get_db), username: str = Depends(authenticate_user)):
|
||||
def override_website(company_id: int, url: str, db: Session = Depends(get_db)):
|
||||
company = db.query(Company).filter(Company.id == company_id).first()
|
||||
if not company:
|
||||
raise HTTPException(404, detail="Company not found")
|
||||
@@ -582,116 +317,35 @@ def override_website(company_id: int, url: str, db: Session = Depends(get_db), u
|
||||
return {"status": "updated", "website": company.website}
|
||||
|
||||
@app.post("/api/companies/{company_id}/override/impressum")
|
||||
|
||||
def override_impressum(company_id: int, url: str, background_tasks: BackgroundTasks, db: Session = Depends(get_db), username: str = Depends(authenticate_user)):
|
||||
|
||||
def override_impressum(company_id: int, url: str, background_tasks: BackgroundTasks, db: Session = Depends(get_db)):
|
||||
company = db.query(Company).filter(Company.id == company_id).first()
|
||||
|
||||
if not company:
|
||||
|
||||
raise HTTPException(404, detail="Company not found")
|
||||
|
||||
|
||||
|
||||
# Create or update manual impressum lock
|
||||
|
||||
existing = db.query(EnrichmentData).filter(
|
||||
|
||||
EnrichmentData.company_id == company_id,
|
||||
|
||||
EnrichmentData.source_type == "impressum_override"
|
||||
|
||||
).first()
|
||||
|
||||
|
||||
|
||||
if not existing:
|
||||
|
||||
db.add(EnrichmentData(
|
||||
|
||||
company_id=company_id,
|
||||
|
||||
source_type="impressum_override",
|
||||
|
||||
content={"url": url},
|
||||
|
||||
is_locked=True
|
||||
|
||||
))
|
||||
|
||||
else:
|
||||
|
||||
existing.content = {"url": url}
|
||||
|
||||
existing.is_locked = True
|
||||
|
||||
|
||||
|
||||
db.commit()
|
||||
|
||||
return {"status": "updated"}
|
||||
|
||||
|
||||
|
||||
@app.post("/api/companies/{company_id}/report-mistake")
|
||||
|
||||
def report_company_mistake(
|
||||
|
||||
company_id: int,
|
||||
|
||||
request: ReportMistakeRequest,
|
||||
|
||||
db: Session = Depends(get_db),
|
||||
|
||||
username: str = Depends(authenticate_user)
|
||||
|
||||
):
|
||||
company = db.query(Company).filter(Company.id == company_id).first()
|
||||
|
||||
if not company:
|
||||
|
||||
raise HTTPException(404, detail="Company not found")
|
||||
|
||||
|
||||
|
||||
new_mistake = ReportedMistake(
|
||||
|
||||
company_id=company_id,
|
||||
|
||||
field_name=request.field_name,
|
||||
|
||||
wrong_value=request.wrong_value,
|
||||
|
||||
corrected_value=request.corrected_value,
|
||||
|
||||
source_url=request.source_url,
|
||||
|
||||
quote=request.quote,
|
||||
|
||||
user_comment=request.user_comment
|
||||
|
||||
)
|
||||
|
||||
db.add(new_mistake)
|
||||
|
||||
db.commit()
|
||||
|
||||
db.refresh(new_mistake)
|
||||
|
||||
|
||||
|
||||
logger.info(f"Reported mistake for company {company_id}: {request.field_name} -> {request.corrected_value}")
|
||||
|
||||
return {"status": "success", "mistake_id": new_mistake.id}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def run_wikipedia_reevaluation_task(company_id: int):
|
||||
|
||||
from .database import SessionLocal
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
company = db.query(Company).filter(Company.id == company_id).first()
|
||||
@@ -815,23 +469,10 @@ def run_analysis_task(company_id: int):
|
||||
# --- Serve Frontend ---
|
||||
static_path = "/frontend_static"
|
||||
if not os.path.exists(static_path):
|
||||
# Local dev fallback
|
||||
static_path = os.path.join(os.path.dirname(__file__), "../../frontend/dist")
|
||||
if not os.path.exists(static_path):
|
||||
static_path = os.path.join(os.path.dirname(__file__), "../static")
|
||||
|
||||
logger.info(f"Static files path: {static_path} (Exists: {os.path.exists(static_path)})")
|
||||
static_path = os.path.join(os.path.dirname(__file__), "../static")
|
||||
|
||||
if os.path.exists(static_path):
|
||||
@app.get("/")
|
||||
async def serve_index():
|
||||
return FileResponse(os.path.join(static_path, "index.html"))
|
||||
|
||||
app.mount("/", StaticFiles(directory=static_path, html=True), name="static")
|
||||
else:
|
||||
@app.get("/")
|
||||
def root_no_frontend():
|
||||
return {"message": "Company Explorer API is running, but frontend was not found.", "path_tried": static_path}
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
|
||||
@@ -18,35 +18,21 @@ class Company(Base):
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
|
||||
# Core Identity (Golden Record - from Research)
|
||||
# Core Identity
|
||||
name = Column(String, index=True)
|
||||
website = Column(String, index=True) # Normalized Domain preferred
|
||||
crm_id = Column(String, unique=True, index=True, nullable=True) # Link to D365
|
||||
|
||||
# CRM Original Data (Source of Truth for Import)
|
||||
crm_name = Column(String, nullable=True)
|
||||
crm_website = Column(String, nullable=True)
|
||||
crm_address = Column(String, nullable=True) # Full address string or JSON
|
||||
crm_vat = Column(String, nullable=True)
|
||||
|
||||
# Classification
|
||||
industry_crm = Column(String, nullable=True) # The "allowed" industry
|
||||
industry_ai = Column(String, nullable=True) # The AI suggested industry
|
||||
|
||||
# Location (Golden Record)
|
||||
# Location
|
||||
city = Column(String, nullable=True)
|
||||
country = Column(String, default="DE")
|
||||
|
||||
# Workflow Status
|
||||
status = Column(String, default="NEW", index=True) # NEW, TO_ENRICH, ENRICHED, QUALIFIED, DISQUALIFIED
|
||||
|
||||
# Quality & Confidence
|
||||
confidence_score = Column(Float, default=0.0) # Overall confidence
|
||||
data_mismatch_score = Column(Float, default=0.0) # 0.0=Match, 1.0=Mismatch
|
||||
|
||||
# Scraping Status Flags
|
||||
website_scrape_status = Column(String, default="PENDING") # PENDING, SUCCESS, FAILED, BLOCKED
|
||||
wiki_search_status = Column(String, default="PENDING") # PENDING, FOUND, NOT_FOUND
|
||||
status = Column(String, default="NEW", index=True)
|
||||
|
||||
# Granular Process Tracking (Timestamps)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
@@ -72,7 +58,6 @@ class Company(Base):
|
||||
# Relationships
|
||||
signals = relationship("Signal", back_populates="company", cascade="all, delete-orphan")
|
||||
enrichment_data = relationship("EnrichmentData", back_populates="company", cascade="all, delete-orphan")
|
||||
reported_mistakes = relationship("ReportedMistake", back_populates="company", cascade="all, delete-orphan")
|
||||
contacts = relationship("Contact", back_populates="company", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
@@ -93,10 +78,6 @@ class Contact(Base):
|
||||
job_title = Column(String) # Visitenkarten-Titel
|
||||
language = Column(String, default="De") # "De", "En"
|
||||
|
||||
# SuperOffice Mapping
|
||||
so_contact_id = Column(Integer, nullable=True, index=True) # SuperOffice Contact ID (Company)
|
||||
so_person_id = Column(Integer, nullable=True, unique=True, index=True) # SuperOffice Person ID
|
||||
|
||||
role = Column(String) # Operativer Entscheider, etc.
|
||||
status = Column(String, default="") # Marketing Status
|
||||
|
||||
@@ -124,13 +105,6 @@ class Industry(Base):
|
||||
status_notion = Column(String, nullable=True) # e.g. "P1 Focus Industry"
|
||||
is_focus = Column(Boolean, default=False) # Derived from status_notion
|
||||
|
||||
# Enhanced Fields (v3.1 - Pains/Gains/Priority)
|
||||
pains = Column(Text, nullable=True)
|
||||
gains = Column(Text, nullable=True)
|
||||
notes = Column(Text, nullable=True)
|
||||
priority = Column(String, nullable=True) # Replaces old status concept ("Freigegeben")
|
||||
ops_focus_secondary = Column(Boolean, default=False)
|
||||
|
||||
# NEW SCHEMA FIELDS (from MIGRATION_PLAN)
|
||||
metric_type = Column(String, nullable=True) # Unit_Count, Area_in, Area_out
|
||||
min_requirement = Column(Float, nullable=True)
|
||||
@@ -142,10 +116,6 @@ class Industry(Base):
|
||||
|
||||
# Optional link to a Robotics Category (the "product" relevant for this industry)
|
||||
primary_category_id = Column(Integer, ForeignKey("robotics_categories.id"), nullable=True)
|
||||
secondary_category_id = Column(Integer, ForeignKey("robotics_categories.id"), nullable=True)
|
||||
|
||||
primary_category = relationship("RoboticsCategory", foreign_keys=[primary_category_id])
|
||||
secondary_category = relationship("RoboticsCategory", foreign_keys=[secondary_category_id])
|
||||
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
@@ -233,49 +203,6 @@ class ImportLog(Base):
|
||||
duplicate_rows = Column(Integer)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
|
||||
class ReportedMistake(Base):
|
||||
__tablename__ = "reported_mistakes"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
company_id = Column(Integer, ForeignKey("companies.id"), index=True, nullable=False)
|
||||
field_name = Column(String, nullable=False)
|
||||
wrong_value = Column(Text, nullable=True)
|
||||
corrected_value = Column(Text, nullable=True)
|
||||
source_url = Column(String, nullable=True)
|
||||
quote = Column(Text, nullable=True)
|
||||
user_comment = Column(Text, nullable=True)
|
||||
status = Column(String, default="PENDING", nullable=False) # PENDING, APPROVED, REJECTED
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
company = relationship("Company", back_populates="reported_mistakes")
|
||||
|
||||
|
||||
class MarketingMatrix(Base):
|
||||
"""
|
||||
Stores the static marketing texts for Industry x Role combinations.
|
||||
Source: Notion (synced).
|
||||
"""
|
||||
__tablename__ = "marketing_matrix"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
|
||||
# The combination keys
|
||||
industry_id = Column(Integer, ForeignKey("industries.id"), nullable=False)
|
||||
role_id = Column(Integer, ForeignKey("job_role_mappings.id"), nullable=False)
|
||||
|
||||
# The Content
|
||||
subject = Column(Text, nullable=True)
|
||||
intro = Column(Text, nullable=True)
|
||||
social_proof = Column(Text, nullable=True)
|
||||
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
industry = relationship("Industry")
|
||||
role = relationship("JobRoleMapping")
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# UTILS
|
||||
# ==============================================================================
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
import sqlite3
|
||||
import os
|
||||
|
||||
# Correct Path for Docker Container
|
||||
DB_PATH = "/app/companies_v3_fixed_2.db"
|
||||
|
||||
def migrate():
|
||||
if not os.path.exists(DB_PATH):
|
||||
print(f"❌ Database not found at {DB_PATH}. Please ensure the volume is mounted correctly.")
|
||||
# Fallback for local testing (optional)
|
||||
if os.path.exists("companies_v3_fixed_2.db"):
|
||||
print("⚠️ Found DB in current directory, using that instead.")
|
||||
db_to_use = "companies_v3_fixed_2.db"
|
||||
else:
|
||||
return
|
||||
else:
|
||||
db_to_use = DB_PATH
|
||||
|
||||
print(f"Migrating database at {db_to_use}...")
|
||||
conn = sqlite3.connect(db_to_use)
|
||||
cursor = conn.cursor()
|
||||
|
||||
columns_to_add = [
|
||||
# Industries (Existing List)
|
||||
("industries", "pains", "TEXT"),
|
||||
("industries", "gains", "TEXT"),
|
||||
("industries", "notes", "TEXT"),
|
||||
("industries", "priority", "TEXT"),
|
||||
("industries", "ops_focus_secondary", "BOOLEAN DEFAULT 0"),
|
||||
("industries", "secondary_category_id", "INTEGER"),
|
||||
|
||||
# Companies (New List for CRM Data)
|
||||
("companies", "crm_name", "TEXT"),
|
||||
("companies", "crm_website", "TEXT"),
|
||||
("companies", "crm_address", "TEXT"),
|
||||
("companies", "crm_vat", "TEXT"),
|
||||
|
||||
# Companies (Status & Quality)
|
||||
("companies", "confidence_score", "FLOAT DEFAULT 0.0"),
|
||||
("companies", "data_mismatch_score", "FLOAT DEFAULT 0.0"),
|
||||
("companies", "website_scrape_status", "TEXT DEFAULT 'PENDING'"),
|
||||
("companies", "wiki_search_status", "TEXT DEFAULT 'PENDING'"),
|
||||
]
|
||||
|
||||
for table, col_name, col_type in columns_to_add:
|
||||
try:
|
||||
# Check if column exists first to avoid error log spam
|
||||
cursor.execute(f"PRAGMA table_info({table})")
|
||||
existing_cols = [row[1] for row in cursor.fetchall()]
|
||||
|
||||
if col_name in existing_cols:
|
||||
print(f" - Column '{col_name}' already exists in '{table}'.")
|
||||
else:
|
||||
print(f" + Adding column '{col_name}' to '{table}'...")
|
||||
cursor.execute(f"ALTER TABLE {table} ADD COLUMN {col_name} {col_type}")
|
||||
|
||||
except sqlite3.OperationalError as e:
|
||||
print(f"❌ Error adding '{col_name}' to '{table}': {e}")
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
print("✅ Migration complete.")
|
||||
|
||||
if __name__ == "__main__":
|
||||
migrate()
|
||||
@@ -69,26 +69,6 @@ def migrate_tables():
|
||||
logger.info(f"Adding column '{col}' to 'companies' table...")
|
||||
cursor.execute(f"ALTER TABLE companies ADD COLUMN {col} {col_type}")
|
||||
|
||||
# 3. Create REPORTED_MISTAKES Table
|
||||
logger.info("Checking 'reported_mistakes' table schema...")
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS reported_mistakes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
company_id INTEGER NOT NULL,
|
||||
field_name TEXT NOT NULL,
|
||||
wrong_value TEXT,
|
||||
corrected_value TEXT,
|
||||
source_url TEXT,
|
||||
quote TEXT,
|
||||
user_comment TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'PENDING',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (company_id) REFERENCES companies (id)
|
||||
)
|
||||
""")
|
||||
logger.info("Table 'reported_mistakes' ensured to exist.")
|
||||
|
||||
conn.commit()
|
||||
logger.info("All migrations completed successfully.")
|
||||
|
||||
|
||||
@@ -1,185 +0,0 @@
|
||||
import sys
|
||||
import os
|
||||
import requests
|
||||
import logging
|
||||
|
||||
# Setup Paths - Relative to script location in container
|
||||
# /app/backend/scripts/sync.py -> /app
|
||||
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "../..")))
|
||||
|
||||
from backend.database import SessionLocal, Industry, RoboticsCategory, init_db
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Try loading from .env in root if exists
|
||||
load_dotenv(dotenv_path="/app/.env")
|
||||
|
||||
# Logging
|
||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
NOTION_TOKEN = os.getenv("NOTION_API_KEY")
|
||||
if not NOTION_TOKEN:
|
||||
# Fallback to file if env missing (legacy way)
|
||||
try:
|
||||
with open("/app/notion_token.txt", "r") as f:
|
||||
NOTION_TOKEN = f.read().strip()
|
||||
except:
|
||||
logger.error("NOTION_API_KEY missing in ENV and file!")
|
||||
sys.exit(1)
|
||||
|
||||
HEADERS = {
|
||||
"Authorization": f"Bearer {NOTION_TOKEN}",
|
||||
"Notion-Version": "2022-06-28",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
def find_db_id(query_name):
|
||||
url = "https://api.notion.com/v1/search"
|
||||
payload = {"query": query_name, "filter": {"value": "database", "property": "object"}}
|
||||
resp = requests.post(url, headers=HEADERS, json=payload)
|
||||
if resp.status_code == 200:
|
||||
results = resp.json().get("results", [])
|
||||
if results:
|
||||
return results[0]['id']
|
||||
return None
|
||||
|
||||
def query_all(db_id):
|
||||
url = f"https://api.notion.com/v1/databases/{db_id}/query"
|
||||
results = []
|
||||
has_more = True
|
||||
next_cursor = None
|
||||
|
||||
while has_more:
|
||||
payload = {}
|
||||
if next_cursor: payload["start_cursor"] = next_cursor
|
||||
|
||||
resp = requests.post(url, headers=HEADERS, json=payload)
|
||||
data = resp.json()
|
||||
results.extend(data.get("results", []))
|
||||
has_more = data.get("has_more", False)
|
||||
next_cursor = data.get("next_cursor")
|
||||
return results
|
||||
|
||||
def extract_rich_text(prop):
|
||||
if not prop or "rich_text" not in prop: return ""
|
||||
return "".join([t.get("plain_text", "") for t in prop.get("rich_text", [])])
|
||||
|
||||
def extract_title(prop):
|
||||
if not prop or "title" not in prop: return ""
|
||||
return "".join([t.get("plain_text", "") for t in prop.get("title", [])])
|
||||
|
||||
def extract_select(prop):
|
||||
if not prop or "select" not in prop or not prop["select"]: return ""
|
||||
return prop["select"]["name"]
|
||||
|
||||
def extract_number(prop):
|
||||
if not prop or "number" not in prop: return None
|
||||
return prop["number"]
|
||||
|
||||
def sync():
|
||||
logger.info("--- Starting Enhanced Sync ---")
|
||||
|
||||
# 1. Init DB (ensure tables exist)
|
||||
init_db()
|
||||
session = SessionLocal()
|
||||
|
||||
# 2. Sync Categories (Products)
|
||||
cat_db_id = find_db_id("Product Categories") or find_db_id("Products")
|
||||
if cat_db_id:
|
||||
logger.info(f"Syncing Products from {cat_db_id}...")
|
||||
pages = query_all(cat_db_id)
|
||||
for page in pages:
|
||||
props = page["properties"]
|
||||
name = extract_title(props.get("Name") or props.get("Product Name"))
|
||||
if not name: continue
|
||||
|
||||
notion_id = page["id"]
|
||||
key = name.lower().replace(" ", "_")
|
||||
|
||||
# Upsert
|
||||
cat = session.query(RoboticsCategory).filter(RoboticsCategory.notion_id == notion_id).first()
|
||||
if not cat:
|
||||
cat = RoboticsCategory(notion_id=notion_id, key=key)
|
||||
session.add(cat)
|
||||
|
||||
cat.name = name
|
||||
cat.description = extract_rich_text(props.get("Description"))
|
||||
|
||||
session.commit()
|
||||
else:
|
||||
logger.warning("Product DB not found!")
|
||||
|
||||
# 3. Sync Industries
|
||||
ind_db_id = find_db_id("Industries")
|
||||
if ind_db_id:
|
||||
logger.info(f"Syncing Industries from {ind_db_id}...")
|
||||
|
||||
# Clear existing
|
||||
session.query(Industry).delete()
|
||||
session.commit()
|
||||
|
||||
pages = query_all(ind_db_id)
|
||||
count = 0
|
||||
|
||||
for page in pages:
|
||||
props = page["properties"]
|
||||
name = extract_title(props.get("Vertical"))
|
||||
if not name: continue
|
||||
|
||||
ind = Industry(notion_id=page["id"], name=name)
|
||||
session.add(ind)
|
||||
|
||||
# Map Fields
|
||||
ind.description = extract_rich_text(props.get("Definition"))
|
||||
ind.notes = extract_rich_text(props.get("Notes"))
|
||||
ind.pains = extract_rich_text(props.get("Pains"))
|
||||
ind.gains = extract_rich_text(props.get("Gains"))
|
||||
|
||||
# Metrics & Scraper Config (NEW)
|
||||
ind.metric_type = extract_select(props.get("Metric Type"))
|
||||
ind.min_requirement = extract_number(props.get("Min. Requirement"))
|
||||
ind.whale_threshold = extract_number(props.get("Whale Threshold"))
|
||||
ind.proxy_factor = extract_number(props.get("Proxy Factor"))
|
||||
|
||||
ind.scraper_search_term = extract_rich_text(props.get("Scraper Search Term"))
|
||||
ind.scraper_keywords = extract_rich_text(props.get("Scraper Keywords"))
|
||||
ind.standardization_logic = extract_rich_text(props.get("Standardization Logic"))
|
||||
|
||||
# Status / Priority
|
||||
prio = extract_select(props.get("Priorität"))
|
||||
if not prio: prio = extract_select(props.get("Freigegeben"))
|
||||
|
||||
ind.priority = prio
|
||||
ind.status_notion = prio # Legacy
|
||||
ind.is_focus = (prio == "Freigegeben")
|
||||
|
||||
# Ops Focus
|
||||
if "Ops Focus: Secondary" in props:
|
||||
ind.ops_focus_secondary = props["Ops Focus: Secondary"].get("checkbox", False)
|
||||
|
||||
# Relations
|
||||
# Primary
|
||||
rels_prim = props.get("Primary Product Category", {}).get("relation", [])
|
||||
if rels_prim:
|
||||
pid = rels_prim[0]["id"]
|
||||
cat = session.query(RoboticsCategory).filter(RoboticsCategory.notion_id == pid).first()
|
||||
if cat: ind.primary_category_id = cat.id
|
||||
|
||||
# Secondary
|
||||
rels_sec = props.get("Secondary Product", {}).get("relation", [])
|
||||
if rels_sec:
|
||||
pid = rels_sec[0]["id"]
|
||||
cat = session.query(RoboticsCategory).filter(RoboticsCategory.notion_id == pid).first()
|
||||
if cat: ind.secondary_category_id = cat.id
|
||||
|
||||
count += 1
|
||||
|
||||
session.commit()
|
||||
logger.info(f"✅ Synced {count} industries.")
|
||||
else:
|
||||
logger.error("Industries DB not found!")
|
||||
|
||||
session.close()
|
||||
|
||||
if __name__ == "__main__":
|
||||
sync()
|
||||
@@ -22,7 +22,10 @@
|
||||
"@types/react": "^18.2.43",
|
||||
"@types/react-dom": "^18.2.17",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"postcss": "^8.4.32",
|
||||
"tailwindcss": "^3.3.6",
|
||||
"typescript": "^5.3.3",
|
||||
"vite": "^5.0.10"
|
||||
"vite": "^5.0.8"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useState, useEffect } from 'react'
|
||||
import axios from 'axios'
|
||||
import {
|
||||
Building, Search, Upload, Globe, MapPin, Play, Search as SearchIcon, Loader2,
|
||||
LayoutGrid, List, ChevronLeft, ChevronRight, ArrowDownUp, Flag
|
||||
LayoutGrid, List, ChevronLeft, ChevronRight, ArrowDownUp
|
||||
} from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
@@ -16,7 +16,6 @@ interface Company {
|
||||
industry_ai: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
has_pending_mistakes: boolean
|
||||
}
|
||||
|
||||
interface CompanyTableProps {
|
||||
@@ -125,10 +124,7 @@ export function CompanyTable({ apiBase, onRowClick, refreshKey, onImportClick }:
|
||||
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="flex items-center gap-2">
|
||||
<Flag className={clsx("h-3 w-3 text-slate-300 dark:text-slate-600", c.has_pending_mistakes && "text-red-500 fill-red-500")} />
|
||||
<div className="font-bold text-slate-900 dark:text-white text-sm truncate" title={c.name}>{c.name}</div>
|
||||
</div>
|
||||
<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>
|
||||
@@ -167,12 +163,7 @@ export function CompanyTable({ apiBase, onRowClick, refreshKey, onImportClick }:
|
||||
<tbody className="divide-y divide-slate-200 dark:divide-slate-800 bg-white dark:bg-slate-900">
|
||||
{data.map((c) => (
|
||||
<tr key={c.id} onClick={() => onRowClick(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">
|
||||
<div className="flex items-center gap-2">
|
||||
<Flag className={clsx("h-3 w-3 text-slate-300 dark:text-slate-600", c.has_pending_mistakes && "text-red-500 fill-red-500")} />
|
||||
<span>{c.name}</span>
|
||||
</div>
|
||||
</td>
|
||||
<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>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import axios from 'axios'
|
||||
import { X, ExternalLink, Bot, Briefcase, Globe, Users, DollarSign, MapPin, Tag, RefreshCw as RefreshCwIcon, Search as SearchIcon, Pencil, Check, Download, Clock, Lock, Unlock, Calculator, Ruler, Database, Trash2, Flag, AlertTriangle, Scale, Target } 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, Calculator, Ruler, Database, Trash2 } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
import { ContactsManager, Contact } from './ContactsManager'
|
||||
|
||||
interface InspectorProps {
|
||||
companyId: number | null
|
||||
initialContactId?: number | null
|
||||
initialContactId?: number | null // NEW
|
||||
onClose: () => void
|
||||
apiBase: string
|
||||
}
|
||||
@@ -25,14 +25,6 @@ type EnrichmentData = {
|
||||
created_at?: string
|
||||
}
|
||||
|
||||
type IndustryDetails = {
|
||||
pains: string | null
|
||||
gains: string | null
|
||||
priority: string | null
|
||||
notes: string | null
|
||||
ops_focus_secondary: boolean
|
||||
}
|
||||
|
||||
type CompanyDetail = {
|
||||
id: number
|
||||
name: string
|
||||
@@ -43,20 +35,6 @@ type CompanyDetail = {
|
||||
signals: Signal[]
|
||||
enrichment_data: EnrichmentData[]
|
||||
contacts?: Contact[]
|
||||
|
||||
// CRM Data (V2)
|
||||
crm_name: string | null
|
||||
crm_website: string | null
|
||||
crm_address: string | null
|
||||
crm_vat: string | null
|
||||
|
||||
// Quality (V2)
|
||||
confidence_score: number | null
|
||||
data_mismatch_score: number | null
|
||||
|
||||
// Industry Strategy (V2)
|
||||
industry_details?: IndustryDetails
|
||||
|
||||
// NEU v0.7.0: Quantitative Metrics
|
||||
calculated_metric_name: string | null
|
||||
calculated_metric_value: number | null
|
||||
@@ -70,34 +48,12 @@ type CompanyDetail = {
|
||||
metric_confidence_reason: string | null
|
||||
}
|
||||
|
||||
type ReportedMistake = {
|
||||
id: number;
|
||||
field_name: string;
|
||||
wrong_value: string | null;
|
||||
corrected_value: string | null;
|
||||
source_url: string | null;
|
||||
quote: string | null;
|
||||
user_comment: string | null;
|
||||
status: 'PENDING' | 'APPROVED' | 'REJECTED';
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
|
||||
export function Inspector({ companyId, initialContactId, onClose, apiBase }: InspectorProps) {
|
||||
const [data, setData] = useState<CompanyDetail | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [isProcessing, setIsProcessing] = useState(false)
|
||||
const [activeTab, setActiveTab] = useState<'overview' | 'contacts'>('overview')
|
||||
|
||||
const [isReportingMistake, setIsReportingMistake] = useState(false)
|
||||
const [existingMistakes, setExistingMistakes] = useState<ReportedMistake[]>([])
|
||||
const [reportedFieldName, setReportedFieldName] = useState("")
|
||||
const [reportedWrongValue, setReportedWrongValue] = useState("")
|
||||
const [reportedCorrectedValue, setReportedCorrectedValue] = useState("")
|
||||
const [reportedSourceUrl, setReportedSourceUrl] = useState("")
|
||||
const [reportedQuote, setReportedQuote] = useState("")
|
||||
const [reportedComment, setReportedComment] = useState("")
|
||||
|
||||
// Polling Logic
|
||||
useEffect(() => {
|
||||
let interval: NodeJS.Timeout;
|
||||
@@ -107,8 +63,9 @@ export function Inspector({ companyId, initialContactId, onClose, apiBase }: Ins
|
||||
}, 2000)
|
||||
}
|
||||
return () => clearInterval(interval)
|
||||
}, [isProcessing, companyId])
|
||||
}, [isProcessing, companyId]) // Dependencies
|
||||
|
||||
// Auto-switch to contacts tab if initialContactId is present
|
||||
useEffect(() => {
|
||||
if (initialContactId) {
|
||||
setActiveTab('contacts')
|
||||
@@ -117,6 +74,7 @@ export function Inspector({ companyId, initialContactId, onClose, apiBase }: Ins
|
||||
}
|
||||
}, [initialContactId, companyId])
|
||||
|
||||
// Manual Override State
|
||||
const [isEditingWiki, setIsEditingWiki] = useState(false)
|
||||
const [wikiUrlInput, setWikiUrlInput] = useState("")
|
||||
const [isEditingWebsite, setIsEditingWebsite] = useState(false)
|
||||
@@ -124,6 +82,7 @@ export function Inspector({ companyId, initialContactId, onClose, apiBase }: Ins
|
||||
const [isEditingImpressum, setIsEditingImpressum] = useState(false)
|
||||
const [impressumUrlInput, setImpressumUrlInput] = useState("")
|
||||
|
||||
// NEU: Industry Override
|
||||
const [industries, setIndustries] = useState<any[]>([])
|
||||
const [isEditingIndustry, setIsEditingIndustry] = useState(false)
|
||||
const [industryInput, setIndustryInput] = useState("")
|
||||
@@ -132,19 +91,18 @@ export function Inspector({ companyId, initialContactId, onClose, apiBase }: Ins
|
||||
if (!companyId) return
|
||||
if (!silent) setLoading(true)
|
||||
|
||||
const companyRequest = axios.get(`${apiBase}/companies/${companyId}`)
|
||||
const mistakesRequest = axios.get(`${apiBase}/mistakes?company_id=${companyId}`)
|
||||
|
||||
Promise.all([companyRequest, mistakesRequest])
|
||||
.then(([companyRes, mistakesRes]) => {
|
||||
const newData = companyRes.data
|
||||
axios.get(`${apiBase}/companies/${companyId}`)
|
||||
.then(res => {
|
||||
const newData = res.data
|
||||
console.log("FETCHED COMPANY DATA:", newData) // DEBUG: Log raw data from API
|
||||
setData(newData)
|
||||
setExistingMistakes(mistakesRes.data.items)
|
||||
|
||||
// Auto-stop processing if status changes to ENRICHED or we see data
|
||||
if (isProcessing) {
|
||||
const hasWiki = newData.enrichment_data?.some((e: any) => e.source_type === 'wikipedia')
|
||||
const hasAnalysis = newData.enrichment_data?.some((e: any) => e.source_type === 'ai_analysis')
|
||||
|
||||
// If we were waiting for Discover (Wiki) or Analyze (AI)
|
||||
if ((hasWiki && newData.status === 'DISCOVERED') || (hasAnalysis && newData.status === 'ENRICHED')) {
|
||||
setIsProcessing(false)
|
||||
}
|
||||
@@ -160,8 +118,9 @@ export function Inspector({ companyId, initialContactId, onClose, apiBase }: Ins
|
||||
setIsEditingWebsite(false)
|
||||
setIsEditingImpressum(false)
|
||||
setIsEditingIndustry(false)
|
||||
setIsProcessing(false)
|
||||
setIsProcessing(false) // Reset on ID change
|
||||
|
||||
// Load industries for dropdown
|
||||
axios.get(`${apiBase}/industries`)
|
||||
.then(res => setIndustries(res.data))
|
||||
.catch(console.error)
|
||||
@@ -172,6 +131,7 @@ export function Inspector({ companyId, initialContactId, onClose, apiBase }: Ins
|
||||
setIsProcessing(true)
|
||||
try {
|
||||
await axios.post(`${apiBase}/enrich/discover`, { company_id: companyId })
|
||||
// Polling effect will handle the rest
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
setIsProcessing(false)
|
||||
@@ -183,6 +143,7 @@ export function Inspector({ companyId, initialContactId, onClose, apiBase }: Ins
|
||||
setIsProcessing(true)
|
||||
try {
|
||||
await axios.post(`${apiBase}/enrich/analyze`, { company_id: companyId })
|
||||
// Polling effect will handle the rest
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
setIsProcessing(false)
|
||||
@@ -191,6 +152,8 @@ export function Inspector({ companyId, initialContactId, onClose, apiBase }: Ins
|
||||
|
||||
const handleExport = () => {
|
||||
if (!data) return;
|
||||
|
||||
// Prepare full export object
|
||||
const exportData = {
|
||||
metadata: {
|
||||
id: data.id,
|
||||
@@ -296,22 +259,30 @@ export function Inspector({ companyId, initialContactId, onClose, apiBase }: Ins
|
||||
setIsProcessing(true)
|
||||
try {
|
||||
await axios.post(`${apiBase}/companies/${companyId}/reevaluate-wikipedia`)
|
||||
// Polling effect will handle the rest
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
setIsProcessing(false)
|
||||
setIsProcessing(false) // Stop on direct error
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
console.log("[Inspector] Delete requested for ID:", companyId)
|
||||
if (!companyId) return;
|
||||
|
||||
if (!window.confirm(`Are you sure you want to delete "${data?.name}"? This action cannot be undone.`)) {
|
||||
console.log("[Inspector] Delete cancelled by user")
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
console.log("[Inspector] Sending DELETE request...")
|
||||
await axios.delete(`${apiBase}/companies/${companyId}`)
|
||||
onClose()
|
||||
window.location.reload()
|
||||
console.log("[Inspector] Delete successful")
|
||||
onClose() // Close the inspector on success
|
||||
window.location.reload() // Force reload to show updated list
|
||||
} catch (e: any) {
|
||||
console.error("[Inspector] Delete failed:", e)
|
||||
alert("Failed to delete company: " + (e.response?.data?.detail || e.message))
|
||||
}
|
||||
}
|
||||
@@ -320,57 +291,12 @@ export function Inspector({ companyId, initialContactId, onClose, apiBase }: Ins
|
||||
if (!companyId) return
|
||||
try {
|
||||
await axios.post(`${apiBase}/enrichment/${companyId}/${sourceType}/lock?locked=${!currentLockStatus}`)
|
||||
fetchData(true)
|
||||
fetchData(true) // Silent refresh
|
||||
} catch (e) {
|
||||
console.error("Lock toggle failed", e)
|
||||
}
|
||||
}
|
||||
|
||||
interface ReportedMistakeRequest {
|
||||
field_name: string;
|
||||
wrong_value?: string | null;
|
||||
corrected_value?: string | null;
|
||||
source_url?: string | null;
|
||||
quote?: string | null;
|
||||
user_comment?: string | null;
|
||||
}
|
||||
|
||||
const handleReportMistake = async () => {
|
||||
if (!companyId) return;
|
||||
if (!reportedFieldName) {
|
||||
alert("Field Name is required.");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsProcessing(true);
|
||||
try {
|
||||
const payload: ReportedMistakeRequest = {
|
||||
field_name: reportedFieldName,
|
||||
wrong_value: reportedWrongValue || null,
|
||||
corrected_value: reportedCorrectedValue || null,
|
||||
source_url: reportedSourceUrl || null,
|
||||
quote: reportedQuote || null,
|
||||
user_comment: reportedComment || null,
|
||||
};
|
||||
|
||||
await axios.post(`${apiBase}/companies/${companyId}/report-mistake`, payload);
|
||||
alert("Mistake reported successfully!");
|
||||
setIsReportingMistake(false);
|
||||
setReportedFieldName("");
|
||||
setReportedWrongValue("");
|
||||
setReportedCorrectedValue("");
|
||||
setReportedSourceUrl("");
|
||||
setReportedQuote("");
|
||||
setReportedComment("");
|
||||
fetchData(true);
|
||||
} catch (e) {
|
||||
alert("Failed to report mistake.");
|
||||
console.error(e);
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddContact = async (contact: Contact) => {
|
||||
if (!companyId) return
|
||||
try {
|
||||
@@ -409,131 +335,8 @@ export function Inspector({ companyId, initialContactId, onClose, apiBase }: Ins
|
||||
const impressum = scrapeData?.impressum
|
||||
const scrapeDate = scrapeEntry?.created_at
|
||||
|
||||
// Strategy Card Renderer
|
||||
const renderStrategyCard = () => {
|
||||
if (!data?.industry_details) return null;
|
||||
const { pains, gains, priority, notes } = data.industry_details;
|
||||
|
||||
return (
|
||||
<div className="bg-purple-50 dark:bg-purple-900/10 rounded-xl p-5 border border-purple-100 dark:border-purple-900/50 mb-6">
|
||||
<h3 className="text-sm font-semibold text-purple-700 dark:text-purple-300 uppercase tracking-wider mb-3 flex items-center gap-2">
|
||||
<Target className="h-4 w-4" /> Strategic Fit (Notion)
|
||||
</h3>
|
||||
|
||||
<div className="grid gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-slate-500 font-bold uppercase">Status:</span>
|
||||
<span className={clsx("px-2 py-0.5 rounded text-xs font-bold",
|
||||
priority === "Freigegeben" ? "bg-green-100 text-green-700" : "bg-yellow-100 text-yellow-700"
|
||||
)}>{priority || "N/A"}</span>
|
||||
</div>
|
||||
|
||||
{pains && (
|
||||
<div>
|
||||
<div className="text-[10px] text-red-600 dark:text-red-400 uppercase font-bold tracking-tight mb-1">Pain Points</div>
|
||||
<div className="text-sm text-slate-700 dark:text-slate-300 whitespace-pre-line">{pains}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{gains && (
|
||||
<div>
|
||||
<div className="text-[10px] text-green-600 dark:text-green-400 uppercase font-bold tracking-tight mb-1">Gain Points</div>
|
||||
<div className="text-sm text-slate-700 dark:text-slate-300 whitespace-pre-line">{gains}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{notes && (
|
||||
<div className="pt-2 border-t border-purple-200 dark:border-purple-800">
|
||||
<div className="text-[10px] text-purple-500 uppercase font-bold tracking-tight">Internal Notes</div>
|
||||
<div className="text-xs text-slate-500 italic">{notes}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// CRM Comparison and Data Quality Renderer
|
||||
const renderDataQualityCard = () => {
|
||||
if (!data) return null;
|
||||
const hasCrmData = data.crm_name || data.crm_website;
|
||||
const hasQualityScores = data.confidence_score != null || data.data_mismatch_score != null;
|
||||
|
||||
if (!hasCrmData && !hasQualityScores) return null;
|
||||
|
||||
const confidenceScore = data.confidence_score ?? 0;
|
||||
const mismatchScore = data.data_mismatch_score ?? 0;
|
||||
|
||||
const getConfidenceColor = (score: number) => {
|
||||
if (score > 0.8) return { bg: "bg-green-100", text: "text-green-700" };
|
||||
if (score > 0.5) return { bg: "bg-yellow-100", text: "text-yellow-700" };
|
||||
return { bg: "bg-red-100", text: "text-red-700" };
|
||||
}
|
||||
|
||||
const getMismatchColor = (score: number) => {
|
||||
if (score <= 0.3) return { bg: "bg-green-100", text: "text-green-700" };
|
||||
if (score <= 0.5) return { bg: "bg-yellow-100", text: "text-yellow-700" };
|
||||
return { bg: "bg-red-100", text: "text-red-700" };
|
||||
}
|
||||
|
||||
const confidenceColors = getConfidenceColor(confidenceScore);
|
||||
const mismatchColors = getMismatchColor(mismatchScore);
|
||||
|
||||
return (
|
||||
<div className="bg-slate-50 dark:bg-slate-950 rounded-lg p-4 border border-slate-200 dark:border-slate-800 mb-6">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="text-sm font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wider flex items-center gap-2">
|
||||
<Scale className="h-4 w-4" /> Data Quality & CRM Sync
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-xs mb-4">
|
||||
{/* AI Quality Metrics */}
|
||||
<div className="space-y-3 p-3 bg-white dark:bg-slate-900 rounded border border-slate-200 dark:border-slate-800">
|
||||
<div className="text-[10px] font-bold text-slate-400 uppercase">AI Quality Scores</div>
|
||||
{data.confidence_score != null && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-slate-500 dark:text-slate-400">Classification Confidence</span>
|
||||
<span className={clsx("font-bold px-2 py-0.5 rounded text-xs", confidenceColors.bg, confidenceColors.text)}>
|
||||
{(confidenceScore * 100).toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{data.data_mismatch_score != null && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-slate-500 dark:text-slate-400">CRM Data Match</span>
|
||||
<span className={clsx("font-bold px-2 py-0.5 rounded text-xs", mismatchColors.bg, mismatchColors.text)}>
|
||||
{((1 - mismatchScore) * 100).toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* CRM Data Display */}
|
||||
<div className="p-3 bg-slate-100 dark:bg-slate-800/50 rounded">
|
||||
<div className="text-[10px] font-bold text-slate-400 uppercase mb-2">SuperOffice (CRM)</div>
|
||||
<div className="space-y-2">
|
||||
<div><span className="text-slate-400">Name:</span> <span className="font-medium break-all">{data.crm_name || "-"}</span></div>
|
||||
<div><span className="text-slate-400">Web:</span> <span className="font-mono break-all">{data.crm_website || "-"}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-xs">
|
||||
<div className="p-3 bg-white dark:bg-slate-900 rounded border border-blue-100 dark:border-blue-900/50">
|
||||
<div className="text-[10px] font-bold text-blue-500 uppercase mb-2">Enriched Data (AI)</div>
|
||||
<div className="space-y-2">
|
||||
<div><span className="text-slate-400">Name:</span> <span className="font-medium text-slate-900 dark:text-white">{data.name}</span></div>
|
||||
<div><span className="text-slate-400">Web:</span> <span className="font-mono text-blue-600 dark:text-blue-400">{data.website}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-y-0 right-0 w-full md:w-[600px] bg-white dark:bg-slate-900 border-l border-slate-200 dark:border-slate-800 shadow-2xl transform transition-transform duration-300 ease-in-out z-50 overflow-y-auto">
|
||||
<div className="fixed inset-y-0 right-0 w-full md:w-[550px] bg-white dark:bg-slate-900 border-l border-slate-200 dark:border-slate-800 shadow-2xl transform transition-transform duration-300 ease-in-out z-50 overflow-y-auto">
|
||||
{loading ? (
|
||||
<div className="p-8 text-slate-500">Loading details...</div>
|
||||
) : !data ? (
|
||||
@@ -559,13 +362,6 @@ export function Inspector({ companyId, initialContactId, onClose, apiBase }: Ins
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setIsReportingMistake(true)}
|
||||
className="p-1.5 text-slate-500 hover:text-orange-600 dark:hover:text-orange-500 transition-colors"
|
||||
title="Report a Mistake"
|
||||
>
|
||||
<Flag className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => fetchData(true)}
|
||||
className="p-1.5 text-slate-500 hover:text-slate-900 dark:hover:text-white transition-colors"
|
||||
@@ -628,76 +424,7 @@ export function Inspector({ companyId, initialContactId, onClose, apiBase }: Ins
|
||||
)}
|
||||
</div>
|
||||
|
||||
{existingMistakes.length > 0 && (
|
||||
<div className="mt-4 p-4 bg-orange-50 dark:bg-orange-900/20 border border-orange-200 dark:border-orange-800/50 rounded-lg">
|
||||
<h4 className="flex items-center gap-2 text-sm font-bold text-orange-800 dark:text-orange-300 mb-3">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
Existing Correction Proposals
|
||||
</h4>
|
||||
<div className="space-y-3 max-h-40 overflow-y-auto pr-2">
|
||||
{existingMistakes.map(mistake => (
|
||||
<div key={mistake.id} className="text-xs p-3 bg-white dark:bg-slate-800/50 rounded border border-slate-200 dark:border-slate-700/50">
|
||||
<div className="flex justify-between items-start">
|
||||
<span className="font-bold text-slate-800 dark:text-slate-200">{mistake.field_name}</span>
|
||||
<span className={clsx("px-2 py-0.5 rounded-full text-[9px] font-medium", {
|
||||
'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/50 dark:text-yellow-300': mistake.status === 'PENDING',
|
||||
'bg-green-100 text-green-800 dark:bg-green-900/50 dark:text-green-300': mistake.status === 'APPROVED',
|
||||
'bg-red-100 text-red-800 dark:bg-red-900/50 dark:text-red-300': mistake.status === 'REJECTED'
|
||||
})}>
|
||||
{mistake.status}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-slate-600 dark:text-slate-400 mt-1">
|
||||
<span className="line-through text-red-500/80">{mistake.wrong_value || 'N/A'}</span> → <strong className="text-green-600 dark:text-green-400">{mistake.corrected_value || 'N/A'}</strong>
|
||||
</p>
|
||||
{mistake.user_comment && <p className="mt-2 text-slate-500 italic">"{mistake.user_comment}"</p>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mistake Report Form (Missing in previous version) */}
|
||||
{isReportingMistake && (
|
||||
<div className="mt-4 p-4 bg-slate-100 dark:bg-slate-800 rounded border border-slate-200 dark:border-slate-700 animate-in slide-in-from-top-2">
|
||||
<h4 className="text-sm font-bold mb-3 flex justify-between items-center">
|
||||
Report a Data Error
|
||||
<button onClick={() => setIsReportingMistake(false)} className="text-slate-400 hover:text-red-500"><X className="h-4 w-4"/></button>
|
||||
</h4>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-[10px] uppercase font-bold text-slate-500 mb-1">Field Name (Required)</label>
|
||||
<input className="w-full text-xs p-2 rounded border dark:bg-slate-900 dark:border-slate-700" value={reportedFieldName} onChange={e => setReportedFieldName(e.target.value)} placeholder="e.g. Revenue, Employee Count" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label className="block text-[10px] uppercase font-bold text-slate-500 mb-1">Wrong Value</label>
|
||||
<input className="w-full text-xs p-2 rounded border dark:bg-slate-900 dark:border-slate-700" value={reportedWrongValue} onChange={e => setReportedWrongValue(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[10px] uppercase font-bold text-slate-500 mb-1">Correct Value</label>
|
||||
<input className="w-full text-xs p-2 rounded border dark:bg-slate-900 dark:border-slate-700" value={reportedCorrectedValue} onChange={e => setReportedCorrectedValue(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[10px] uppercase font-bold text-slate-500 mb-1">Source URL / Proof</label>
|
||||
<input className="w-full text-xs p-2 rounded border dark:bg-slate-900 dark:border-slate-700" value={reportedSourceUrl} onChange={e => setReportedSourceUrl(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[10px] uppercase font-bold text-slate-500 mb-1">Comment</label>
|
||||
<textarea className="w-full text-xs p-2 rounded border dark:bg-slate-900 dark:border-slate-700" rows={2} value={reportedComment} onChange={e => setReportedComment(e.target.value)} />
|
||||
</div>
|
||||
<button
|
||||
onClick={handleReportMistake}
|
||||
disabled={isProcessing}
|
||||
className="w-full bg-orange-600 hover:bg-orange-700 text-white py-2 rounded text-xs font-bold"
|
||||
>
|
||||
SUBMIT REPORT
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tab Navigation */}
|
||||
<div className="mt-6 flex border-b border-slate-200 dark:border-slate-800">
|
||||
<button
|
||||
onClick={() => setActiveTab('overview')}
|
||||
@@ -733,6 +460,7 @@ export function Inspector({ companyId, initialContactId, onClose, apiBase }: Ins
|
||||
|
||||
{activeTab === 'overview' && (
|
||||
<>
|
||||
{/* Action Bar (Only for Overview) */}
|
||||
<div className="flex gap-2 mb-6">
|
||||
<button
|
||||
onClick={handleDiscover}
|
||||
@@ -752,9 +480,7 @@ export function Inspector({ companyId, initialContactId, onClose, apiBase }: Ins
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{renderDataQualityCard()}
|
||||
{renderStrategyCard()}
|
||||
|
||||
{/* Impressum / Legal Data */}
|
||||
<div className="bg-slate-50 dark:bg-slate-950 rounded-lg p-4 border border-slate-200 dark:border-slate-800 flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -770,6 +496,7 @@ export function Inspector({ companyId, initialContactId, onClose, apiBase }: Ins
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Lock Button for Impressum */}
|
||||
{scrapeEntry && (
|
||||
<button
|
||||
onClick={() => handleLockToggle('website_scrape', scrapeEntry.is_locked || false)}
|
||||
@@ -779,7 +506,7 @@ export function Inspector({ companyId, initialContactId, onClose, apiBase }: Ins
|
||||
? "text-green-600 dark:text-green-400 hover:text-green-700"
|
||||
: "text-slate-400 hover:text-slate-900 dark:hover:text-white"
|
||||
)}
|
||||
title={scrapeEntry.is_locked ? "Data Locked" : "Unlocked"}
|
||||
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>
|
||||
@@ -853,6 +580,9 @@ export function Inspector({ companyId, initialContactId, onClose, apiBase }: Ins
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
{/* Core Classification */}
|
||||
<div className="bg-blue-50/50 dark:bg-blue-900/10 rounded-xl p-5 border border-blue-100 dark:border-blue-900/50 mb-6">
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div>
|
||||
@@ -921,6 +651,7 @@ export function Inspector({ companyId, initialContactId, onClose, apiBase }: Ins
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* AI Analysis Dossier */}
|
||||
{aiAnalysis && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -948,34 +679,60 @@ export function Inspector({ companyId, initialContactId, onClose, apiBase }: Ins
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Wikipedia Section */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wider flex items-center gap-2">
|
||||
<Globe className="h-4 w-4" /> Company Profile (Wikipedia)
|
||||
</h3>
|
||||
|
||||
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
{wikiDate && (
|
||||
|
||||
<div className="text-[10px] text-slate-500 flex items-center gap-1 mr-2">
|
||||
|
||||
<Clock className="h-3 w-3" /> {new Date(wikiDate).toLocaleDateString()}
|
||||
|
||||
</div>
|
||||
|
||||
)}
|
||||
|
||||
|
||||
|
||||
{/* 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>
|
||||
|
||||
)}
|
||||
|
||||
{/* Re-evaluate Button */}
|
||||
<button
|
||||
onClick={handleReevaluateWikipedia}
|
||||
disabled={isProcessing}
|
||||
@@ -985,14 +742,24 @@ export function Inspector({ companyId, initialContactId, onClose, apiBase }: Ins
|
||||
<RefreshCwIcon className={clsx("h-3.5 w-3.5", isProcessing && "animate-spin")} />
|
||||
</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
|
||||
onClick={handleWikiOverride}
|
||||
@@ -1028,6 +795,7 @@ export function Inspector({ companyId, initialContactId, onClose, apiBase }: Ins
|
||||
|
||||
{wiki && wiki.url !== 'k.A.' && !isEditingWiki ? (
|
||||
<div>
|
||||
{/* ... existing wiki content ... */}
|
||||
<div className="bg-white dark:bg-slate-800/30 rounded-xl p-5 border border-slate-200 dark:border-slate-800/50 relative overflow-hidden shadow-sm">
|
||||
<div className="absolute top-0 right-0 p-3 opacity-10">
|
||||
<Globe className="h-16 w-16 text-slate-900 dark:text-white" />
|
||||
@@ -1115,6 +883,7 @@ export function Inspector({ companyId, initialContactId, onClose, apiBase }: Ins
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Quantitative Potential Analysis (v0.7.0) */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wider mb-3 flex items-center gap-2">
|
||||
<Bot className="h-4 w-4" /> Quantitative Potential
|
||||
@@ -1122,6 +891,7 @@ export function Inspector({ companyId, initialContactId, onClose, apiBase }: Ins
|
||||
|
||||
{data.calculated_metric_value != null || data.standardized_metric_value != null ? (
|
||||
<div className="bg-slate-50 dark:bg-slate-950 rounded-lg p-4 border border-slate-200 dark:border-slate-800 space-y-4">
|
||||
{/* Calculated Metric */}
|
||||
{data.calculated_metric_value != null && (
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="p-2 bg-white dark:bg-slate-800 rounded-lg text-blue-500 mt-1">
|
||||
@@ -1137,6 +907,7 @@ export function Inspector({ companyId, initialContactId, onClose, apiBase }: Ins
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Standardized Metric */}
|
||||
{data.standardized_metric_value != null && (
|
||||
<div className="flex items-start gap-3 pt-4 border-t border-slate-200 dark:border-slate-800">
|
||||
<div className="p-2 bg-white dark:bg-slate-800 rounded-lg text-green-500 mt-1">
|
||||
@@ -1153,9 +924,11 @@ export function Inspector({ companyId, initialContactId, onClose, apiBase }: Ins
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Source & Confidence */}
|
||||
{data.metric_source && (
|
||||
<div className="flex justify-between items-center text-[10px] text-slate-500 pt-2 border-t border-slate-200 dark:border-slate-800">
|
||||
|
||||
{/* Confidence Score */}
|
||||
{data.metric_confidence != null && (
|
||||
<div className="flex items-center gap-1.5" title={data.metric_confidence_reason || "No reason provided"}>
|
||||
<span className="uppercase font-bold tracking-tight text-[9px]">Confidence:</span>
|
||||
@@ -1173,6 +946,7 @@ export function Inspector({ companyId, initialContactId, onClose, apiBase }: Ins
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Source Link */}
|
||||
<div className="flex items-center gap-1">
|
||||
<Database className="h-3 w-3" />
|
||||
<span>Source:</span>
|
||||
@@ -1200,18 +974,27 @@ export function Inspector({ companyId, initialContactId, onClose, apiBase }: Ins
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Meta Info */}
|
||||
<div className="pt-6 border-t border-slate-200 dark:border-slate-800 flex items-center justify-between">
|
||||
<div className="text-[10px] text-slate-500 flex items-center gap-2 uppercase font-bold tracking-widest">
|
||||
<Calendar className="h-3 w-3" /> Added: {new Date(data.created_at).toLocaleDateString()}
|
||||
</div>
|
||||
<div className="text-[10px] text-slate-400 dark:text-slate-600 italic">
|
||||
ID: CE-{data.id.toString().padStart(4, '0')}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
{activeTab === 'contacts' && (
|
||||
<ContactsManager
|
||||
contacts={data.contacts}
|
||||
initialContactId={initialContactId}
|
||||
onAddContact={handleAddContact}
|
||||
onEditContact={handleEditContact}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<ContactsManager
|
||||
contacts={data.contacts}
|
||||
initialContactId={initialContactId}
|
||||
onAddContact={handleAddContact}
|
||||
onEditContact={handleEditContact}
|
||||
/>
|
||||
)} </div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import axios from 'axios'
|
||||
import { X, Bot, Tag, Target, Users, Plus, Trash2, Save, Flag, Check, Ban, ExternalLink } from 'lucide-react'
|
||||
import { X, Bot, Tag, Target, Users, Plus, Trash2, Save } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
interface RoboticsSettingsProps {
|
||||
@@ -9,46 +9,27 @@ interface RoboticsSettingsProps {
|
||||
apiBase: string
|
||||
}
|
||||
|
||||
type ReportedMistake = {
|
||||
id: number;
|
||||
company_id: number;
|
||||
company: { name: string }; // Assuming company name is eagerly loaded
|
||||
field_name: string;
|
||||
wrong_value: string | null;
|
||||
corrected_value: string | null;
|
||||
source_url: string | null;
|
||||
quote: string | null;
|
||||
user_comment: string | null;
|
||||
status: 'PENDING' | 'APPROVED' | 'REJECTED';
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export function RoboticsSettings({ isOpen, onClose, apiBase }: RoboticsSettingsProps) {
|
||||
const [activeTab, setActiveTab] = useState<'robotics' | 'industries' | 'roles' | 'mistakes'>(
|
||||
localStorage.getItem('roboticsSettingsActiveTab') as 'robotics' | 'industries' | 'roles' | 'mistakes' || 'robotics'
|
||||
const [activeTab, setActiveTab] = useState<'robotics' | 'industries' | 'roles'>(
|
||||
localStorage.getItem('roboticsSettingsActiveTab') as 'robotics' | 'industries' | 'roles' || 'robotics'
|
||||
)
|
||||
|
||||
const [roboticsCategories, setRoboticsCategories] = useState<any[]>([])
|
||||
const [industries, setIndustries] = useState<any[]>([])
|
||||
const [jobRoles, setJobRoles] = useState<any[]>([])
|
||||
const [reportedMistakes, setReportedMistakes] = useState<ReportedMistake[]>([])
|
||||
const [currentMistakeStatusFilter, setCurrentMistakeStatusFilter] = useState<string>("PENDING");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const fetchAllData = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const [resRobotics, resIndustries, resJobRoles, resMistakes] = await Promise.all([
|
||||
const [resRobotics, resIndustries, resJobRoles] = await Promise.all([
|
||||
axios.get(`${apiBase}/robotics/categories`),
|
||||
axios.get(`${apiBase}/industries`),
|
||||
axios.get(`${apiBase}/job_roles`),
|
||||
axios.get(`${apiBase}/mistakes?status=${currentMistakeStatusFilter}`),
|
||||
]);
|
||||
setRoboticsCategories(resRobotics.data);
|
||||
setIndustries(resIndustries.data);
|
||||
setJobRoles(resJobRoles.data);
|
||||
setReportedMistakes(resMistakes.data.items);
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch settings data:", e);
|
||||
alert("Fehler beim Laden der Settings. Siehe Konsole.");
|
||||
@@ -81,19 +62,6 @@ export function RoboticsSettings({ isOpen, onClose, apiBase }: RoboticsSettingsP
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpdateMistakeStatus = async (mistakeId: number, newStatus: 'APPROVED' | 'REJECTED') => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await axios.put(`${apiBase}/mistakes/${mistakeId}`, { status: newStatus });
|
||||
fetchAllData(); // Refresh all data, including mistakes
|
||||
} catch (e) {
|
||||
alert("Failed to update mistake status");
|
||||
console.error(e);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddJobRole = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
@@ -141,7 +109,6 @@ export function RoboticsSettings({ isOpen, onClose, apiBase }: RoboticsSettingsP
|
||||
{ id: 'robotics', label: 'Robotics Potential', icon: Bot },
|
||||
{ id: 'industries', label: 'Industry Focus', icon: Target },
|
||||
{ id: 'roles', label: 'Job Role Mapping', icon: Users },
|
||||
{ id: 'mistakes', label: 'Reported Mistakes', icon: Flag },
|
||||
].map(t => (
|
||||
<button
|
||||
key={t.id}
|
||||
@@ -179,20 +146,9 @@ export function RoboticsSettings({ isOpen, onClose, apiBase }: RoboticsSettingsP
|
||||
)}
|
||||
<div className="flex gap-4 items-start pr-12">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h4 className="font-bold text-slate-900 dark:text-white text-sm">{ind.name}</h4>
|
||||
{ind.priority && (
|
||||
<span className={clsx("text-[9px] font-bold px-1.5 py-0.5 rounded uppercase tracking-wider",
|
||||
ind.priority === "Freigegeben" ? "bg-green-100 text-green-700" : "bg-purple-100 text-purple-700"
|
||||
)}>
|
||||
{ind.priority}
|
||||
</span>
|
||||
)}
|
||||
{ind.ops_focus_secondary && (
|
||||
<span className="text-[9px] font-bold px-1.5 py-0.5 rounded uppercase tracking-wider bg-orange-100 text-orange-700 border border-orange-200">
|
||||
SEC-PRODUCT
|
||||
</span>
|
||||
)}
|
||||
<h4 className="font-bold text-slate-900 dark:text-white text-sm">{ind.name}</h4>
|
||||
<div className="flex flex-wrap gap-2 mt-1">
|
||||
{ind.status_notion && <span className="text-[10px] border border-slate-300 dark:border-slate-700 px-1.5 rounded text-slate-500">{ind.status_notion}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
@@ -202,47 +158,12 @@ export function RoboticsSettings({ isOpen, onClose, apiBase }: RoboticsSettingsP
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-slate-600 dark:text-slate-300 italic whitespace-pre-wrap">{ind.description || "No definition"}</p>
|
||||
|
||||
{(ind.pains || ind.gains) && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 mt-2">
|
||||
{ind.pains && (
|
||||
<div className="p-2 bg-red-50/50 dark:bg-red-900/10 rounded border border-red-100 dark:border-red-900/30">
|
||||
<div className="text-[9px] font-bold text-red-600 dark:text-red-400 uppercase mb-1">Pains</div>
|
||||
<div className="text-[10px] text-slate-600 dark:text-slate-400 line-clamp-3 hover:line-clamp-none transition-all">{ind.pains}</div>
|
||||
</div>
|
||||
)}
|
||||
{ind.gains && (
|
||||
<div className="p-2 bg-green-50/50 dark:bg-green-900/10 rounded border border-green-100 dark:border-green-900/30">
|
||||
<div className="text-[9px] font-bold text-green-600 dark:text-green-400 uppercase mb-1">Gains</div>
|
||||
<div className="text-[10px] text-slate-600 dark:text-slate-400 line-clamp-3 hover:line-clamp-none transition-all">{ind.gains}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{ind.notes && (
|
||||
<div className="text-[10px] text-slate-500 border-l-2 border-slate-200 dark:border-slate-800 pl-2 py-1">
|
||||
<span className="font-bold uppercase mr-1">Notes:</span> {ind.notes}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-slate-600 dark:text-slate-300 italic whitespace-pre-wrap">{ind.description || "No definition"}</p>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2 text-[10px] bg-white dark:bg-slate-900 p-2 rounded border border-slate-200 dark:border-slate-800">
|
||||
<div><span className="block text-slate-400 font-bold uppercase">Whale ></span><span className="text-slate-700 dark:text-slate-200">{ind.whale_threshold || "-"}</span></div>
|
||||
<div><span className="block text-slate-400 font-bold uppercase">Min Req</span><span className="text-slate-700 dark:text-slate-200">{ind.min_requirement || "-"}</span></div>
|
||||
<div><span className="block text-slate-400 font-bold uppercase">Unit</span><span className="text-slate-700 dark:text-slate-200 truncate">{ind.scraper_search_term || "-"}</span></div>
|
||||
<div>
|
||||
<span className="block text-slate-400 font-bold uppercase">Product</span>
|
||||
<span className="text-slate-700 dark:text-slate-200 truncate">{roboticsCategories.find(c => c.id === ind.primary_category_id)?.name || "-"}</span>
|
||||
{ind.secondary_category_id && (
|
||||
<div className="mt-1 pt-1 border-t border-slate-100 dark:border-slate-800">
|
||||
<span className="block text-orange-400 font-bold uppercase text-[9px]">Sec. Prod</span>
|
||||
<span className="text-slate-700 dark:text-slate-200 truncate">{roboticsCategories.find(c => c.id === ind.secondary_category_id)?.name || "-"}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div><span className="block text-slate-400 font-bold uppercase">Product</span><span className="text-slate-700 dark:text-slate-200 truncate">{roboticsCategories.find(c => c.id === ind.primary_category_id)?.name || "-"}</span></div>
|
||||
</div>
|
||||
{ind.scraper_keywords && <div className="text-[10px]"><span className="text-slate-400 font-bold uppercase mr-2">Keywords:</span><span className="text-slate-600 dark:text-slate-400 font-mono">{ind.scraper_keywords}</span></div>}
|
||||
{ind.standardization_logic && <div className="text-[10px]"><span className="text-slate-400 font-bold uppercase mr-2">Standardization:</span><span className="text-slate-600 dark:text-slate-400 font-mono">{ind.standardization_logic}</span></div>}
|
||||
@@ -269,86 +190,6 @@ export function RoboticsSettings({ isOpen, onClose, apiBase }: RoboticsSettingsP
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div key="mistakes-content" className={clsx("space-y-4", { 'hidden': isLoading || activeTab !== 'mistakes' })}>
|
||||
<div className="flex justify-between items-center">
|
||||
<h3 className="text-sm font-bold text-slate-700 dark:text-slate-300">Reported Data Mistakes</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-slate-500">Filter:</span>
|
||||
<select
|
||||
value={currentMistakeStatusFilter}
|
||||
onChange={e => setCurrentMistakeStatusFilter(e.target.value)}
|
||||
className="bg-slate-50 dark:bg-slate-800 border border-slate-300 dark:border-slate-700 rounded-md px-2 py-1 text-xs text-slate-900 dark:text-white focus:ring-1 focus:ring-blue-500 outline-none"
|
||||
>
|
||||
<option value="PENDING">Pending</option>
|
||||
<option value="APPROVED">Approved</option>
|
||||
<option value="REJECTED">Rejected</option>
|
||||
<option value="ALL">All</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-slate-50 dark:bg-slate-950 border border-slate-200 dark:border-slate-800 rounded-lg overflow-hidden">
|
||||
<table className="w-full text-left text-xs">
|
||||
<thead className="bg-slate-100 dark:bg-slate-900 border-b border-slate-200 dark:border-slate-800 text-slate-500 font-bold uppercase"><tr>
|
||||
<th className="p-3">Company</th>
|
||||
<th className="p-3">Field</th>
|
||||
<th className="p-3">Wrong Value</th>
|
||||
<th className="p-3">Corrected Value</th>
|
||||
<th className="p-3">Source / Quote / Comment</th>
|
||||
<th className="p-3">Status</th>
|
||||
<th className="p-3 w-10">Actions</th>
|
||||
</tr></thead>
|
||||
<tbody className="divide-y divide-slate-200 dark:divide-slate-800">
|
||||
{reportedMistakes.length > 0 ? (
|
||||
reportedMistakes.map(mistake => (
|
||||
<tr key={mistake.id} className="group">
|
||||
<td className="p-2 font-medium text-slate-900 dark:text-slate-200">{mistake.company.name}</td>
|
||||
<td className="p-2 text-slate-700 dark:text-slate-300">{mistake.field_name}</td>
|
||||
<td className="p-2 text-red-600 dark:text-red-400">{mistake.wrong_value || '-'}</td>
|
||||
<td className="p-2 text-green-600 dark:text-green-400">{mistake.corrected_value || '-'}</td>
|
||||
<td className="p-2 text-slate-500">
|
||||
{mistake.source_url && <a href={mistake.source_url} target="_blank" rel="noopener noreferrer" className="text-blue-600 dark:text-blue-400 hover:underline flex items-center gap-1 mb-1"><ExternalLink className="h-3 w-3" /> Source</a>}
|
||||
{mistake.quote && <p className="italic text-[10px] my-1">"{mistake.quote}"</p>}
|
||||
{mistake.user_comment && <p className="text-[10px]">Comment: {mistake.user_comment}</p>}
|
||||
</td>
|
||||
<td className="p-2">
|
||||
<span className={clsx("px-2 py-0.5 rounded-full text-[10px] font-semibold", {
|
||||
"bg-yellow-100 text-yellow-700": mistake.status === "PENDING",
|
||||
"bg-green-100 text-green-700": mistake.status === "APPROVED",
|
||||
"bg-red-100 text-red-700": mistake.status === "REJECTED",
|
||||
})}>
|
||||
{mistake.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="p-2 text-center">
|
||||
{mistake.status === "PENDING" && (
|
||||
<div className="flex gap-1 justify-center opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
onClick={() => handleUpdateMistakeStatus(mistake.id, "APPROVED")}
|
||||
className="text-green-600 hover:text-green-700"
|
||||
title="Approve Mistake"
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleUpdateMistakeStatus(mistake.id, "REJECTED")}
|
||||
className="text-red-600 hover:text-red-700"
|
||||
title="Reject Mistake"
|
||||
>
|
||||
<Ban className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
) : (
|
||||
<tr><td colSpan={7} className="p-8 text-center text-slate-500 italic">No reported mistakes found.</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["DOM", "DOM.Iterable", "ESNext"],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": false,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"allowImportingTsExtensions": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import sys
|
||||
import os
|
||||
|
||||
# Add backend to path so imports work
|
||||
sys.path.append(os.path.join(os.getcwd(), "company-explorer"))
|
||||
|
||||
from backend.database import init_db
|
||||
|
||||
print("Initializing Database Schema...")
|
||||
init_db()
|
||||
print("Done.")
|
||||
@@ -1,39 +0,0 @@
|
||||
import sqlite3
|
||||
import os
|
||||
|
||||
DB_PATH = "/app/companies_v3_fixed_2.db"
|
||||
|
||||
# If running outside container, adjust path
|
||||
if not os.path.exists(DB_PATH):
|
||||
DB_PATH = "companies_v3_fixed_2.db"
|
||||
|
||||
def upgrade():
|
||||
print(f"Upgrading database at {DB_PATH}...")
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
cursor = conn.cursor()
|
||||
|
||||
# 2. Add Columns to Contact
|
||||
try:
|
||||
cursor.execute("ALTER TABLE contacts ADD COLUMN so_contact_id INTEGER")
|
||||
print("✅ Added column: so_contact_id")
|
||||
except sqlite3.OperationalError as e:
|
||||
if "duplicate column" in str(e):
|
||||
print("ℹ️ Column so_contact_id already exists")
|
||||
else:
|
||||
print(f"❌ Error adding so_contact_id: {e}")
|
||||
|
||||
try:
|
||||
cursor.execute("ALTER TABLE contacts ADD COLUMN so_person_id INTEGER")
|
||||
print("✅ Added column: so_person_id")
|
||||
except sqlite3.OperationalError as e:
|
||||
if "duplicate column" in str(e):
|
||||
print("ℹ️ Column so_person_id already exists")
|
||||
else:
|
||||
print(f"❌ Error adding so_person_id: {e}")
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
print("Upgrade complete.")
|
||||
|
||||
if __name__ == "__main__":
|
||||
upgrade()
|
||||
@@ -1,154 +0,0 @@
|
||||
import requests
|
||||
import os
|
||||
import base64
|
||||
import json
|
||||
import time
|
||||
|
||||
# --- Konfiguration ---
|
||||
BASE_URL = "http://192.168.178.6:8090/ce/api"
|
||||
API_USER = os.getenv("COMPANY_EXPLORER_API_USER", "admin")
|
||||
API_PASSWORD = os.getenv("COMPANY_EXPLORER_API_PASSWORD", "gemini")
|
||||
|
||||
def _make_api_request(method, endpoint, params=None, json_data=None):
|
||||
"""Eine zentrale Hilfsfunktion für API-Anfragen."""
|
||||
url = f"{BASE_URL}{endpoint}"
|
||||
try:
|
||||
response = requests.request(
|
||||
method,
|
||||
url,
|
||||
auth=(API_USER, API_PASSWORD),
|
||||
params=params,
|
||||
json=json_data,
|
||||
timeout=20
|
||||
)
|
||||
response.raise_for_status()
|
||||
if response.status_code == 204 or not response.content:
|
||||
return {}
|
||||
return response.json()
|
||||
except requests.exceptions.HTTPError as http_err:
|
||||
return {"error": f"HTTP error occurred: {http_err} - {response.text}"}
|
||||
except requests.exceptions.ConnectionError as conn_err:
|
||||
return {"error": f"Connection error: {conn_err}."}
|
||||
except requests.exceptions.Timeout as timeout_err:
|
||||
return {"error": f"Timeout error: {timeout_err}."}
|
||||
except requests.exceptions.RequestException as req_err:
|
||||
return {"error": f"An unexpected error occurred: {req_err}"}
|
||||
except json.JSONDecodeError:
|
||||
return {"error": f"Failed to decode JSON from response: {response.text}"}
|
||||
|
||||
def check_company_existence(company_name: str) -> dict:
|
||||
"""Prüft die Existenz eines Unternehmens."""
|
||||
response = _make_api_request("GET", "/companies", params={"search": company_name})
|
||||
if "error" in response:
|
||||
return {"exists": False, "error": response["error"]}
|
||||
|
||||
if response.get("total", 0) > 0:
|
||||
for company in response.get("items", []):
|
||||
if company.get("name", "").lower() == company_name.lower():
|
||||
return {"exists": True, "company": company}
|
||||
|
||||
return {"exists": False, "message": f"Company '{company_name}' not found."}
|
||||
|
||||
def create_company(company_name: str) -> dict:
|
||||
"""Erstellt ein neues Unternehmen."""
|
||||
return _make_api_request("POST", "/companies", json_data={"name": company_name, "country": "DE"})
|
||||
|
||||
def trigger_discovery(company_id: int) -> dict:
|
||||
"""Startet den Discovery-Prozess."""
|
||||
return _make_api_request("POST", "/enrich/discover", json_data={"company_id": company_id})
|
||||
|
||||
def trigger_analysis(company_id: int) -> dict:
|
||||
"""Startet den Analyse-Prozess."""
|
||||
return _make_api_request("POST", "/enrich/analyze", json_data={"company_id": company_id})
|
||||
|
||||
def get_company_details(company_id: int) -> dict:
|
||||
"""Holt die vollständigen Details zu einem Unternehmen."""
|
||||
return _make_api_request("GET", f"/companies/{company_id}")
|
||||
|
||||
def handle_company_workflow(company_name: str) -> dict:
|
||||
"""
|
||||
Haupt-Workflow: Prüft, erstellt und reichert ein Unternehmen an.
|
||||
Gibt die finalen Unternehmensdaten zurück.
|
||||
"""
|
||||
print(f"Workflow gestartet für: '{company_name}'")
|
||||
|
||||
# 1. Prüfen, ob das Unternehmen existiert
|
||||
existence_check = check_company_existence(company_name)
|
||||
|
||||
if existence_check.get("exists"):
|
||||
company_id = existence_check["company"]["id"]
|
||||
print(f"Unternehmen '{company_name}' (ID: {company_id}) existiert bereits.")
|
||||
final_company_data = get_company_details(company_id)
|
||||
return {"status": "found", "data": final_company_data}
|
||||
|
||||
if "error" in existence_check:
|
||||
print(f"Fehler bei der Existenzprüfung: {existence_check['error']}")
|
||||
return {"status": "error", "message": existence_check['error']}
|
||||
|
||||
# 2. Wenn nicht, Unternehmen erstellen
|
||||
print(f"Unternehmen '{company_name}' nicht gefunden. Erstelle es...")
|
||||
creation_response = create_company(company_name)
|
||||
|
||||
if "error" in creation_response:
|
||||
print(f"Fehler bei der Erstellung: {creation_response['error']}")
|
||||
return {"status": "error", "message": creation_response['error']}
|
||||
|
||||
company_id = creation_response.get("id")
|
||||
if not company_id:
|
||||
print(f"Fehler: Konnte keine ID aus der Erstellungs-Antwort extrahieren: {creation_response}")
|
||||
return {"status": "error", "message": "Failed to get company ID after creation."}
|
||||
|
||||
print(f"Unternehmen '{company_name}' erfolgreich mit ID {company_id} erstellt.")
|
||||
|
||||
# 3. Discovery anstoßen
|
||||
print(f"Starte Discovery für ID {company_id}...")
|
||||
discovery_status = trigger_discovery(company_id)
|
||||
if "error" in discovery_status:
|
||||
print(f"Fehler beim Anstoßen der Discovery: {discovery_status['error']}")
|
||||
return {"status": "error", "message": discovery_status['error']}
|
||||
|
||||
# 4. Warten, bis Discovery eine Website gefunden hat (Polling)
|
||||
max_wait_time = 30
|
||||
start_time = time.time()
|
||||
website_found = False
|
||||
print("Warte auf Abschluss der Discovery (max. 30s)...")
|
||||
while time.time() - start_time < max_wait_time:
|
||||
details = get_company_details(company_id)
|
||||
if details.get("website") and details["website"] not in ["", "k.A."]:
|
||||
print(f"Website gefunden: {details['website']}")
|
||||
website_found = True
|
||||
break
|
||||
time.sleep(3)
|
||||
print(".")
|
||||
|
||||
if not website_found:
|
||||
print("Discovery hat nach 30s keine Website gefunden. Breche Analyse ab.")
|
||||
final_data = get_company_details(company_id)
|
||||
return {"status": "created_discovery_timeout", "data": final_data}
|
||||
|
||||
# 5. Analyse anstoßen
|
||||
print(f"Starte Analyse für ID {company_id}...")
|
||||
analysis_status = trigger_analysis(company_id)
|
||||
if "error" in analysis_status:
|
||||
print(f"Fehler beim Anstoßen der Analyse: {analysis_status['error']}")
|
||||
return {"status": "error", "message": analysis_status['error']}
|
||||
|
||||
print("Analyse-Prozess erfolgreich in die Warteschlange gestellt.")
|
||||
|
||||
# 6. Finale Daten abrufen und zurückgeben
|
||||
final_company_data = get_company_details(company_id)
|
||||
|
||||
return {"status": "created_and_enriched", "data": final_company_data}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_company_existing = "Robo-Planet GmbH"
|
||||
test_company_new = f"Zufallsfirma {int(time.time())}"
|
||||
|
||||
print(f"--- Szenario 1: Test mit einem existierenden Unternehmen: '{test_company_existing}' ---")
|
||||
result_existing = handle_company_workflow(test_company_existing)
|
||||
print(json.dumps(result_existing, indent=2, ensure_ascii=False))
|
||||
|
||||
print(f"\n--- Szenario 2: Test mit einem neuen Unternehmen: '{test_company_new}' ---")
|
||||
result_new = handle_company_workflow(test_company_new)
|
||||
print(json.dumps(result_new, indent=2, ensure_ascii=False))
|
||||
@@ -18,21 +18,3 @@ View your app in AI Studio: https://ai.studio/apps/drive/1vJMxbT1hW3SiMDUeEd8cXG
|
||||
2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
|
||||
3. Run the app:
|
||||
`npm run dev`
|
||||
|
||||
## Importing Single Competitors
|
||||
|
||||
To import a specific competitor from a JSON analysis file into Notion, use the `import_single_competitor.py` script. This script allows you to selectively import data for a single company without affecting other entries.
|
||||
|
||||
**Usage:**
|
||||
|
||||
```bash
|
||||
python3 import_single_competitor.py --file <path_to_json_file> --name "<Exact Competitor Name>"
|
||||
```
|
||||
|
||||
**Example:**
|
||||
|
||||
To import "FENKA Robotics GmbH" from `Roboplanet_Competitive_Analysis_01_2026.json`:
|
||||
|
||||
```bash
|
||||
python3 import_single_competitor.py --file Roboplanet_Competitive_Analysis_01_2026.json --name "FENKA Robotics GmbH"
|
||||
```
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
# --- STAGE 1: Builder ---
|
||||
FROM python:3.11-slim AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install system dependencies needed for building C-extensions
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install dependencies system-wide
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
# VERIFICATION STEP: Ensure uvicorn is installed and found
|
||||
RUN which uvicorn || (echo "ERROR: uvicorn not found after install!" && exit 1)
|
||||
|
||||
# --- STAGE 2: Final Runtime ---
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install curl for healthcheck
|
||||
RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy system-wide installed packages from builder
|
||||
COPY --from=builder /usr/local/lib/python3.11/site-packages /usr/local/lib/python3.11/site-packages
|
||||
COPY --from=builder /usr/local/bin /usr/local/bin
|
||||
# Ensure /usr/local/bin (where pip installs executables by default) is in PATH
|
||||
ENV PATH=/usr/local/bin:$PATH
|
||||
|
||||
# Copy source code explicitly from their locations relative to the build context (which will be the project root)
|
||||
COPY worker.py .
|
||||
COPY webhook_app.py .
|
||||
COPY queue_manager.py .
|
||||
COPY config.py .
|
||||
COPY superoffice_client.py .
|
||||
|
||||
# Expose port for Webhook
|
||||
EXPOSE 8000
|
||||
|
||||
# Start both worker and webhook directly within the CMD, using absolute path for uvicorn
|
||||
CMD ["/bin/bash", "-c", "python3 worker.py & /usr/local/bin/uvicorn webhook_app:app --host 0.0.0.0 --port 8000"]
|
||||
@@ -1,90 +0,0 @@
|
||||
# SuperOffice Connector ("The Muscle") - GTM Engine
|
||||
|
||||
Dies ist der "dumme" Microservice zur Anbindung von **SuperOffice CRM** an die **Company Explorer Intelligence**.
|
||||
Der Connector agiert als reiner Bote ("Muscle"): Er nimmt Webhook-Events entgegen, fragt das "Gehirn" (Company Explorer) nach Instruktionen und führt diese im CRM aus.
|
||||
|
||||
## 1. Architektur: "The Intelligent Hub & The Loyal Messenger"
|
||||
|
||||
Wir haben uns für eine **Event-gesteuerte Architektur** entschieden, um Skalierbarkeit und Echtzeit-Verarbeitung zu gewährleisten.
|
||||
|
||||
**Der Datenfluss:**
|
||||
1. **Auslöser:** User ändert in SuperOffice einen Kontakt (z.B. Status -> `Init`).
|
||||
2. **Transport:** SuperOffice sendet ein `POST` Event an unseren Webhook-Endpunkt (`:8003/webhook`).
|
||||
3. **Queueing:** Der `Webhook Receiver` validiert das Event und legt es sofort in eine lokale `SQLite`-Queue (`connector_queue.db`).
|
||||
4. **Verarbeitung:** Ein separater `Worker`-Prozess holt den Job ab.
|
||||
5. **Provisioning:** Der Worker fragt den **Company Explorer** (`POST /api/provision/superoffice-contact`): "Was soll ich mit Person ID 123 tun?".
|
||||
6. **Write-Back:** Der Company Explorer liefert das fertige Text-Paket (Subject, Intro, Proof) zurück. Der Worker schreibt dies via REST API in die UDF-Felder von SuperOffice.
|
||||
|
||||
## 2. Kern-Komponenten
|
||||
|
||||
* **`webhook_app.py` (FastAPI):**
|
||||
* Lauscht auf Port `8000` (Extern: `8003`).
|
||||
* Nimmt Events entgegen, prüft Token (`WEBHOOK_SECRET`).
|
||||
* Schreibt Jobs in die Queue.
|
||||
* Endpunkt: `POST /webhook`.
|
||||
|
||||
* **`queue_manager.py` (SQLite):**
|
||||
* Verwaltet die lokale Job-Queue.
|
||||
* Status: `PENDING` -> `PROCESSING` -> `COMPLETED` / `FAILED`.
|
||||
* Persistiert Jobs auch bei Container-Neustart.
|
||||
|
||||
* **`worker.py`:**
|
||||
* Läuft als Hintergrundprozess.
|
||||
* Pollt die Queue alle 5 Sekunden.
|
||||
* Kommuniziert mit Company Explorer (Intern: `http://company-explorer:8000`) und SuperOffice API.
|
||||
* Behandelt Fehler und Retries.
|
||||
|
||||
* **`superoffice_client.py`:**
|
||||
* Kapselt die SuperOffice REST API (Auth, GET, PUT).
|
||||
* Verwaltet Refresh Tokens.
|
||||
|
||||
## 3. Setup & Konfiguration
|
||||
|
||||
### Docker Service
|
||||
Der Service läuft im Container `connector-superoffice`.
|
||||
Startet via `start.sh` sowohl den Webserver als auch den Worker.
|
||||
|
||||
### Konfiguration (`.env`)
|
||||
Der Connector benötigt folgende Variablen (in `docker-compose.yml` gesetzt):
|
||||
|
||||
```yaml
|
||||
environment:
|
||||
API_USER: "admin"
|
||||
API_PASSWORD: "..."
|
||||
COMPANY_EXPLORER_URL: "http://company-explorer:8000" # Interne Docker-Adresse
|
||||
WEBHOOK_SECRET: "changeme" # Muss mit SO-Webhook Config übereinstimmen
|
||||
# Plus die SuperOffice Credentials (Client ID, Secret, Refresh Token)
|
||||
```
|
||||
|
||||
## 4. API-Schnittstelle (Intern)
|
||||
|
||||
Der Connector ruft den Company Explorer auf und liefert dabei **Live-Daten** aus dem CRM für den "Double Truth" Abgleich:
|
||||
|
||||
**Request:** `POST /api/provision/superoffice-contact`
|
||||
```json
|
||||
{
|
||||
"so_contact_id": 12345,
|
||||
"so_person_id": 67890,
|
||||
"crm_name": "RoboPlanet GmbH",
|
||||
"crm_website": "www.roboplanet.de",
|
||||
"job_title": "Geschäftsführer"
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"texts": {
|
||||
"subject": "Optimierung Ihrer Logistik...",
|
||||
"intro": "Als Logistikleiter kennen Sie...",
|
||||
"social_proof": "Wir helfen bereits Firma X..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 5. Offene To-Dos (Roadmap)
|
||||
|
||||
* [ ] **UDF-Mapping:** Aktuell sind die `ProgId`s (z.B. `SuperOffice:5`) im Code (`worker.py`) hartkodiert. Dies muss in eine Config ausgelagert werden.
|
||||
* [ ] **Fehlerbehandlung:** Was passiert, wenn der Company Explorer "404 Not Found" meldet? (Aktuell: Log Warning & Skip).
|
||||
* [ ] **Redis:** Bei sehr hoher Last (>100 Events/Sekunde) sollte die SQLite-Queue durch Redis ersetzt werden.
|
||||
@@ -1 +0,0 @@
|
||||
# This file makes the directory a Python package
|
||||
@@ -1,66 +0,0 @@
|
||||
import time
|
||||
import requests
|
||||
from config import Config
|
||||
from logging_config import setup_logging
|
||||
|
||||
logger = setup_logging(__name__)
|
||||
|
||||
class AuthHandler:
|
||||
def __init__(self):
|
||||
# Load configuration from Config class
|
||||
self.client_id = Config.SO_CLIENT_ID
|
||||
self.client_secret = Config.SO_CLIENT_SECRET
|
||||
self.refresh_token = Config.SO_REFRESH_TOKEN
|
||||
self.tenant_id = Config.SO_CONTEXT_IDENTIFIER # e.g., Cust55774
|
||||
|
||||
# OAuth Token Endpoint for SOD (Could be configurable in future)
|
||||
self.token_url = "https://sod.superoffice.com/login/common/oauth/tokens"
|
||||
|
||||
self._access_token = None
|
||||
self._webapi_url = None
|
||||
self._expiry = 0
|
||||
|
||||
if not self.client_id:
|
||||
logger.error("SO_CLIENT_ID (or SO_SOD) is not set in environment!")
|
||||
|
||||
def get_ticket(self):
|
||||
if self._access_token and time.time() < self._expiry:
|
||||
return self._access_token, self._webapi_url
|
||||
return self.refresh_access_token()
|
||||
|
||||
def refresh_access_token(self):
|
||||
if not self.client_id:
|
||||
raise ValueError("Client ID is missing. Cannot refresh token.")
|
||||
|
||||
logger.info(f"Refreshing Access Token for Client ID: {self.client_id[:5]}...")
|
||||
|
||||
payload = {
|
||||
"grant_type": "refresh_token",
|
||||
"client_id": self.client_id,
|
||||
"client_secret": self.client_secret,
|
||||
"refresh_token": self.refresh_token
|
||||
}
|
||||
|
||||
headers = {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"Accept": "application/json"
|
||||
}
|
||||
|
||||
try:
|
||||
resp = requests.post(self.token_url, data=payload, headers=headers, timeout=30)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
|
||||
self._access_token = data.get("access_token")
|
||||
# Based on user's browser URL
|
||||
self._webapi_url = f"https://app-sod.superoffice.com/{self.tenant_id}"
|
||||
|
||||
self._expiry = time.time() + int(data.get("expires_in", 3600)) - 60
|
||||
logger.info("Successfully refreshed Access Token.")
|
||||
return self._access_token, self._webapi_url
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error refreshing access token: {e}")
|
||||
if hasattr(e, 'response') and e.response is not None:
|
||||
logger.error(f"Response: {e.response.text}")
|
||||
raise
|
||||
@@ -1,152 +0,0 @@
|
||||
import sqlite3
|
||||
import os
|
||||
import json
|
||||
import requests
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv(override=True)
|
||||
|
||||
DB_FILE = "marketing_matrix.db"
|
||||
GEMINI_API_KEY = os.getenv("GEMINI_API_KEY")
|
||||
NOTION_API_KEY = os.getenv("NOTION_API_KEY")
|
||||
NOTION_DB_INDUSTRIES = "2ec88f4285448014ab38ea664b4c2b81"
|
||||
|
||||
# --- MAPPINGS ---
|
||||
# SuperOffice ID -> Notion Vertical Name
|
||||
VERTICAL_MAP = {
|
||||
23: "Logistics - Warehouse"
|
||||
}
|
||||
|
||||
# SuperOffice ID -> Persona Name & Pains (aus unserer Definition)
|
||||
ROLE_MAP = {
|
||||
19: {"name": "Operativer Entscheider", "pains": "Zuverlässigkeit, einfache Bedienbarkeit, Personaleinsatz-Optimierung, minimale Störungen"},
|
||||
20: {"name": "Infrastruktur-Verantwortlicher", "pains": "Technische Machbarkeit, IT-Sicherheit, Integration, Brandschutz"},
|
||||
21: {"name": "Wirtschaftlicher Entscheider", "pains": "ROI, Amortisationszeit, Kostenstruktur, Einsparpotenziale"},
|
||||
22: {"name": "Innovations-Treiber", "pains": "Wettbewerbsfähigkeit, Modernisierung, Employer Branding, Kundenerlebnis"}
|
||||
}
|
||||
|
||||
# --- DATABASE SETUP ---
|
||||
def init_db():
|
||||
conn = sqlite3.connect(DB_FILE)
|
||||
c = conn.cursor()
|
||||
c.execute('''CREATE TABLE IF NOT EXISTS text_blocks
|
||||
(vertical_id INTEGER, role_id INTEGER,
|
||||
subject TEXT, intro TEXT, social_proof TEXT,
|
||||
PRIMARY KEY (vertical_id, role_id))''')
|
||||
conn.commit()
|
||||
conn.close()
|
||||
print("✅ Database initialized.")
|
||||
|
||||
# --- NOTION FETCHER ---
|
||||
def get_vertical_pains_gains(vertical_name):
|
||||
url = f"https://api.notion.com/v1/databases/{NOTION_DB_INDUSTRIES}/query"
|
||||
headers = {
|
||||
"Authorization": f"Bearer {NOTION_API_KEY}",
|
||||
"Notion-Version": "2022-06-28",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
payload = {
|
||||
"filter": {
|
||||
"property": "Vertical",
|
||||
"title": {"contains": vertical_name}
|
||||
}
|
||||
}
|
||||
resp = requests.post(url, headers=headers, json=payload)
|
||||
if resp.status_code == 200:
|
||||
results = resp.json().get("results", [])
|
||||
if results:
|
||||
props = results[0]['properties']
|
||||
pains = props.get('Pains', {}).get('rich_text', [])
|
||||
gains = props.get('Gains', {}).get('rich_text', [])
|
||||
return {
|
||||
"pains": pains[0]['plain_text'] if pains else "",
|
||||
"gains": gains[0]['plain_text'] if gains else ""
|
||||
}
|
||||
print(f"⚠️ Warning: No data found for {vertical_name}")
|
||||
return {"pains": "N/A", "gains": "N/A"}
|
||||
|
||||
# --- GEMINI GENERATOR ---
|
||||
def generate_text(vertical_name, v_data, role_id, role_data):
|
||||
url = f"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key={GEMINI_API_KEY}"
|
||||
|
||||
prompt = f"""
|
||||
Du bist ein B2B-Copywriter. Erstelle 3 Textbausteine für eine Cold-Outreach E-Mail.
|
||||
|
||||
KONTEXT:
|
||||
Branche: {vertical_name}
|
||||
Branchen-Pains: {v_data['pains']}
|
||||
Lösung-Gains: {v_data['gains']}
|
||||
|
||||
Rolle: {role_data['name']}
|
||||
Rollen-Pains: {role_data['pains']}
|
||||
|
||||
AUFGABE:
|
||||
1. Subject: Betreffzeile (max 40 Zeichen!). Knackig, Pain-bezogen.
|
||||
2. Intro: Einleitungssatz (max 40 Zeichen!). Brücke Pain -> Lösung.
|
||||
3. SocialProof: Referenzsatz (max 40 Zeichen!). "Wir arbeiten mit X..."
|
||||
|
||||
FORMAT (JSON):
|
||||
{{ "Subject": "...", "Intro": "...", "SocialProof": "..." }}
|
||||
"""
|
||||
|
||||
payload = {
|
||||
"contents": [{"parts": [{"text": prompt}]}],
|
||||
"generationConfig": {"responseMimeType": "application/json"}
|
||||
}
|
||||
|
||||
try:
|
||||
resp = requests.post(url, json=payload)
|
||||
if resp.status_code == 200:
|
||||
return json.loads(resp.json()['candidates'][0]['content']['parts'][0]['text'])
|
||||
except Exception as e:
|
||||
print(f"Gemini Error: {e}")
|
||||
return None
|
||||
|
||||
# --- MAIN ---
|
||||
def run_matrix():
|
||||
init_db()
|
||||
conn = sqlite3.connect(DB_FILE)
|
||||
c = conn.cursor()
|
||||
|
||||
# Iterate Verticals
|
||||
for v_id, v_name in VERTICAL_MAP.items():
|
||||
print(f"\nProcessing Vertical: {v_name} (ID: {v_id})")
|
||||
v_data = get_vertical_pains_gains(v_name)
|
||||
|
||||
# Iterate Roles
|
||||
for r_id, r_data in ROLE_MAP.items():
|
||||
print(f" > Generating for Role: {r_data['name']} (ID: {r_id})...", end="", flush=True)
|
||||
|
||||
# Check if exists (optional: skip if exists)
|
||||
# ...
|
||||
|
||||
text_block = generate_text(v_name, v_data, r_id, r_data)
|
||||
|
||||
if text_block:
|
||||
# Robustness: Handle list return from Gemini
|
||||
if isinstance(text_block, list):
|
||||
if len(text_block) > 0 and isinstance(text_block[0], dict):
|
||||
text_block = text_block[0] # Take first item if list of dicts
|
||||
else:
|
||||
print(" ❌ Failed (Unexpected JSON format: List without dicts).")
|
||||
continue
|
||||
|
||||
# Cut to 40 chars hard limit (Safety)
|
||||
subj = text_block.get("Subject", "")[:40]
|
||||
intro = text_block.get("Intro", "Intro")[:40] # Fallback key name check
|
||||
if "Introduction_Textonly" in text_block: intro = text_block["Introduction_Textonly"][:40]
|
||||
proof = text_block.get("SocialProof", "")[:40]
|
||||
if "Industry_References_Textonly" in text_block: proof = text_block["Industry_References_Textonly"][:40]
|
||||
|
||||
c.execute("INSERT OR REPLACE INTO text_blocks VALUES (?, ?, ?, ?, ?)",
|
||||
(v_id, r_id, subj, intro, proof))
|
||||
conn.commit()
|
||||
print(" ✅ Done.")
|
||||
else:
|
||||
print(" ❌ Failed.")
|
||||
|
||||
conn.close()
|
||||
print("\nMatrix generation complete.")
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_matrix()
|
||||
@@ -1,45 +0,0 @@
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Load environment variables
|
||||
if os.path.exists(".env"):
|
||||
load_dotenv(".env", override=True)
|
||||
elif os.path.exists("../.env"):
|
||||
load_dotenv("../.env", override=True)
|
||||
|
||||
class Config:
|
||||
# SuperOffice API Configuration
|
||||
SO_CLIENT_ID = os.getenv("SO_SOD")
|
||||
SO_CLIENT_SECRET = os.getenv("SO_CLIENT_SECRET")
|
||||
SO_CONTEXT_IDENTIFIER = os.getenv("SO_CONTEXT_IDENTIFIER")
|
||||
SO_REFRESH_TOKEN = os.getenv("SO_REFRESH_TOKEN")
|
||||
|
||||
# Company Explorer Configuration
|
||||
CE_API_URL = os.getenv("CE_API_URL", "http://company-explorer:8000")
|
||||
CE_API_USER = os.getenv("CE_API_USER", "admin")
|
||||
CE_API_PASSWORD = os.getenv("CE_API_PASSWORD", "gemini")
|
||||
|
||||
# UDF Mapping (ProgIds) - Defaulting to SOD values, should be overridden in Prod
|
||||
UDF_CONTACT_MAPPING = {
|
||||
"ai_challenge_sentence": os.getenv("UDF_CONTACT_CHALLENGE", "SuperOffice:1"),
|
||||
"ai_sentence_timestamp": os.getenv("UDF_CONTACT_TIMESTAMP", "SuperOffice:2"),
|
||||
"ai_sentence_source_hash": os.getenv("UDF_CONTACT_HASH", "SuperOffice:3"),
|
||||
"ai_last_outreach_date": os.getenv("UDF_CONTACT_OUTREACH", "SuperOffice:4")
|
||||
}
|
||||
|
||||
UDF_PERSON_MAPPING = {
|
||||
"ai_email_draft": os.getenv("UDF_PERSON_DRAFT", "SuperOffice:1"),
|
||||
"ma_status": os.getenv("UDF_PERSON_STATUS", "SuperOffice:2")
|
||||
}
|
||||
|
||||
# MA Status ID Mapping (Text -> ID) - Defaulting to discovered SOD values
|
||||
MA_STATUS_ID_MAP = {
|
||||
"Ready_to_Send": int(os.getenv("MA_STATUS_ID_READY", 11)),
|
||||
"Sent_Week1": int(os.getenv("MA_STATUS_ID_WEEK1", 12)),
|
||||
"Sent_Week2": int(os.getenv("MA_STATUS_ID_WEEK2", 13)),
|
||||
"Bounced": int(os.getenv("MA_STATUS_ID_BOUNCED", 14)),
|
||||
"Soft_Denied": int(os.getenv("MA_STATUS_ID_DENIED", 15)),
|
||||
"Interested": int(os.getenv("MA_STATUS_ID_INTERESTED", 16)),
|
||||
"Out_of_Office": int(os.getenv("MA_STATUS_ID_OOO", 17)),
|
||||
"Unsubscribed": int(os.getenv("MA_STATUS_ID_UNSUB", 18))
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import os
|
||||
import requests
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Lade Umgebungsvariablen, um ID und Refresh Token zu holen
|
||||
load_dotenv(dotenv_path="../.env")
|
||||
|
||||
# Wir nutzen das Secret, das du mir gegeben hast, HARTCODIERT, um .env Fehler auszuschließen
|
||||
HARDCODED_SECRET = "418c424681944ad4138788692dfd7ab2"
|
||||
|
||||
# ID und Refresh Token aus der .env (zur Kontrolle)
|
||||
client_id = os.getenv("SO_CLIENT_ID") or os.getenv("SO_SOD")
|
||||
refresh_token = os.getenv("SO_REFRESH_TOKEN")
|
||||
|
||||
print(f"--- DEBUG AUTHENTICATION ---")
|
||||
print(f"Client ID (aus .env): {client_id}")
|
||||
print(f"Refresh Token (aus .env): {refresh_token[:10]}...")
|
||||
print(f"Client Secret (hartcodiert): {HARDCODED_SECRET[:5]}...")
|
||||
|
||||
url = "https://sod.superoffice.com/login/common/oauth/tokens"
|
||||
|
||||
# Payload für den Request
|
||||
payload = {
|
||||
"grant_type": "refresh_token",
|
||||
"client_id": client_id,
|
||||
"client_secret": HARDCODED_SECRET,
|
||||
"refresh_token": refresh_token,
|
||||
# Manche Server brauchen das hier auch beim Refresh:
|
||||
"redirect_uri": "https://devnet-tools.superoffice.com/openid/callback"
|
||||
}
|
||||
|
||||
print(f"\nSende Request an: {url}")
|
||||
try:
|
||||
resp = requests.post(url, data=payload)
|
||||
print(f"Status Code: {resp.status_code}")
|
||||
|
||||
if resp.status_code == 200:
|
||||
print("SUCCESS! Access Token erhalten.")
|
||||
print(resp.json())
|
||||
else:
|
||||
print("FAILURE.")
|
||||
print(resp.text)
|
||||
except Exception as e:
|
||||
print(f"Exception: {e}")
|
||||
@@ -1,89 +0,0 @@
|
||||
# connector-superoffice/discover_fields.py (Standalone & Robust)
|
||||
import os
|
||||
import requests
|
||||
import json
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Load environment variables
|
||||
load_dotenv(override=True)
|
||||
|
||||
# Configuration
|
||||
SO_ENV = os.getenv("SO_ENVIRONMENT", "sod") # sod, stage, online
|
||||
SO_CLIENT_ID = os.getenv("SO_CLIENT_ID") or os.getenv("SO_SOD")
|
||||
SO_CLIENT_SECRET = os.getenv("SO_CLIENT_SECRET")
|
||||
# SO_REDIRECT_URI often required for validation even in refresh flow
|
||||
SO_REDIRECT_URI = os.getenv("SO_REDIRECT_URI", "http://localhost")
|
||||
SO_REFRESH_TOKEN = os.getenv("SO_REFRESH_TOKEN")
|
||||
|
||||
def get_access_token():
|
||||
"""Refreshes the access token using the refresh token."""
|
||||
url = f"https://{SO_ENV}.superoffice.com/login/common/oauth/tokens"
|
||||
data = {
|
||||
"grant_type": "refresh_token",
|
||||
"client_id": SO_CLIENT_ID,
|
||||
"client_secret": SO_CLIENT_SECRET,
|
||||
"refresh_token": SO_REFRESH_TOKEN,
|
||||
"redirect_uri": SO_REDIRECT_URI
|
||||
}
|
||||
|
||||
print(f"DEBUG: Refreshing token at {url} for Client ID {SO_CLIENT_ID[:5]}...")
|
||||
|
||||
response = requests.post(url, data=data)
|
||||
if response.status_code == 200:
|
||||
print("✅ Access Token refreshed.")
|
||||
return response.json().get("access_token")
|
||||
else:
|
||||
print(f"❌ Error getting token: {response.text}")
|
||||
return None
|
||||
|
||||
def discover_udfs(base_url, token, entity="Contact"):
|
||||
"""
|
||||
Fetches the UDF layout for a specific entity.
|
||||
entity: 'Contact' (Firma) or 'Person'
|
||||
"""
|
||||
endpoint = "Contact" if entity == "Contact" else "Person"
|
||||
url = f"{base_url}/api/v1/{endpoint}?$top=1&$select=userDefinedFields"
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Accept": "application/json"
|
||||
}
|
||||
|
||||
print(f"\n--- DISCOVERING UDFS FOR: {entity} ---")
|
||||
try:
|
||||
response = requests.get(url, headers=headers)
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
if data['value']:
|
||||
item = data['value'][0]
|
||||
udfs = item.get('userDefinedFields', {})
|
||||
|
||||
print(f"Found {len(udfs)} UDFs on this record.")
|
||||
|
||||
# Filter logic: Show interesting fields
|
||||
relevant_udfs = {k: v for k, v in udfs.items() if "marketing" in k.lower() or "robotic" in k.lower() or "challenge" in k.lower() or "ai" in k.lower()}
|
||||
|
||||
if relevant_udfs:
|
||||
print("✅ FOUND RELEVANT FIELDS (ProgId : Value):")
|
||||
print(json.dumps(relevant_udfs, indent=2))
|
||||
else:
|
||||
print("⚠️ No fields matching 'marketing/robotic/ai' found.")
|
||||
print("First 5 UDFs for context:")
|
||||
print(json.dumps(list(udfs.keys())[:5], indent=2))
|
||||
else:
|
||||
print("No records found to inspect.")
|
||||
else:
|
||||
print(f"Error {response.status_code}: {response.text}")
|
||||
except Exception as e:
|
||||
print(f"Request failed: {e}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
token = get_access_token()
|
||||
if token:
|
||||
# Hardcoded Base URL for Cust55774 (Fix: Use app-sod as per README)
|
||||
base_url = "https://app-sod.superoffice.com/Cust55774"
|
||||
|
||||
discover_udfs(base_url, token, "Person")
|
||||
discover_udfs(base_url, token, "Contact")
|
||||
else:
|
||||
print("Could not get Access Token. Check .env")
|
||||
@@ -1,49 +0,0 @@
|
||||
import os
|
||||
import requests
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv(override=True)
|
||||
|
||||
# Config
|
||||
SO_CLIENT_ID = os.getenv("SO_CLIENT_ID") or os.getenv("SO_SOD")
|
||||
SO_CLIENT_SECRET = os.getenv("SO_CLIENT_SECRET")
|
||||
SO_REFRESH_TOKEN = os.getenv("SO_REFRESH_TOKEN")
|
||||
# Base URL for your tenant (Dev)
|
||||
BASE_URL = "https://app-sod.superoffice.com/Cust55774/api/v1"
|
||||
|
||||
def get_token():
|
||||
url = "https://sod.superoffice.com/login/common/oauth/tokens"
|
||||
data = {
|
||||
"grant_type": "refresh_token",
|
||||
"client_id": SO_CLIENT_ID,
|
||||
"client_secret": SO_CLIENT_SECRET,
|
||||
"refresh_token": SO_REFRESH_TOKEN,
|
||||
"redirect_uri": "http://localhost"
|
||||
}
|
||||
try:
|
||||
resp = requests.post(url, data=data)
|
||||
if resp.status_code == 200:
|
||||
return resp.json().get("access_token")
|
||||
else:
|
||||
print(f"Token Error: {resp.text}")
|
||||
return None
|
||||
except Exception as e:
|
||||
print(f"Connection Error: {e}")
|
||||
return None
|
||||
|
||||
def check_contact(id):
|
||||
token = get_token()
|
||||
if not token: return
|
||||
|
||||
headers = {"Authorization": f"Bearer {token}", "Accept": "application/json"}
|
||||
url = f"{BASE_URL}/Contact/{id}"
|
||||
|
||||
resp = requests.get(url, headers=headers)
|
||||
if resp.status_code == 200:
|
||||
c = resp.json()
|
||||
print(f"✅ SUCCESS! Contact {id}: {c.get('Name')} (Category: {c.get('Category', {}).get('Value')})")
|
||||
else:
|
||||
print(f"❌ API Error {resp.status_code}: {resp.text}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
check_contact(2)
|
||||
@@ -1,36 +0,0 @@
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
import urllib.parse
|
||||
|
||||
def generate_url():
|
||||
load_dotenv(dotenv_path="/home/node/clawd/.env")
|
||||
|
||||
client_id = os.getenv("SO_CLIENT_ID") or os.getenv("SO_SOD")
|
||||
redirect_uri = "https://devnet-tools.superoffice.com/openid/callback" # Das muss im Portal so registriert sein
|
||||
state = "12345"
|
||||
|
||||
if not client_id:
|
||||
print("Fehler: Keine SO_CLIENT_ID in der .env gefunden!")
|
||||
return
|
||||
|
||||
params = {
|
||||
"client_id": client_id,
|
||||
"redirect_uri": redirect_uri,
|
||||
"response_type": "code",
|
||||
"scope": "openid offline_access", # Wichtig für Refresh Token
|
||||
"state": state
|
||||
}
|
||||
|
||||
base_url = "https://sod.superoffice.com/login/common/oauth/authorize"
|
||||
auth_url = f"{base_url}?{urllib.parse.urlencode(params)}"
|
||||
|
||||
print("\nBitte öffne diese URL im Browser:")
|
||||
print("-" * 60)
|
||||
print(auth_url)
|
||||
print("-" * 60)
|
||||
print("\nNach dem Login wirst du auf eine Seite weitergeleitet, die nicht lädt (localhost).")
|
||||
print("Kopiere die URL aus der Adresszeile und gib mir den Wert nach '?code='.")
|
||||
|
||||
if __name__ == "__main__":
|
||||
generate_url()
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
|
||||
# Generate private key
|
||||
private_key = rsa.generate_private_key(
|
||||
public_exponent=65537,
|
||||
key_size=2048,
|
||||
)
|
||||
|
||||
# Serialize private key to PEM
|
||||
pem_private = private_key.private_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PrivateFormat.PKCS8,
|
||||
encryption_algorithm=serialization.NoEncryption()
|
||||
)
|
||||
|
||||
# Generate public key
|
||||
public_key = private_key.public_key()
|
||||
pem_public = public_key.public_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PublicFormat.SubjectPublicKeyInfo
|
||||
)
|
||||
|
||||
# Save to files
|
||||
with open("private_key.pem", "wb") as f:
|
||||
f.write(pem_private)
|
||||
|
||||
with open("public_key.pem", "wb") as f:
|
||||
f.write(pem_public)
|
||||
|
||||
print("Keys generated successfully!")
|
||||
print(f"Private Key (save to .env as SO_PRIVATE_KEY):\n{pem_private.decode('utf-8')}")
|
||||
print("-" * 20)
|
||||
print(f"Public Key (upload to SuperOffice Dev Portal):\n{pem_public.decode('utf-8')}")
|
||||
@@ -1,126 +0,0 @@
|
||||
import os
|
||||
import json
|
||||
import requests
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Load environment variables
|
||||
load_dotenv(override=True)
|
||||
|
||||
GEMINI_API_KEY = os.getenv("GEMINI_API_KEY")
|
||||
NOTION_API_KEY = os.getenv("NOTION_API_KEY")
|
||||
|
||||
# Configuration
|
||||
NOTION_DB_INDUSTRIES = "2ec88f4285448014ab38ea664b4c2b81" # ID aus deinen Links
|
||||
|
||||
def get_vertical_data(vertical_name):
|
||||
"""Fetches Pains/Gains for a specific Vertical from Notion."""
|
||||
url = f"https://api.notion.com/v1/databases/{NOTION_DB_INDUSTRIES}/query"
|
||||
headers = {
|
||||
"Authorization": f"Bearer {NOTION_API_KEY}",
|
||||
"Notion-Version": "2022-06-28",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
payload = {
|
||||
"filter": {
|
||||
"property": "Vertical",
|
||||
"title": {
|
||||
"contains": vertical_name
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
response = requests.post(url, headers=headers, json=payload)
|
||||
if response.status_code == 200:
|
||||
results = response.json().get("results", [])
|
||||
if results:
|
||||
page = results[0]
|
||||
props = page['properties']
|
||||
|
||||
# Extract Text from Rich Text fields
|
||||
pains = props.get('Pains', {}).get('rich_text', [])
|
||||
gains = props.get('Gains', {}).get('rich_text', [])
|
||||
|
||||
pain_text = pains[0]['plain_text'] if pains else "N/A"
|
||||
gain_text = gains[0]['plain_text'] if gains else "N/A"
|
||||
|
||||
return {
|
||||
"vertical": vertical_name,
|
||||
"pains": pain_text,
|
||||
"gains": gain_text
|
||||
}
|
||||
return None
|
||||
|
||||
def generate_copy_with_gemini(vertical_data, persona_role, persona_pains):
|
||||
"""
|
||||
Generates the 3 text blocks using Gemini.
|
||||
"""
|
||||
url = f"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key={GEMINI_API_KEY}"
|
||||
|
||||
# Der Prompt (Dein Template)
|
||||
prompt_text = f"""
|
||||
Du bist ein kompetenter Lösungsberater und brillanter Texter.
|
||||
AUFGABE: Erstelle 3 Textblöcke (Subject, Introduction_Textonly, Industry_References_Textonly) für eine E-Mail.
|
||||
|
||||
--- KONTEXT ---
|
||||
ZIELBRANCHE: {vertical_data['vertical']}
|
||||
BRANCHEN-HERAUSFORDERUNGEN (PAIN POINTS): {vertical_data['pains']}
|
||||
LÖSUNGS-VORTEILE (GAINS): {vertical_data['gains']}
|
||||
|
||||
ANSPRECHPARTNER: {persona_role}
|
||||
PERSÖNLICHE HERAUSFORDERUNGEN DES ANSPRECHPARTNERS: {persona_pains}
|
||||
|
||||
REFERENZKUNDEN: "Wir arbeiten bereits mit Marktführern in Ihrer Branche." (Platzhalter)
|
||||
|
||||
--- DEINE AUFGABE ---
|
||||
1. Subject: Formuliere eine kurze Betreffzeile (max. 5 Wörter). Richte sie direkt an einem Pain Point aus.
|
||||
2. Introduction_Textonly: Formuliere einen Einleitungstext (2 Sätze).
|
||||
- Fokus: Brücke zwischen Problem und Lösung.
|
||||
3. Industry_References_Textonly: Formuliere einen Social Proof Satz.
|
||||
|
||||
--- FORMAT ---
|
||||
Antworte NUR mit reinem JSON:
|
||||
{{
|
||||
"Subject": "...",
|
||||
"Introduction_Textonly": "...",
|
||||
"Industry_References_Textonly": "..."
|
||||
}}
|
||||
"""
|
||||
|
||||
payload = {
|
||||
"contents": [{"parts": [{"text": prompt_text}]}],
|
||||
"generationConfig": {"responseMimeType": "application/json"}
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.post(url, json=payload)
|
||||
if response.status_code == 200:
|
||||
return json.loads(response.json()['candidates'][0]['content']['parts'][0]['text'])
|
||||
else:
|
||||
print(f"Gemini Error: {response.text}")
|
||||
return None
|
||||
except Exception as e:
|
||||
print(f"Error: {e}")
|
||||
return None
|
||||
|
||||
if __name__ == "__main__":
|
||||
# TEST RUN
|
||||
|
||||
# 1. Daten holen (Beispiel: Logistics)
|
||||
print("Fetching Vertical Data...")
|
||||
vertical = get_vertical_data("Logistics - Warehouse")
|
||||
|
||||
if vertical:
|
||||
print(f"Loaded: {vertical['vertical']}")
|
||||
|
||||
# 2. Persona definieren (Beispiel: Operativer Entscheider)
|
||||
role = "Logistikleiter / Operations Manager"
|
||||
role_pains = "Stillstand, Personalmangel, Stress, Unfallgefahr"
|
||||
|
||||
# 3. Generieren
|
||||
print("Generating Copy...")
|
||||
copy = generate_copy_with_gemini(vertical, role, role_pains)
|
||||
|
||||
print("\n--- RESULT ---")
|
||||
print(json.dumps(copy, indent=2, ensure_ascii=False))
|
||||
else:
|
||||
print("Vertical not found.")
|
||||
@@ -1,71 +0,0 @@
|
||||
import os
|
||||
import requests
|
||||
import json
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Load config
|
||||
load_dotenv(dotenv_path="../.env")
|
||||
client_id = os.getenv("SO_CLIENT_ID") or os.getenv("SO_SOD")
|
||||
client_secret = os.getenv("SO_CLIENT_SECRET")
|
||||
redirect_uri = "http://localhost" # Ensure this is in "Allowed Redirect URLs" in Dev Portal
|
||||
|
||||
if not client_id or not client_secret:
|
||||
print("Error: Please set SO_CLIENT_ID (or SO_SOD) and SO_CLIENT_SECRET in .env first.")
|
||||
exit(1)
|
||||
|
||||
# Step 1: Generate Authorization URL
|
||||
auth_url = (
|
||||
f"https://sod.superoffice.com/login/common/oauth/authorize"
|
||||
f"?client_id={client_id}"
|
||||
f"&redirect_uri={redirect_uri}"
|
||||
f"&response_type=code"
|
||||
f"&scope=openid" # Scope might be needed, try without first
|
||||
)
|
||||
|
||||
print(f"\n--- STEP 1: Authorization ---")
|
||||
print(f"Please open this URL in your browser:\n\n{auth_url}\n")
|
||||
print("1. Log in to your test tenant (Cust55774).")
|
||||
print("2. Click 'Allow' / 'Zulassen'.")
|
||||
print("3. You will be redirected to a localhost URL (it might fail to load, that's fine).")
|
||||
print("4. Copy the full URL from your browser's address bar and paste it here.")
|
||||
|
||||
redirected_url = input("\nPaste the full redirect URL here: ").strip()
|
||||
|
||||
# Extract code
|
||||
try:
|
||||
from urllib.parse import urlparse, parse_qs
|
||||
parsed = urlparse(redirected_url)
|
||||
code = parse_qs(parsed.query)['code'][0]
|
||||
except Exception as e:
|
||||
print(f"Error extracting code: {e}")
|
||||
exit(1)
|
||||
|
||||
# Step 2: Exchange Code for Tokens
|
||||
print(f"\n--- STEP 2: Token Exchange ---")
|
||||
token_url = "https://sod.superoffice.com/login/common/oauth/tokens"
|
||||
payload = {
|
||||
"grant_type": "authorization_code",
|
||||
"client_id": client_id,
|
||||
"client_secret": client_secret,
|
||||
"code": code,
|
||||
"redirect_uri": redirect_uri
|
||||
}
|
||||
|
||||
try:
|
||||
resp = requests.post(token_url, data=payload)
|
||||
resp.raise_for_status()
|
||||
tokens = resp.json()
|
||||
|
||||
refresh_token = tokens.get("refresh_token")
|
||||
access_token = tokens.get("access_token")
|
||||
|
||||
print("\nSUCCESS! Here are your tokens:")
|
||||
print(f"\nSO_REFRESH_TOKEN=\"{refresh_token}\"\n")
|
||||
print(f"\n(Access Token for testing: {access_token[:20]}...)")
|
||||
|
||||
print("\nAction: Please update your .env file with the SO_REFRESH_TOKEN value above!")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error exchanging code: {e}")
|
||||
if hasattr(e, 'response') and e.response:
|
||||
print(f"Response: {e.response.text}")
|
||||
@@ -1,129 +0,0 @@
|
||||
import os
|
||||
import requests
|
||||
from dotenv import load_dotenv
|
||||
import logging
|
||||
|
||||
# Set up basic logging
|
||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class AuthHandler:
|
||||
def __init__(self):
|
||||
load_dotenv(override=True)
|
||||
self.client_id = os.getenv("SO_CLIENT_ID") or os.getenv("SO_SOD")
|
||||
self.client_secret = os.getenv("SO_CLIENT_SECRET")
|
||||
self.refresh_token = os.getenv("SO_REFRESH_TOKEN")
|
||||
self.redirect_uri = os.getenv("SO_REDIRECT_URI", "http://localhost")
|
||||
self.env = os.getenv("SO_ENVIRONMENT", "sod")
|
||||
self.cust_id = os.getenv("SO_CONTEXT_IDENTIFIER", "Cust55774")
|
||||
|
||||
if not all([self.client_id, self.client_secret, self.refresh_token]):
|
||||
raise ValueError("SuperOffice credentials missing in .env file.")
|
||||
|
||||
logger.info("AuthHandler initialized with environment variables.")
|
||||
|
||||
def get_access_token(self):
|
||||
# This method would typically handle caching and refreshing
|
||||
# For this health check, we'll directly call _refresh_access_token
|
||||
return self._refresh_access_token()
|
||||
|
||||
def _refresh_access_token(self):
|
||||
url = f"https://{self.env}.superoffice.com/login/common/oauth/tokens"
|
||||
data = {
|
||||
"grant_type": "refresh_token",
|
||||
"client_id": self.client_id,
|
||||
"client_secret": self.client_secret,
|
||||
"refresh_token": self.refresh_token,
|
||||
"redirect_uri": self.redirect_uri
|
||||
}
|
||||
try:
|
||||
resp = requests.post(url, data=data)
|
||||
resp.raise_for_status()
|
||||
logger.info("Access token refreshed successfully.")
|
||||
return resp.json().get("access_token")
|
||||
except requests.exceptions.HTTPError as e:
|
||||
logger.error(f"❌ Token Refresh Error (Status: {e.response.status_code}): {e.response.text}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Connection Error during token refresh: {e}")
|
||||
return None
|
||||
|
||||
class SuperOfficeClient:
|
||||
def __init__(self, auth_handler):
|
||||
self.auth_handler = auth_handler
|
||||
self.env = os.getenv("SO_ENVIRONMENT", "sod")
|
||||
self.cust_id = os.getenv("SO_CONTEXT_IDENTIFIER", "Cust55774")
|
||||
self.base_url = f"https://app-{self.env}.superoffice.com/{self.cust_id}/api/v1"
|
||||
self.access_token = self.auth_handler.get_access_token()
|
||||
if not self.access_token:
|
||||
raise Exception("Failed to obtain access token during SuperOfficeClient initialization.")
|
||||
|
||||
self.headers = {
|
||||
"Authorization": f"Bearer {self.access_token}",
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json"
|
||||
}
|
||||
logger.info("✅ SuperOffice Client initialized and authenticated.")
|
||||
|
||||
def _get(self, endpoint):
|
||||
try:
|
||||
resp = requests.get(f"{self.base_url}/{endpoint}", headers=self.headers)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
except requests.exceptions.HTTPError as e:
|
||||
logger.error(f"❌ API GET Error for {endpoint} (Status: {e.response.status_code}): {e.response.text}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Connection Error for {endpoint}: {e}")
|
||||
return None
|
||||
|
||||
def perform_health_check():
|
||||
logger.info("Starting SuperOffice API health check...")
|
||||
try:
|
||||
auth_handler = AuthHandler()
|
||||
so_client = SuperOfficeClient(auth_handler)
|
||||
|
||||
# Test 1: Associate/Me
|
||||
logger.info("\n--- Test 1: Fetching current user details (/Associate/Me) ---")
|
||||
user_details = so_client._get("Associate/Me")
|
||||
if user_details:
|
||||
logger.info(f"✅ Associate/Me successful! Connected as: {user_details.get('Name')} (Associate ID: {user_details.get('AssociateId')})")
|
||||
else:
|
||||
logger.error("❌ Associate/Me failed.")
|
||||
|
||||
# Test 2: Get Person by ID (e.g., ID 1)
|
||||
logger.info("\n--- Test 2: Fetching Person with ID 1 (/Person/1) ---")
|
||||
person = so_client._get("Person/1")
|
||||
if person:
|
||||
logger.info(f"✅ Person/1 successful! Name: {person.get('Firstname')} {person.get('Lastname')}")
|
||||
else:
|
||||
logger.error("❌ Person/1 failed. (Could be that Person ID 1 does not exist or insufficient permissions)")
|
||||
|
||||
# Test 3: Get Contact by ID (e.g., ID 1)
|
||||
logger.info("\n--- Test 3: Fetching Contact with ID 1 (/Contact/1) ---")
|
||||
contact = so_client._get("Contact/1")
|
||||
if contact:
|
||||
logger.info(f"✅ Contact/1 successful! Name: {contact.get('Name')}")
|
||||
else:
|
||||
logger.error("❌ Contact/1 failed. (Could be that Contact ID 1 does not exist or insufficient permissions)")
|
||||
|
||||
# Overall check - if at least one read operation was successful
|
||||
if user_details or person or contact:
|
||||
logger.info("\n✅ SuperOffice API Connector seems partially operational (at least one read test passed).")
|
||||
return True
|
||||
else:
|
||||
logger.error("\n❌ SuperOffice API Connector is NOT operational (all read tests failed).")
|
||||
return False
|
||||
|
||||
except ValueError as ve:
|
||||
logger.error(f"❌ Configuration error: {ve}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"❌ An unexpected error occurred during health check: {e}", exc_info=True)
|
||||
return False
|
||||
|
||||
if __name__ == "__main__":
|
||||
if perform_health_check():
|
||||
logger.info("\nOverall SuperOffice API Health Check: PASSED (partially operational is still a pass for now).")
|
||||
else:
|
||||
logger.error("\nOverall SuperOffice API Health Check: FAILED.")
|
||||
@@ -1,108 +0,0 @@
|
||||
import os
|
||||
import sqlite3
|
||||
import requests
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv(override=True)
|
||||
|
||||
# --- CONFIGURATION ---
|
||||
DB_FILE = "marketing_matrix.db"
|
||||
SO_CLIENT_ID = os.getenv("SO_CLIENT_ID") or os.getenv("SO_SOD")
|
||||
SO_CLIENT_SECRET = os.getenv("SO_CLIENT_SECRET")
|
||||
SO_REFRESH_TOKEN = os.getenv("SO_REFRESH_TOKEN")
|
||||
BASE_URL = "https://app-sod.superoffice.com/Cust55774/api/v1"
|
||||
|
||||
# --- SUPEROFFICE UDF ProgIds (from your discovery) ---
|
||||
PROG_ID_CONTACT_CHALLENGE = "SuperOffice:6"
|
||||
PROG_ID_PERSON_SUBJECT = "SuperOffice:5"
|
||||
PROG_ID_PERSON_INTRO = "SuperOffice:6"
|
||||
PROG_ID_PERSON_PROOF = "SuperOffice:7"
|
||||
|
||||
# --- TEST DATA ---
|
||||
TEST_PERSON_ID = 1
|
||||
TEST_CONTACT_ID = 2
|
||||
TEST_VERTICAL_ID = 24 # Healthcare - Hospital
|
||||
TEST_ROLE_ID = 19 # Operativer Entscheider
|
||||
|
||||
def get_token():
|
||||
url = "https://sod.superoffice.com/login/common/oauth/tokens"
|
||||
data = {"grant_type": "refresh_token", "client_id": SO_CLIENT_ID, "client_secret": SO_CLIENT_SECRET, "refresh_token": SO_REFRESH_TOKEN, "redirect_uri": "http://localhost"}
|
||||
try:
|
||||
resp = requests.post(url, data=data)
|
||||
if resp.status_code == 200:
|
||||
return resp.json().get("access_token")
|
||||
else:
|
||||
print(f"Token Error: {resp.text}")
|
||||
return None
|
||||
except Exception as e:
|
||||
print(f"Connection Error: {e}")
|
||||
return None
|
||||
|
||||
def get_text_from_matrix(vertical_id, role_id):
|
||||
conn = sqlite3.connect(DB_FILE)
|
||||
c = conn.cursor()
|
||||
c.execute("SELECT subject, intro, social_proof FROM text_blocks WHERE vertical_id = ? AND role_id = ?", (vertical_id, role_id))
|
||||
row = c.fetchone()
|
||||
conn.close()
|
||||
return row if row else (None, None, None)
|
||||
|
||||
def update_udfs(entity, entity_id, payload, token):
|
||||
url = f"{BASE_URL}/{entity}/{entity_id}"
|
||||
headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json", "Accept": "application/json"}
|
||||
|
||||
# SuperOffice expects the full JSON body, not just the UDF part for PUT
|
||||
# First, GET the existing entity
|
||||
get_resp = requests.get(url, headers=headers)
|
||||
if get_resp.status_code != 200:
|
||||
print(f"❌ ERROR fetching {entity} {entity_id}: {get_resp.text}")
|
||||
return False
|
||||
|
||||
existing_data = get_resp.json()
|
||||
|
||||
# Merge UDFs
|
||||
if "UserDefinedFields" not in existing_data:
|
||||
existing_data["UserDefinedFields"] = {}
|
||||
existing_data["UserDefinedFields"].update(payload)
|
||||
|
||||
print(f"Updating {entity} {entity_id} with new UDFs...")
|
||||
put_resp = requests.put(url, headers=headers, json=existing_data)
|
||||
|
||||
if put_resp.status_code == 200:
|
||||
print(f"✅ SUCCESS: Updated {entity} {entity_id}")
|
||||
return True
|
||||
else:
|
||||
print(f"❌ ERROR updating {entity} {entity_id}: {put_resp.status_code} - {put_resp.text}")
|
||||
return False
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("--- Starting SuperOffice Injection Test ---")
|
||||
|
||||
# 1. Get Text from local DB
|
||||
subject, intro, proof = get_text_from_matrix(TEST_VERTICAL_ID, TEST_ROLE_ID)
|
||||
if not subject:
|
||||
print("❌ ERROR: Could not find matching text in local DB. Aborting.")
|
||||
exit()
|
||||
|
||||
print(f"Found texts for V_ID:{TEST_VERTICAL_ID}, R_ID:{TEST_ROLE_ID}")
|
||||
|
||||
# 2. Get API Token
|
||||
access_token = get_token()
|
||||
if not access_token:
|
||||
print("❌ ERROR: Could not get SuperOffice token. Aborting.")
|
||||
exit()
|
||||
|
||||
# 3. Prepare Payloads
|
||||
contact_payload = {
|
||||
PROG_ID_CONTACT_CHALLENGE: intro # Using intro for challenge in this demo
|
||||
}
|
||||
person_payload = {
|
||||
PROG_ID_PERSON_SUBJECT: subject,
|
||||
PROG_ID_PERSON_INTRO: intro,
|
||||
PROG_ID_PERSON_PROOF: proof
|
||||
}
|
||||
|
||||
# 4. Inject data
|
||||
update_udfs("Contact", TEST_CONTACT_ID, contact_payload, access_token)
|
||||
update_udfs("Person", TEST_PERSON_ID, person_payload, access_token)
|
||||
|
||||
print("\n--- Test complete ---")
|
||||
@@ -1,125 +0,0 @@
|
||||
import os
|
||||
import sqlite3
|
||||
import requests
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv(override=True)
|
||||
|
||||
# --- CONFIGURATION ---
|
||||
DB_FILE = "marketing_matrix.db"
|
||||
SO_CLIENT_ID = os.getenv("SO_CLIENT_ID") or os.getenv("SO_SOD")
|
||||
SO_CLIENT_SECRET = os.getenv("SO_CLIENT_SECRET")
|
||||
SO_REFRESH_TOKEN = os.getenv("SO_REFRESH_TOKEN")
|
||||
BASE_URL = "https://app-sod.superoffice.com/Cust55774/api/v1"
|
||||
|
||||
# --- SUPEROFFICE UDF ProgIds ---
|
||||
PROG_ID_CONTACT_CHALLENGE = "SuperOffice:6"
|
||||
PROG_ID_PERSON_SUBJECT = "SuperOffice:5"
|
||||
PROG_ID_PERSON_INTRO = "SuperOffice:6"
|
||||
PROG_ID_PERSON_PROOF = "SuperOffice:7"
|
||||
# Annahme: Das sind die ProgIds der Felder, die die IDs speichern
|
||||
PROG_ID_CONTACT_VERTICAL = "SuperOffice:5"
|
||||
PROG_ID_PERSON_ROLE = "SuperOffice:3" # KORRIGIERT
|
||||
|
||||
# --- TARGET DATA ---
|
||||
TARGET_PERSON_ID = 1
|
||||
TARGET_CONTACT_ID = 2
|
||||
|
||||
def get_token():
|
||||
url = "https://sod.superoffice.com/login/common/oauth/tokens"
|
||||
data = {"grant_type": "refresh_token", "client_id": SO_CLIENT_ID, "client_secret": SO_CLIENT_SECRET, "refresh_token": SO_REFRESH_TOKEN, "redirect_uri": "http://localhost"}
|
||||
resp = requests.post(url, data=data)
|
||||
return resp.json().get("access_token") if resp.status_code == 200 else None
|
||||
|
||||
def get_text_from_matrix(vertical_id, role_id):
|
||||
conn = sqlite3.connect(DB_FILE)
|
||||
c = conn.cursor()
|
||||
c.execute("SELECT subject, intro, social_proof FROM text_blocks WHERE vertical_id = ? AND role_id = ?", (vertical_id, role_id))
|
||||
row = c.fetchone()
|
||||
conn.close()
|
||||
return row if row else (None, None, None)
|
||||
|
||||
def get_entity_data(entity, entity_id, token):
|
||||
url = f"{BASE_URL}/{entity}/{entity_id}"
|
||||
headers = {"Authorization": f"Bearer {token}", "Accept": "application/json"}
|
||||
resp = requests.get(url, headers=headers)
|
||||
return resp.json() if resp.status_code == 200 else None
|
||||
|
||||
def update_udfs(entity, entity_id, payload, token):
|
||||
url = f"{BASE_URL}/{entity}/{entity_id}"
|
||||
headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json", "Accept": "application/json"}
|
||||
|
||||
existing_data = get_entity_data(entity, entity_id, token)
|
||||
if not existing_data:
|
||||
print(f"❌ ERROR fetching {entity} {entity_id}")
|
||||
return False
|
||||
|
||||
if "UserDefinedFields" not in existing_data:
|
||||
existing_data["UserDefinedFields"] = {}
|
||||
existing_data["UserDefinedFields"].update(payload)
|
||||
|
||||
print(f"Updating {entity} {entity_id} with new UDFs...")
|
||||
put_resp = requests.put(url, headers=headers, json=existing_data)
|
||||
|
||||
if put_resp.status_code == 200:
|
||||
print(f"✅ SUCCESS: Updated {entity} {entity_id}")
|
||||
return True
|
||||
else:
|
||||
print(f"❌ ERROR updating {entity} {entity_id}: {put_resp.status_code} - {put_resp.text}")
|
||||
return False
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("--- Starting SuperOffice Injection (LOGIC CORRECTED) ---")
|
||||
|
||||
# 1. Get API Token
|
||||
access_token = get_token()
|
||||
if not access_token:
|
||||
print("❌ ERROR: Could not get SuperOffice token. Aborting.")
|
||||
exit()
|
||||
|
||||
# 2. Get real data from SuperOffice
|
||||
print(f"Fetching data for Person {TARGET_PERSON_ID} and Contact {TARGET_CONTACT_ID}...")
|
||||
person_data = get_entity_data("Person", TARGET_PERSON_ID, access_token)
|
||||
contact_data = get_entity_data("Contact", TARGET_CONTACT_ID, access_token)
|
||||
|
||||
if not person_data or not contact_data:
|
||||
print("❌ ERROR: Could not fetch test entities. Aborting.")
|
||||
exit()
|
||||
|
||||
# Extract and CLEAN the IDs from the UDFs
|
||||
try:
|
||||
vertical_id_raw = contact_data["UserDefinedFields"][PROG_ID_CONTACT_VERTICAL]
|
||||
role_id_raw = person_data["UserDefinedFields"][PROG_ID_PERSON_ROLE]
|
||||
|
||||
# Clean the "[I:xx]" format to a pure integer
|
||||
vertical_id = int(vertical_id_raw.replace("[I:", "").replace("]", ""))
|
||||
role_id = int(role_id_raw.replace("[I:", "").replace("]", ""))
|
||||
|
||||
print(f"Detected Vertical ID: {vertical_id} (Raw: {vertical_id_raw}), Role ID: {role_id} (Raw: {role_id_raw})")
|
||||
except KeyError as e:
|
||||
print(f"❌ ERROR: A ProgId is wrong or the field is empty: {e}. Aborting.")
|
||||
exit()
|
||||
|
||||
# 3. Get Text from local DB
|
||||
subject, intro, proof = get_text_from_matrix(vertical_id, role_id)
|
||||
if not subject:
|
||||
print(f"❌ ERROR: Could not find matching text for V_ID:{vertical_id}, R_ID:{role_id} in local DB. Aborting.")
|
||||
exit()
|
||||
|
||||
print(f"Found texts for V_ID:{vertical_id}, R_ID:{role_id}")
|
||||
|
||||
# 4. Prepare Payloads
|
||||
contact_payload = {
|
||||
PROG_ID_CONTACT_CHALLENGE: intro
|
||||
}
|
||||
person_payload = {
|
||||
PROG_ID_PERSON_SUBJECT: subject,
|
||||
PROG_ID_PERSON_INTRO: intro,
|
||||
PROG_ID_PERSON_PROOF: proof
|
||||
}
|
||||
|
||||
# 5. Inject data
|
||||
update_udfs("Contact", TARGET_CONTACT_ID, contact_payload, access_token)
|
||||
update_udfs("Person", TARGET_PERSON_ID, person_payload, access_token)
|
||||
|
||||
print("\n--- Test complete ---")
|
||||
@@ -1,25 +0,0 @@
|
||||
import os
|
||||
import logging
|
||||
import json
|
||||
from dotenv import load_dotenv
|
||||
from auth_handler import AuthHandler
|
||||
from superoffice_client import SuperOfficeClient
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def discover_contact_structure():
|
||||
load_dotenv(dotenv_path="/home/node/clawd/.env", override=True)
|
||||
client = SuperOfficeClient()
|
||||
|
||||
logger.info("Fetching contact ID 2 to inspect structure...")
|
||||
|
||||
contact = client._get("Contact/2")
|
||||
if contact:
|
||||
print("\n--- CONTACT STRUCTURE ---")
|
||||
print(json.dumps(contact, indent=2))
|
||||
else:
|
||||
logger.error("Failed to fetch contact.")
|
||||
|
||||
if __name__ == "__main__":
|
||||
discover_contact_structure()
|
||||
@@ -1,8 +0,0 @@
|
||||
import sqlite3
|
||||
import pandas as pd
|
||||
|
||||
conn = sqlite3.connect("marketing_matrix.db")
|
||||
df = pd.read_sql_query("SELECT * FROM text_blocks", conn)
|
||||
conn.close()
|
||||
|
||||
print(df.to_markdown(index=False))
|
||||
@@ -1,23 +0,0 @@
|
||||
from dotenv import load_dotenv
|
||||
import json
|
||||
from config import Config
|
||||
from logging_config import setup_logging
|
||||
from auth_handler import AuthHandler
|
||||
from superoffice_client import SuperOfficeClient
|
||||
|
||||
logger = setup_logging("inspector")
|
||||
|
||||
def inspect_person(person_id):
|
||||
load_dotenv(dotenv_path="/home/node/clawd/.env", override=True)
|
||||
client = SuperOfficeClient()
|
||||
logger.info(f"Fetching Person with ID {person_id} to inspect structure...")
|
||||
person_data = client._get(f"Person/{person_id}")
|
||||
if person_data:
|
||||
print(f"\n--- PERSON STRUCTURE (ID: {person_id}) ---")
|
||||
print(json.dumps(person_data, indent=2))
|
||||
else:
|
||||
logger.error(f"Failed to fetch person data.")
|
||||
|
||||
if __name__ == "__main__":
|
||||
target_person_id = 9
|
||||
inspect_person(target_person_id)
|
||||
@@ -1,13 +0,0 @@
|
||||
import sqlite3
|
||||
|
||||
conn = sqlite3.connect("marketing_matrix.db")
|
||||
c = conn.cursor()
|
||||
|
||||
print(f"{'V_ID':<5} | {'R_ID':<5} | {'SUBJECT':<30} | {'INTRO':<30}")
|
||||
print("-" * 80)
|
||||
|
||||
for row in c.execute("SELECT * FROM text_blocks"):
|
||||
v_id, r_id, subj, intro, proof = row
|
||||
print(f"{v_id:<5} | {r_id:<5} | {subj:<30} | {intro:<30}")
|
||||
|
||||
conn.close()
|
||||
@@ -1,42 +0,0 @@
|
||||
import logging
|
||||
import os
|
||||
from logging.handlers import RotatingFileHandler
|
||||
|
||||
def setup_logging(name="connector", log_level=logging.INFO):
|
||||
"""
|
||||
Sets up a robust logging configuration.
|
||||
Logs to console and to a rotating file.
|
||||
"""
|
||||
# Create logs directory if it doesn't exist
|
||||
log_dir = "logs"
|
||||
if not os.path.exists(log_dir):
|
||||
os.makedirs(log_dir)
|
||||
|
||||
logger = logging.getLogger(name)
|
||||
logger.setLevel(log_level)
|
||||
|
||||
# Avoid duplicate handlers if setup is called multiple times
|
||||
if logger.handlers:
|
||||
return logger
|
||||
|
||||
# Formatter
|
||||
formatter = logging.Formatter(
|
||||
'%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
||||
datefmt='%Y-%m-%d %H:%M:%S'
|
||||
)
|
||||
|
||||
# Console Handler
|
||||
console_handler = logging.StreamHandler()
|
||||
console_handler.setFormatter(formatter)
|
||||
logger.addHandler(console_handler)
|
||||
|
||||
# File Handler (Rotating: 5MB size, keep last 3 files)
|
||||
file_handler = RotatingFileHandler(
|
||||
os.path.join(log_dir, f"{name}.log"),
|
||||
maxBytes=5*1024*1024,
|
||||
backupCount=3
|
||||
)
|
||||
file_handler.setFormatter(formatter)
|
||||
logger.addHandler(file_handler)
|
||||
|
||||
return logger
|
||||
@@ -1,167 +0,0 @@
|
||||
import os
|
||||
import logging
|
||||
from auth_handler import AuthHandler
|
||||
from superoffice_client import SuperOfficeClient
|
||||
# from explorer_client import CompanyExplorerClient
|
||||
from logging_config import setup_logging
|
||||
|
||||
# Use the centralized logging configuration
|
||||
logger = setup_logging(__name__)
|
||||
|
||||
def main():
|
||||
# Note: Environment loading is now handled by config.py/helpers implicitly when clients are initialized
|
||||
|
||||
logger.info("Starting SuperOffice Connector S2S POC...")
|
||||
|
||||
try:
|
||||
# Initialize Auth
|
||||
auth = AuthHandler()
|
||||
|
||||
# Initialize Client
|
||||
client = SuperOfficeClient()
|
||||
|
||||
# TODO: Initialize Explorer Client when explorer_client.py is implemented
|
||||
# ce_client = CompanyExplorerClient()
|
||||
|
||||
# 1. Test Connection
|
||||
logger.info("Step 1: Testing connection...")
|
||||
user_info = client._get("Associate/Me")
|
||||
if user_info:
|
||||
logger.info(f"Connected successfully as: {user_info.get('FullName')}")
|
||||
else:
|
||||
logger.warning("Connection test for Associate/Me failed, but continuing with other tests...")
|
||||
|
||||
# TODO: Test Company Explorer Connection when explorer_client.py is implemented
|
||||
# logger.info("Step 1b: Testing Company Explorer connection...")
|
||||
# if ce_client.check_health():
|
||||
# logger.info("Company Explorer is reachable.")
|
||||
# else:
|
||||
# logger.warning("Company Explorer is NOT reachable. Sync might fail.")
|
||||
|
||||
# 2. Search for our demo company
|
||||
demo_company_name = "Gemini Test Company [2ff88f42]"
|
||||
logger.info(f"Step 2: Searching for company '{demo_company_name}'...")
|
||||
contact = client.find_contact_by_criteria(name=demo_company_name)
|
||||
|
||||
target_contact_id = None
|
||||
|
||||
if contact:
|
||||
target_contact_id = contact.get('contactId')
|
||||
logger.info(f"Found existing demo company: {contact.get('nameDepartment')} (ID: {target_contact_id})")
|
||||
else:
|
||||
logger.info(f"Demo company not found. Creating new one...")
|
||||
demo_company_url = "https://www.gemini-test-company.com"
|
||||
demo_company_orgnr = "DE123456789"
|
||||
|
||||
new_contact = client.create_contact(
|
||||
name=demo_company_name,
|
||||
url=demo_company_url,
|
||||
org_nr=demo_company_orgnr
|
||||
)
|
||||
if new_contact:
|
||||
target_contact_id = new_contact.get('ContactId')
|
||||
logger.info(f"Created new demo company with ID: {target_contact_id}")
|
||||
|
||||
# 3. Create a Person linked to this company
|
||||
if target_contact_id:
|
||||
logger.info(f"Step 3: Creating Person for Contact ID {target_contact_id}...")
|
||||
|
||||
# Create Max Mustermann
|
||||
person = client.create_person(
|
||||
first_name="Max",
|
||||
last_name="Mustermann",
|
||||
contact_id=target_contact_id,
|
||||
email="max.mustermann@gemini-test.com"
|
||||
)
|
||||
|
||||
if person:
|
||||
logger.info("SUCCESS: Person created!")
|
||||
person_id = person.get('PersonId')
|
||||
logger.info(f"Name: {person.get('Firstname')} {person.get('Lastname')}")
|
||||
logger.info(f"Person ID: {person_id}")
|
||||
logger.info(f"Linked to Contact ID: {person.get('Contact').get('ContactId')}")
|
||||
|
||||
# 4. Create a Sale for this company
|
||||
logger.info(f"Step 4: Creating Sale for Contact ID {target_contact_id}...")
|
||||
sale = client.create_sale(
|
||||
title=f"Robotics Automation Opportunity - {demo_company_name}",
|
||||
contact_id=target_contact_id,
|
||||
person_id=person_id,
|
||||
amount=50000.0
|
||||
)
|
||||
if sale:
|
||||
logger.info("SUCCESS: Sale created!")
|
||||
logger.info(f"Sale Title: {sale.get('Heading')}")
|
||||
logger.info(f"Sale ID: {sale.get('SaleId')}")
|
||||
else:
|
||||
logger.error("Failed to create sale.")
|
||||
|
||||
# 5. Create a Project for this company and add the person to it
|
||||
logger.info(f"Step 5: Creating Project for Contact ID {target_contact_id} and adding Person ID {person_id}...")
|
||||
project = client.create_project(
|
||||
name=f"Marketing Campaign Q1 [2ff88f42]",
|
||||
contact_id=target_contact_id,
|
||||
person_id=person_id
|
||||
)
|
||||
if project:
|
||||
logger.info("SUCCESS: Project created and person added!")
|
||||
logger.info(f"Project Name: {project.get('Name')}")
|
||||
logger.info(f"Project ID: {project.get('ProjectId')}")
|
||||
|
||||
# 6. Update Contact UDFs
|
||||
logger.info(f"Step 6: Updating Contact UDFs for Contact ID {target_contact_id}...")
|
||||
contact_udf_data = {
|
||||
"ai_challenge_sentence": "The company faces challenges in automating its logistics processes due to complex infrastructure.",
|
||||
"ai_sentence_timestamp": "2026-02-10T12:00:00Z", # Using a fixed timestamp for demo
|
||||
"ai_sentence_source_hash": "website_v1_hash_abc",
|
||||
"ai_last_outreach_date": "2026-02-10T12:00:00Z" # Using a fixed timestamp for demo
|
||||
}
|
||||
updated_contact = client.update_entity_udfs(target_contact_id, "Contact", contact_udf_data)
|
||||
if updated_contact:
|
||||
logger.info("SUCCESS: Contact UDFs updated!")
|
||||
else:
|
||||
logger.error("Failed to update Contact UDFs.")
|
||||
|
||||
# 7. Update Person UDFs
|
||||
logger.info(f"Step 7: Updating Person UDFs for Person ID {person_id}...")
|
||||
person_udf_data = {
|
||||
"ai_email_draft": "This is a short draft for the personalized email.", # Placeholder, as it's currently a short text field
|
||||
"ma_status": "Ready_to_Send"
|
||||
}
|
||||
updated_person = client.update_entity_udfs(person_id, "Person", person_udf_data)
|
||||
if updated_person:
|
||||
logger.info("SUCCESS: Person UDFs updated!")
|
||||
else:
|
||||
logger.error("Failed to update Person UDFs.")
|
||||
|
||||
# TODO: Sync to Company Explorer when explorer_client.py is implemented
|
||||
# if updated_contact:
|
||||
# logger.info(f"Step 9: Syncing Company to Company Explorer...")
|
||||
# ce_payload = {
|
||||
# "name": updated_contact.get("Name"),
|
||||
# "website": updated_contact.get("UrlAddress"),
|
||||
# "city": updated_contact.get("City"),
|
||||
# "country": "DE" # Defaulting to DE for now
|
||||
# }
|
||||
|
||||
# ce_result = ce_client.import_company(ce_payload)
|
||||
# if ce_result:
|
||||
# logger.info(f"SUCCESS: Company synced to Explorer! ID: {ce_result.get('id')}")
|
||||
# else:
|
||||
# logger.error("Failed to sync company to Explorer.")
|
||||
# else:
|
||||
# logger.warning("Skipping CE sync because contact update failed or contact object is missing.")
|
||||
|
||||
else:
|
||||
logger.error("Failed to create project.")
|
||||
|
||||
else:
|
||||
logger.error("Failed to create person.")
|
||||
else:
|
||||
logger.error("Skipping person creation because company could not be found or created.")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"An error occurred: {e}", exc_info=True)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,82 +0,0 @@
|
||||
import re
|
||||
|
||||
def normalize_persona(title: str) -> str:
|
||||
"""
|
||||
Normalisiert rohe Jobtitel auf die 4 RoboPlanet-Personas.
|
||||
Rückgabe: Persona-ID (z.B. 'PERSONA_A_OPS') oder 'MANUAL_CHECK'.
|
||||
"""
|
||||
if not title:
|
||||
return "MANUAL_CHECK"
|
||||
|
||||
t = title.lower()
|
||||
|
||||
# 1. HARD EXCLUDES (Kosten sparen / Irrelevanz)
|
||||
blacklist = [
|
||||
"praktikant", "intern", "student", "assistenz", "assistant",
|
||||
"werkstudent", "azubi", "auszubildende", "secretary", "sekretär"
|
||||
]
|
||||
if any(x in t for x in blacklist):
|
||||
return "IGNORE"
|
||||
|
||||
# 2. HIERARCHISCHE LOGIK (Specialist > Generalist)
|
||||
|
||||
# Persona D: Visionary (Innovations-Treiber)
|
||||
# Trigger: ESG, Digitalisierung, Transformation
|
||||
keywords_d = [
|
||||
"sustainability", "esg", "umwelt", "digital", "innovation",
|
||||
"transformation", "csr", "strategy", "strategie", "future"
|
||||
]
|
||||
if any(x in t for x in keywords_d):
|
||||
return "PERSONA_D_VISIONARY"
|
||||
|
||||
# Persona B: FM / Infra (Infrastruktur-Verantwortlicher)
|
||||
# Trigger: Facility, Technik, Immobilien, Bau
|
||||
keywords_b = [
|
||||
"facility", "fm", "objekt", "immobilie", "technisch", "technik",
|
||||
"instandhaltung", "real estate", "maintenance", "haushandwerker",
|
||||
"building", "property", "bau", "infrastructure"
|
||||
]
|
||||
if any(x in t for x in keywords_b):
|
||||
return "PERSONA_B_FM"
|
||||
|
||||
# Persona A: Ops (Operativer Entscheider - Q1 Fokus!)
|
||||
# Trigger: Logistik, Lager, Supply Chain, Produktion, Operations
|
||||
keywords_a = [
|
||||
"logistik", "lager", "supply", "operat", "versand", "warehouse",
|
||||
"fuhrpark", "site manager", "verkehr", "dispatch", "fertigung",
|
||||
"produktion", "production", "plant", "werk", "standortleiter",
|
||||
"branch manager", "niederlassungsleiter"
|
||||
]
|
||||
if any(x in t for x in keywords_a):
|
||||
return "PERSONA_A_OPS"
|
||||
|
||||
# Persona C: Economic / Boss (Wirtschaftlicher Entscheider)
|
||||
# Trigger: C-Level, GF, Finance (wenn keine spezifischere Rolle greift)
|
||||
keywords_c = [
|
||||
"gf", "geschäftsführer", "ceo", "cfo", "finance", "finanz",
|
||||
"vorstand", "prokurist", "owner", "inhaber", "founder", "gründer",
|
||||
"managing director", "general manager"
|
||||
]
|
||||
if any(x in t for x in keywords_c):
|
||||
return "PERSONA_C_ECON"
|
||||
|
||||
# Fallback
|
||||
return "MANUAL_CHECK"
|
||||
|
||||
# Test-Cases (nur bei direkter Ausführung)
|
||||
if __name__ == "__main__":
|
||||
test_titles = [
|
||||
"Head of Supply Chain Management",
|
||||
"Technischer Leiter Facility",
|
||||
"Geschäftsführer",
|
||||
"Director Sustainability",
|
||||
"Praktikant Marketing",
|
||||
"Teamleiter Fuhrpark",
|
||||
"Hausmeister",
|
||||
"Kaufmännischer Leiter"
|
||||
]
|
||||
|
||||
print(f"{'TITLE':<40} | {'PERSONA'}")
|
||||
print("-" * 60)
|
||||
for title in test_titles:
|
||||
print(f"{title:<40} | {normalize_persona(title)}")
|
||||
@@ -1,26 +0,0 @@
|
||||
import requests
|
||||
import json
|
||||
from config import Config
|
||||
from logging_config import setup_logging
|
||||
|
||||
logger = setup_logging("ce_parser")
|
||||
|
||||
def parse_openapi():
|
||||
# Use CE IP directly for this local tool
|
||||
url = "http://172.17.0.1:8000/openapi.json"
|
||||
auth = (Config.CE_API_USER, Config.CE_API_PASSWORD)
|
||||
try:
|
||||
resp = requests.get(url, auth=auth, timeout=5)
|
||||
resp.raise_for_status()
|
||||
spec = resp.json()
|
||||
schemas = spec.get("components", {}).get("schemas", {})
|
||||
target_schemas = ["CompanyCreate", "BulkImportRequest"]
|
||||
for schema_name in target_schemas:
|
||||
if schema_name in schemas:
|
||||
print(f"\nSchema: {schema_name}")
|
||||
print(json.dumps(schemas[schema_name], indent=2))
|
||||
except Exception as e:
|
||||
logger.error(f"Error parsing CE OpenAPI: {e}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
parse_openapi()
|
||||
@@ -1,157 +0,0 @@
|
||||
import os
|
||||
import sqlite3
|
||||
import hashlib
|
||||
import time
|
||||
from datetime import datetime
|
||||
import pytz
|
||||
from superoffice_client import SuperOfficeClient
|
||||
from build_matrix import get_vertical_pains_gains, generate_text # Reuse logic
|
||||
|
||||
# --- CONFIGURATION ---
|
||||
DB_FILE_MATRIX = "marketing_matrix.db"
|
||||
DB_FILE_STATE = "processing_state.db"
|
||||
POLLING_INTERVAL_SECONDS = 900
|
||||
BUSINESS_TZ = pytz.timezone("Europe/Berlin")
|
||||
|
||||
PROG_ID_CONTACT_VERTICAL = "SuperOffice:5"
|
||||
PROG_ID_PERSON_ROLE = "SuperOffice:3"
|
||||
PROG_ID_CONTACT_CHALLENGE = "SuperOffice:6"
|
||||
PROG_ID_PERSON_SUBJECT = "SuperOffice:5"
|
||||
PROG_ID_PERSON_INTRO = "SuperOffice:6"
|
||||
PROG_ID_PERSON_PROOF = "SuperOffice:7"
|
||||
PROG_ID_PERSON_HASH = "SuperOffice:8"
|
||||
|
||||
# Mappings (would be better in a config file)
|
||||
VERTICAL_MAP = {
|
||||
23: "Logistics - Warehouse",
|
||||
24: "Healthcare - Hospital",
|
||||
25: "Infrastructure - Transport",
|
||||
26: "Leisure - Indoor Active"
|
||||
}
|
||||
ROLE_MAP = {
|
||||
19: {"name": "Operativer Entscheider", "pains": "..."},
|
||||
20: {"name": "Infrastruktur-Verantwortlicher", "pains": "..."},
|
||||
21: {"name": "Wirtschaftlicher Entscheider", "pains": "..."},
|
||||
22: {"name": "Innovations-Treiber", "pains": "..."}
|
||||
}
|
||||
|
||||
# --- DATABASE & STATE ---
|
||||
def init_state_db():
|
||||
# ... (same as before)
|
||||
pass
|
||||
|
||||
def process_and_update_person(client: SuperOfficeClient, person_id: int, vertical_id: int, role_id: int):
|
||||
print(f" -> Processing Person ID: {person_id} for V:{vertical_id}/R:{role_id}")
|
||||
|
||||
vertical_name = VERTICAL_MAP.get(vertical_id)
|
||||
role_data = ROLE_MAP.get(role_id)
|
||||
if not vertical_name or not role_data:
|
||||
raise ValueError("Vertical or Role ID not in mapping.")
|
||||
|
||||
v_data = get_vertical_pains_gains(vertical_name)
|
||||
|
||||
# Check if text already exists in matrix
|
||||
conn = sqlite3.connect(DB_FILE_MATRIX)
|
||||
c = conn.cursor()
|
||||
c.execute("SELECT subject, intro, social_proof FROM text_blocks WHERE vertical_id = ? AND role_id = ?", (vertical_id, role_id))
|
||||
row = c.fetchone()
|
||||
if not row:
|
||||
# If not, generate it on the fly
|
||||
print(" -> Text not in matrix, generating live...")
|
||||
text_block = generate_text(vertical_name, v_data, role_id, role_data)
|
||||
if not text_block:
|
||||
raise Exception("Failed to generate text block from Gemini.")
|
||||
|
||||
# Save to matrix for future use
|
||||
subj, intro, proof = text_block['Subject'][:40], text_block['Intro'][:40], text_block['SocialProof'][:40]
|
||||
c.execute("INSERT OR REPLACE INTO text_blocks VALUES (?, ?, ?, ?, ?)", (vertical_id, role_id, subj, intro, proof))
|
||||
conn.commit()
|
||||
else:
|
||||
subj, intro, proof = row
|
||||
|
||||
conn.close()
|
||||
|
||||
# Generate Hash
|
||||
copy_hash = hashlib.md5(f"{subj}{intro}{proof}".encode()).hexdigest()
|
||||
|
||||
# Prepare Payloads
|
||||
contact_payload = {PROG_ID_CONTACT_CHALLENGE: intro}
|
||||
person_payload = {
|
||||
PROG_ID_PERSON_SUBJECT: subj,
|
||||
PROG_ID_PERSON_INTRO: intro,
|
||||
PROG_ID_PERSON_PROOF: proof,
|
||||
PROG_ID_PERSON_HASH: copy_hash
|
||||
}
|
||||
|
||||
# Inject data
|
||||
person_data = client.get_person(person_id)
|
||||
contact_id = person_data.get('contact', {}).get('contactId')
|
||||
|
||||
client.update_udfs("Contact", contact_id, contact_payload)
|
||||
client.update_udfs("Person", person_id, person_payload)
|
||||
|
||||
return copy_hash
|
||||
|
||||
# --- POLLING DAEMON ---
|
||||
def poll_for_changes(client: SuperOfficeClient, last_run_utc: str):
|
||||
print(f"Polling for persons modified since {last_run_utc}...")
|
||||
|
||||
select = "personId,contact/contactId,userDefinedFields,lastModified"
|
||||
filter = f"lastModified gt '{last_run_utc}'"
|
||||
|
||||
updated_persons = client.search(f"Person?$select={select}&$filter={filter}")
|
||||
if not updated_persons:
|
||||
print("No persons updated.")
|
||||
return
|
||||
|
||||
print(f"Found {len(updated_persons)} updated persons.")
|
||||
conn_state = sqlite3.connect(DB_FILE_STATE)
|
||||
c_state = conn_state.cursor()
|
||||
|
||||
for person in updated_persons:
|
||||
person_id = person.get('personId')
|
||||
try:
|
||||
udfs = person.get('UserDefinedFields', {})
|
||||
contact_id = person.get('contact', {}).get('contactId')
|
||||
if not contact_id: continue
|
||||
|
||||
contact_data = client.get_contact(contact_id)
|
||||
if not contact_data: continue
|
||||
|
||||
vertical_id_raw = contact_data["UserDefinedFields"].get(PROG_ID_CONTACT_VERTICAL, "")
|
||||
role_id_raw = udfs.get(PROG_ID_PERSON_ROLE, "")
|
||||
|
||||
if not vertical_id_raw or not role_id_raw: continue
|
||||
|
||||
vertical_id = int(vertical_id_raw.replace("[I:", "").replace("]", ""))
|
||||
role_id = int(role_id_raw.replace("[I:", "").replace("]", ""))
|
||||
|
||||
expected_hash = hashlib.md5(f"{vertical_id}-{role_id}".encode()).hexdigest()
|
||||
|
||||
c_state.execute("SELECT last_known_hash FROM person_state WHERE person_id = ?", (person_id,))
|
||||
result = c_state.fetchone()
|
||||
last_known_hash = result[0] if result else None
|
||||
|
||||
if expected_hash != last_known_hash:
|
||||
new_copy_hash = process_and_update_person(client, person_id, vertical_id, role_id)
|
||||
|
||||
c_state.execute("INSERT OR REPLACE INTO person_state VALUES (?, ?, ?)",
|
||||
(person_id, expected_hash, datetime.utcnow().isoformat()))
|
||||
conn_state.commit()
|
||||
else:
|
||||
print(f" - Skipping Person {person_id}: No relevant change (V/R hash unchanged).")
|
||||
|
||||
except Exception as e:
|
||||
print(f" - ❌ Error on Person {person_id}: {e}")
|
||||
|
||||
conn_state.close()
|
||||
|
||||
def main():
|
||||
# ... (main loop from before, but simplified) ...
|
||||
# Needs full implementation
|
||||
pass
|
||||
|
||||
if __name__ == '__main__':
|
||||
# Full script would need pip install pytz flask
|
||||
print("This is the final blueprint for the polling daemon.")
|
||||
# You would start the main() loop here.
|
||||
@@ -1,147 +0,0 @@
|
||||
import os
|
||||
import sqlite3
|
||||
import hashlib
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from superoffice_client import SuperOfficeClient
|
||||
|
||||
# --- CONFIGURATION ---
|
||||
DB_FILE_MATRIX = "marketing_matrix.db"
|
||||
DB_FILE_STATE = "processing_state.db"
|
||||
POLLING_INTERVAL_SECONDS = 300 # 5 minutes
|
||||
|
||||
# UDF ProgIds
|
||||
PROG_ID_CONTACT_VERTICAL = "SuperOffice:5"
|
||||
PROG_ID_PERSON_ROLE = "SuperOffice:3"
|
||||
PROG_ID_CONTACT_CHALLENGE = "SuperOffice:6"
|
||||
PROG_ID_PERSON_SUBJECT = "SuperOffice:5"
|
||||
PROG_ID_PERSON_INTRO = "SuperOffice:6"
|
||||
PROG_ID_PERSON_PROOF = "SuperOffice:7"
|
||||
PROG_ID_PERSON_HASH = "SuperOffice:8"
|
||||
|
||||
# --- DATABASE SETUP ---
|
||||
def init_state_db():
|
||||
conn = sqlite3.connect(DB_FILE_STATE)
|
||||
c = conn.cursor()
|
||||
# Stores the last known hash for a person to detect changes
|
||||
c.execute('''CREATE TABLE IF NOT EXISTS person_state
|
||||
(person_id INTEGER PRIMARY KEY,
|
||||
last_known_hash TEXT,
|
||||
last_updated TEXT)''')
|
||||
# Stores the timestamp of the last run
|
||||
c.execute('''CREATE TABLE IF NOT EXISTS system_state
|
||||
(key TEXT PRIMARY KEY, value TEXT)''')
|
||||
c.execute("INSERT OR IGNORE INTO system_state VALUES ('last_run_utc', ?)", (datetime.utcnow().isoformat(),))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
print("✅ State DB initialized.")
|
||||
|
||||
# --- CORE LOGIC ---
|
||||
def get_text_from_matrix(vertical_id, role_id):
|
||||
# (Same as in webhook_server)
|
||||
# ... (omitted for brevity, will be imported)
|
||||
pass
|
||||
|
||||
def process_person(client: SuperOfficeClient, person_id: int):
|
||||
# (Central logic from webhook_server, adapted slightly)
|
||||
# ... (omitted for brevity, will be imported/reused)
|
||||
pass
|
||||
|
||||
def poll_for_changes(client: SuperOfficeClient, last_run_utc: str):
|
||||
print(f"Polling for persons modified since {last_run_utc}...")
|
||||
|
||||
# API Query: Get recently updated persons
|
||||
# We select the fields we need to minimize payload
|
||||
select_fields = "personId,contact/contactId,userDefinedFields"
|
||||
filter_query = f"lastModified gt '{last_run_utc}'"
|
||||
|
||||
# In a real scenario, you'd handle paging for many results
|
||||
recently_updated_persons = client.search(f"Person?$select={select_fields}&$filter={filter_query}")
|
||||
|
||||
if not recently_updated_persons:
|
||||
print("No persons updated since last run.")
|
||||
return
|
||||
|
||||
print(f"Found {len(recently_updated_persons)} updated persons to check.")
|
||||
conn = sqlite3.connect(DB_FILE_STATE)
|
||||
c = conn.cursor()
|
||||
|
||||
for person in recently_updated_persons:
|
||||
person_id = person.get('personId')
|
||||
|
||||
try:
|
||||
# 1. Get current state from SuperOffice
|
||||
udfs = person.get('UserDefinedFields', {})
|
||||
vertical_id_raw = client.get_contact(person['contact']['contactId'])["UserDefinedFields"].get(PROG_ID_CONTACT_VERTICAL, "")
|
||||
role_id_raw = udfs.get(PROG_ID_PERSON_ROLE, "")
|
||||
|
||||
if not vertical_id_raw or not role_id_raw:
|
||||
print(f" - Skipping Person {person_id}: Missing Vertical/Role ID.")
|
||||
continue
|
||||
|
||||
vertical_id = int(vertical_id_raw.replace("[I:", "").replace("]", ""))
|
||||
role_id = int(role_id_raw.replace("[I:", "").replace("]", ""))
|
||||
|
||||
# 2. Generate the "expected" hash
|
||||
expected_hash = hashlib.md5(f"{vertical_id}-{role_id}".encode()).hexdigest()
|
||||
|
||||
# 3. Get last known hash from our state DB
|
||||
c.execute("SELECT last_known_hash FROM person_state WHERE person_id = ?", (person_id,))
|
||||
result = c.fetchone()
|
||||
last_known_hash = result[0] if result else None
|
||||
|
||||
# 4. Compare and act
|
||||
if expected_hash != last_known_hash:
|
||||
print(f" -> Change detected for Person {person_id} (New state: V:{vertical_id}/R:{role_id}). Processing...")
|
||||
|
||||
# Here we would call the full processing logic from webhook_server.py
|
||||
# For now, we simulate the update and save the new state.
|
||||
# process_single_person(client, person_id) # This would be the real call
|
||||
|
||||
# Update our state DB
|
||||
c.execute("INSERT OR REPLACE INTO person_state VALUES (?, ?, ?)",
|
||||
(person_id, expected_hash, datetime.utcnow().isoformat()))
|
||||
conn.commit()
|
||||
print(f" ✅ Processed and updated state for Person {person_id}.")
|
||||
else:
|
||||
print(f" - Skipping Person {person_id}: No relevant change detected (hash is the same).")
|
||||
|
||||
except Exception as e:
|
||||
print(f" - ❌ Error processing Person {person_id}: {e}")
|
||||
|
||||
conn.close()
|
||||
|
||||
# --- MAIN DAEMON LOOP ---
|
||||
def main():
|
||||
init_state_db()
|
||||
|
||||
try:
|
||||
client = SuperOfficeClient()
|
||||
except Exception as e:
|
||||
print(f"Could not start daemon: {e}")
|
||||
return
|
||||
|
||||
while True:
|
||||
conn = sqlite3.connect(DB_FILE_STATE)
|
||||
c = conn.cursor()
|
||||
c.execute("SELECT value FROM system_state WHERE key = 'last_run_utc'")
|
||||
last_run = c.fetchone()[0]
|
||||
|
||||
# Poll for changes
|
||||
poll_for_changes(client, last_run)
|
||||
|
||||
# Update last run time
|
||||
new_last_run = datetime.utcnow().isoformat()
|
||||
c.execute("UPDATE system_state SET value = ? WHERE key = 'last_run_utc'", (new_last_run,))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
print(f"\nPolling complete. Next run in {POLLING_INTERVAL_SECONDS} seconds...")
|
||||
time.sleep(POLLING_INTERVAL_SECONDS)
|
||||
|
||||
if __name__ == '__main__':
|
||||
# This is a conceptual sketch.
|
||||
# The SuperOfficeClient needs a `search` method.
|
||||
# The logic from webhook_server needs to be callable.
|
||||
print("This script is a blueprint for the polling daemon.")
|
||||
print("It requires a `search` method in the SuperOfficeClient and refactoring.")
|
||||
@@ -1,28 +0,0 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCzOeR7FtJjnT9/
|
||||
WwecjWz4f+1qal3DgVmcGlWVg6iqa2cEPlKxQF/4+f9a0PsXzCr+siD/dLbSItc8
|
||||
Dtj3lCGrKdJjR3dzc0ruHpfqQtPhjyHRauBOmqa4OpjuVSxpb/FIJh8mcyMrc+HK
|
||||
dF1mSGbDW/7bIEjA7iAyrHNkonoOeXp0O4q74UzglyNKypCgAmx46UUSaNCDwaOH
|
||||
f0Dl9yI6M/X9T9MVGRm/5y3AGM0rQWSwpGTR6qzMQnoswPPPj4mZWuWmFXaj32We
|
||||
X73KxjMljCJ9Cfy1Sq0tdbFS/XeU9hZ70+3xWX4QDkMEqAhoIAW3PIOWTYwCCWc4
|
||||
ukuUXjzrAgMBAAECggEAUum45CLCMPhJrFLB+jBJFcsk2+KaPvxDpt5d8n3GlRR7
|
||||
w3BLlBmabJXHBs4AI1m+GDby4gsuGpeop+2cfSinzMXbwTcKMTxIkVFQ6TyCReqP
|
||||
9BAz9dlAwKDHKBb6JUr2vfB437JLNmp1LdJYdR2QgNc510ifr7VZ6udxuMAbpD7R
|
||||
WGjqqeee3XK4+1GxzAAC6qerhBRWDa89Qo+tf7U/Fm23mOicbG+uyiKra8rV79eW
|
||||
AXpK1Xqk9BIurwl+b+tlUicgZhirHeV8FJ5wdSOJNUytwRQSkk5ntXtZPxdGHx5P
|
||||
rvlvhRLQEOMR9gJNv7bpQKzDxqfr+uWF7vFub50qpQKBgQD3utl92eaKjnRtCmdx
|
||||
OTSLXP1IOEorR7OOJ+DLDtoq7Qu+Unt8PT0PmRfwY2PS1/dZxHCpZYr0Q/zB+5AQ
|
||||
OMlByY2VQd2F1EOMZArllqRZFjuSyLsz2lYe9Op46Bas+yCVXOK4Pv/iFB7IQXLO
|
||||
K7HTWEIccaHZ1Jna+XkrVTOS/QKBgQC5NZiNwHflgDqcxKKPIqGpEjTlw876ZcBv
|
||||
j+8udVMgJXGgbVYmU0ru1WVUfEnUZzqTOoXyGSC0xebU1eRfXaEJn6CB1OUPUUYA
|
||||
cqrkvv9I3RAissbU2+jdeWuhb71V160YNKMkx5iEkW4KACgNm1YPjX+dfMzOiYKK
|
||||
BKtDoBuYBwKBgFcCD2V+ZNSBWC78GnzP5L6V+HenHZW55zykkPWAz+uHujosaiam
|
||||
s42I7bmGjwb8x2mF7zPv8C/+uQXAv0aTS0yJ5+pmadGZTeg/MvyUPkDz6BST3/xE
|
||||
UT8qMjgo+93hjf4n05F2vxS+kFkxc4sqGZjrRL0MxBXn7+nS+VXY5PZZAoGAKFgo
|
||||
dxhqBbA9FFExKATfOjkhFLvmplzr4mF0NKaSCPqfGdc3YPnb5NLPU+wPGRmzhMbG
|
||||
zsnyee5yLgK50JxQrAv9psp9ayzFFuvjlhiU+4ZMMYLIFS4iN7xvWadBkyV8Kz2s
|
||||
HCLuclJLqhoGn5Aq2xBzsBazdno12WLS+9QwrpkCgYEA3K/CCDxRLnL4jf0drpmM
|
||||
g48k7NQlADJQRK71RkGHmPWENYTTyRsRFTyG3swKBWNu9033v5t0pi2y98S6s4/J
|
||||
3cUlmZJeiBekj03FbsiQAq4pkZ75ipAXAZuO3vZd1M3M7qHqLZtASFt4YvHfvhky
|
||||
Ai4mj+qNKxcfR8OgYWSWySA=
|
||||
-----END PRIVATE KEY-----
|
||||
@@ -1,9 +0,0 @@
|
||||
-----BEGIN PUBLIC KEY-----
|
||||
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAsznkexbSY50/f1sHnI1s
|
||||
+H/tampdw4FZnBpVlYOoqmtnBD5SsUBf+Pn/WtD7F8wq/rIg/3S20iLXPA7Y95Qh
|
||||
qynSY0d3c3NK7h6X6kLT4Y8h0WrgTpqmuDqY7lUsaW/xSCYfJnMjK3PhynRdZkhm
|
||||
w1v+2yBIwO4gMqxzZKJ6Dnl6dDuKu+FM4JcjSsqQoAJseOlFEmjQg8Gjh39A5fci
|
||||
OjP1/U/TFRkZv+ctwBjNK0FksKRk0eqszEJ6LMDzz4+JmVrlphV2o99lnl+9ysYz
|
||||
JYwifQn8tUqtLXWxUv13lPYWe9Pt8Vl+EA5DBKgIaCAFtzyDlk2MAglnOLpLlF48
|
||||
6wIDAQAB
|
||||
-----END PUBLIC KEY-----
|
||||
@@ -1,106 +0,0 @@
|
||||
import sqlite3
|
||||
import json
|
||||
from datetime import datetime, timedelta
|
||||
import os
|
||||
|
||||
DB_PATH = os.getenv("DB_PATH", "connector_queue.db")
|
||||
|
||||
class JobQueue:
|
||||
def __init__(self):
|
||||
self._init_db()
|
||||
|
||||
def _init_db(self):
|
||||
with sqlite3.connect(DB_PATH) as conn:
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS jobs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
event_type TEXT,
|
||||
payload TEXT,
|
||||
status TEXT DEFAULT 'PENDING',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
error_msg TEXT,
|
||||
next_try_at TIMESTAMP
|
||||
)
|
||||
""")
|
||||
# Migration for existing DBs
|
||||
try:
|
||||
conn.execute("ALTER TABLE jobs ADD COLUMN next_try_at TIMESTAMP")
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
|
||||
def add_job(self, event_type: str, payload: dict):
|
||||
with sqlite3.connect(DB_PATH) as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO jobs (event_type, payload, status) VALUES (?, ?, ?)",
|
||||
(event_type, json.dumps(payload), 'PENDING')
|
||||
)
|
||||
|
||||
def get_next_job(self):
|
||||
"""
|
||||
Atomically fetches the next pending job where next_try_at is reached.
|
||||
"""
|
||||
job = None
|
||||
with sqlite3.connect(DB_PATH) as conn:
|
||||
conn.row_factory = sqlite3.Row
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Lock the job
|
||||
cursor.execute("BEGIN EXCLUSIVE")
|
||||
try:
|
||||
cursor.execute("""
|
||||
SELECT id, event_type, payload, created_at
|
||||
FROM jobs
|
||||
WHERE status = 'PENDING'
|
||||
AND (next_try_at IS NULL OR next_try_at <= datetime('now'))
|
||||
ORDER BY created_at ASC
|
||||
LIMIT 1
|
||||
""")
|
||||
row = cursor.fetchone()
|
||||
|
||||
if row:
|
||||
job = dict(row)
|
||||
# Mark as processing
|
||||
cursor.execute(
|
||||
"UPDATE jobs SET status = 'PROCESSING', updated_at = datetime('now') WHERE id = ?",
|
||||
(job['id'],)
|
||||
)
|
||||
conn.commit()
|
||||
else:
|
||||
conn.rollback() # No job found
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
|
||||
if job:
|
||||
job['payload'] = json.loads(job['payload'])
|
||||
|
||||
return job
|
||||
|
||||
def retry_job_later(self, job_id, delay_seconds=60):
|
||||
next_try = datetime.utcnow() + timedelta(seconds=delay_seconds)
|
||||
with sqlite3.connect(DB_PATH) as conn:
|
||||
conn.execute(
|
||||
"UPDATE jobs SET status = 'PENDING', next_try_at = ?, updated_at = datetime('now') WHERE id = ?",
|
||||
(next_try, job_id)
|
||||
)
|
||||
|
||||
def complete_job(self, job_id):
|
||||
with sqlite3.connect(DB_PATH) as conn:
|
||||
conn.execute(
|
||||
"UPDATE jobs SET status = 'COMPLETED', updated_at = datetime('now') WHERE id = ?",
|
||||
(job_id,)
|
||||
)
|
||||
|
||||
def fail_job(self, job_id, error_msg):
|
||||
with sqlite3.connect(DB_PATH) as conn:
|
||||
conn.execute(
|
||||
"UPDATE jobs SET status = 'FAILED', error_msg = ?, updated_at = datetime('now') WHERE id = ?",
|
||||
(str(error_msg), job_id)
|
||||
)
|
||||
|
||||
def get_stats(self):
|
||||
with sqlite3.connect(DB_PATH) as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT status, COUNT(*) FROM jobs GROUP BY status")
|
||||
return dict(cursor.fetchall())
|
||||
@@ -1,60 +0,0 @@
|
||||
import sys
|
||||
import os
|
||||
from superoffice_client import SuperOfficeClient
|
||||
|
||||
# Configuration
|
||||
WEBHOOK_NAME = "Gemini Connector Hook"
|
||||
TARGET_URL = "https://floke-ai.duckdns.org/connector/webhook?token=changeme" # Token match .env
|
||||
EVENTS = [
|
||||
"contact.created",
|
||||
"contact.changed",
|
||||
"person.created",
|
||||
"person.changed"
|
||||
]
|
||||
|
||||
def register():
|
||||
print("🚀 Initializing SuperOffice Client...")
|
||||
try:
|
||||
client = SuperOfficeClient()
|
||||
except Exception as e:
|
||||
print(f"❌ Failed to connect: {e}")
|
||||
return
|
||||
|
||||
print("🔎 Checking existing webhooks...")
|
||||
webhooks = client._get("Webhook")
|
||||
|
||||
if webhooks and 'value' in webhooks:
|
||||
for wh in webhooks['value']:
|
||||
if wh['Name'] == WEBHOOK_NAME:
|
||||
print(f"⚠️ Webhook '{WEBHOOK_NAME}' already exists (ID: {wh['WebhookId']}).")
|
||||
|
||||
# Check if URL matches
|
||||
if wh['TargetUrl'] != TARGET_URL:
|
||||
print(f" ⚠️ URL Mismatch! Deleting old webhook...")
|
||||
# Warning: _delete is not implemented in generic client yet, skipping auto-fix
|
||||
print(" Please delete it manually via API or extend client.")
|
||||
return
|
||||
|
||||
print(f"✨ Registering new webhook: {WEBHOOK_NAME}")
|
||||
payload = {
|
||||
"Name": WEBHOOK_NAME,
|
||||
"Events": EVENTS,
|
||||
"TargetUrl": TARGET_URL,
|
||||
"Secret": "changeme", # Used for signature calculation by SO
|
||||
"State": "Active",
|
||||
"Type": "Webhook"
|
||||
}
|
||||
|
||||
try:
|
||||
# Note: _post is defined in your client, returns JSON
|
||||
res = client._post("Webhook", payload)
|
||||
if res and "WebhookId" in res:
|
||||
print(f"✅ SUCCESS! Webhook created with ID: {res['WebhookId']}")
|
||||
print(f" Target: {res['TargetUrl']}")
|
||||
else:
|
||||
print(f"❌ Creation failed. Response: {res}")
|
||||
except Exception as e:
|
||||
print(f"❌ Error during registration: {e}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
register()
|
||||
@@ -1,10 +0,0 @@
|
||||
requests
|
||||
python-dotenv
|
||||
cryptography
|
||||
pyjwt
|
||||
xmltodict
|
||||
holidays
|
||||
fastapi
|
||||
uvicorn
|
||||
schedule
|
||||
sqlalchemy
|
||||
@@ -1,6 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Start Worker in background
|
||||
python worker.py &
|
||||
|
||||
# Start Webhook Server in foreground
|
||||
uvicorn webhook_app:app --host 0.0.0.0 --port 8000
|
||||
@@ -1,263 +0,0 @@
|
||||
import os
|
||||
import requests
|
||||
import json
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv(override=True)
|
||||
|
||||
class SuperOfficeClient:
|
||||
"""A client for interacting with the SuperOffice REST API."""
|
||||
|
||||
def __init__(self):
|
||||
# Helper to strip quotes if Docker passed them literally
|
||||
def get_clean_env(key, default=None):
|
||||
val = os.getenv(key)
|
||||
if val and val.strip(): # Check if not empty string
|
||||
return val.strip('"').strip("'")
|
||||
return default
|
||||
|
||||
self.client_id = get_clean_env("SO_CLIENT_ID") or get_clean_env("SO_SOD")
|
||||
self.client_secret = get_clean_env("SO_CLIENT_SECRET")
|
||||
self.refresh_token = get_clean_env("SO_REFRESH_TOKEN")
|
||||
self.redirect_uri = get_clean_env("SO_REDIRECT_URI", "http://localhost")
|
||||
self.env = get_clean_env("SO_ENVIRONMENT", "sod")
|
||||
self.cust_id = get_clean_env("SO_CONTEXT_IDENTIFIER", "Cust55774") # Fallback for your dev
|
||||
|
||||
if not all([self.client_id, self.client_secret, self.refresh_token]):
|
||||
raise ValueError("SuperOffice credentials missing in .env file.")
|
||||
|
||||
self.base_url = f"https://app-{self.env}.superoffice.com/{self.cust_id}/api/v1"
|
||||
self.access_token = self._refresh_access_token()
|
||||
if not self.access_token:
|
||||
raise Exception("Failed to authenticate with SuperOffice.")
|
||||
|
||||
self.headers = {
|
||||
"Authorization": f"Bearer {self.access_token}",
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json"
|
||||
}
|
||||
print("✅ SuperOffice Client initialized and authenticated.")
|
||||
|
||||
def _refresh_access_token(self):
|
||||
"""Refreshes and returns a new access token."""
|
||||
url = f"https://{self.env}.superoffice.com/login/common/oauth/tokens"
|
||||
print(f"DEBUG: Refresh URL: '{url}' (Env: '{self.env}')") # DEBUG
|
||||
data = {
|
||||
"grant_type": "refresh_token",
|
||||
"client_id": self.client_id,
|
||||
"client_secret": self.client_secret,
|
||||
"refresh_token": self.refresh_token,
|
||||
"redirect_uri": self.redirect_uri
|
||||
}
|
||||
try:
|
||||
resp = requests.post(url, data=data)
|
||||
resp.raise_for_status()
|
||||
return resp.json().get("access_token")
|
||||
except requests.exceptions.HTTPError as e:
|
||||
print(f"❌ Token Refresh Error: {e.response.text}")
|
||||
return None
|
||||
except Exception as e:
|
||||
print(f"❌ Connection Error during token refresh: {e}")
|
||||
return None
|
||||
|
||||
def _get(self, endpoint):
|
||||
"""Generic GET request."""
|
||||
try:
|
||||
resp = requests.get(f"{self.base_url}/{endpoint}", headers=self.headers)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
except requests.exceptions.HTTPError as e:
|
||||
print(f"❌ API GET Error for {endpoint}: {e.response.text}")
|
||||
return None
|
||||
|
||||
def _put(self, endpoint, payload):
|
||||
"""Generic PUT request."""
|
||||
try:
|
||||
resp = requests.put(f"{self.base_url}/{endpoint}", headers=self.headers, json=payload)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
except requests.exceptions.HTTPError as e:
|
||||
print(f"❌ API PUT Error for {endpoint}: {e.response.text}")
|
||||
return None
|
||||
|
||||
def get_person(self, person_id):
|
||||
"""Gets a single person by ID."""
|
||||
return self._get(f"Person/{person_id}")
|
||||
|
||||
def get_contact(self, contact_id):
|
||||
"""Gets a single contact (company) by ID."""
|
||||
return self._get(f"Contact/{contact_id}")
|
||||
|
||||
def update_udfs(self, entity: str, entity_id: int, udf_payload: dict):
|
||||
"""
|
||||
Updates the UserDefinedFields for a given entity (Person or Contact).
|
||||
|
||||
Args:
|
||||
entity (str): "Person" or "Contact".
|
||||
entity_id (int): The ID of the entity.
|
||||
udf_payload (dict): A dictionary of ProgId:Value pairs.
|
||||
"""
|
||||
endpoint = f"{entity}/{entity_id}"
|
||||
|
||||
# 1. GET the full entity object
|
||||
existing_data = self._get(endpoint)
|
||||
if not existing_data:
|
||||
return False # Error is printed in _get
|
||||
|
||||
# 2. Merge the UDF payload
|
||||
if "UserDefinedFields" not in existing_data:
|
||||
existing_data["UserDefinedFields"] = {}
|
||||
existing_data["UserDefinedFields"].update(udf_payload)
|
||||
|
||||
# 3. PUT the full object back
|
||||
print(f"Updating {entity} {entity_id} with new UDFs...")
|
||||
result = self._put(endpoint, existing_data)
|
||||
|
||||
if result:
|
||||
print(f"✅ Successfully updated {entity} {entity_id}")
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def search(self, query_string: str):
|
||||
"""
|
||||
Performs a search using OData syntax and handles pagination.
|
||||
Example: "Person?$select=personId&$filter=lastname eq 'Godelmann'"
|
||||
"""
|
||||
all_results = []
|
||||
next_page_url = f"{self.base_url}/{query_string}"
|
||||
|
||||
while next_page_url:
|
||||
try:
|
||||
resp = requests.get(next_page_url, headers=self.headers)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
|
||||
# Add the items from the current page
|
||||
all_results.extend(data.get('value', []))
|
||||
|
||||
# Check for the next page link
|
||||
next_page_url = data.get('next_page_url', None)
|
||||
|
||||
except requests.exceptions.HTTPError as e:
|
||||
print(f"❌ API Search Error for {query_string}: {e.response.text}")
|
||||
return None
|
||||
|
||||
return all_results
|
||||
|
||||
def find_contact_by_criteria(self, name=None, org_nr=None, url=None):
|
||||
"""
|
||||
Finds a contact (company) by name, OrgNr, or URL.
|
||||
Returns the first matching contact or None.
|
||||
"""
|
||||
filter_parts = []
|
||||
if name:
|
||||
filter_parts.append(f"Name eq '{name}'")
|
||||
if org_nr:
|
||||
filter_parts.append(f"OrgNr eq '{org_nr}'")
|
||||
if url:
|
||||
filter_parts.append(f"UrlAddress eq '{url}'")
|
||||
|
||||
if not filter_parts:
|
||||
print("❌ No criteria provided for contact search.")
|
||||
return None
|
||||
|
||||
query_string = "Contact?$filter=" + " or ".join(filter_parts)
|
||||
results = self.search(query_string)
|
||||
if results:
|
||||
return results[0] # Return the first match
|
||||
return None
|
||||
|
||||
def _post(self, endpoint, payload):
|
||||
"""Generic POST request."""
|
||||
try:
|
||||
resp = requests.post(f"{self.base_url}/{endpoint}", headers=self.headers, json=payload)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
except requests.exceptions.HTTPError as e:
|
||||
print(f"❌ API POST Error for {endpoint} (Status: {e.response.status_code}): {e.response.text}")
|
||||
return None
|
||||
except Exception as e:
|
||||
print(f"❌ Connection Error during POST for {endpoint}: {e}")
|
||||
return None
|
||||
|
||||
def create_contact(self, name: str, url: str = None, org_nr: str = None):
|
||||
"""Creates a new contact (company)."""
|
||||
payload = {"Name": name}
|
||||
if url:
|
||||
payload["UrlAddress"] = url
|
||||
if org_nr:
|
||||
payload["OrgNr"] = org_nr
|
||||
|
||||
print(f"Creating new contact: {name} with payload: {payload}...") # Added payload to log
|
||||
return self._post("Contact", payload)
|
||||
|
||||
def create_person(self, first_name: str, last_name: str, contact_id: int, email: str = None):
|
||||
"""Creates a new person linked to a contact."""
|
||||
payload = {
|
||||
"Firstname": first_name,
|
||||
"Lastname": last_name,
|
||||
"Contact": {"ContactId": contact_id}
|
||||
}
|
||||
if email:
|
||||
payload["EmailAddress"] = email
|
||||
|
||||
print(f"Creating new person: {first_name} {last_name} for Contact ID {contact_id}...")
|
||||
return self._post("Person", payload)
|
||||
|
||||
def create_sale(self, title: str, contact_id: int, person_id: int, amount: float = None):
|
||||
"""Creates a new sale (opportunity) linked to a contact and person."""
|
||||
payload = {
|
||||
"Heading": title,
|
||||
"Contact": {"ContactId": contact_id},
|
||||
"Person": {"PersonId": person_id}
|
||||
}
|
||||
if amount:
|
||||
payload["Amount"] = amount
|
||||
|
||||
print(f"Creating new sale: {title}...")
|
||||
return self._post("Sale", payload)
|
||||
|
||||
def create_project(self, name: str, contact_id: int, person_id: int = None):
|
||||
"""Creates a new project linked to a contact, and optionally adds a person."""
|
||||
payload = {
|
||||
"Name": name,
|
||||
"Contact": {"ContactId": contact_id}
|
||||
}
|
||||
if person_id:
|
||||
# Adding a person to a project requires a ProjectMember object
|
||||
payload["ProjectMembers"] = [
|
||||
{
|
||||
"Person": {"PersonId": person_id},
|
||||
"Role": "Member" # Default role, can be configured if needed
|
||||
}
|
||||
]
|
||||
|
||||
print(f"Creating new project: {name}...")
|
||||
return self._post("Project", payload)
|
||||
|
||||
def update_entity_udfs(self, entity_id: int, entity_type: str, udf_data: dict):
|
||||
"""
|
||||
Updates UDFs for a given entity (Contact or Person).
|
||||
Args:
|
||||
entity_id (int): ID of the entity.
|
||||
entity_type (str): 'Contact' or 'Person'.
|
||||
udf_data (dict): Dictionary with ProgId:Value pairs for UDFs.
|
||||
Returns:
|
||||
dict: The updated entity object from the API, or None on failure.
|
||||
"""
|
||||
# We need to GET the existing entity, update its UDFs, then PUT it back.
|
||||
endpoint = f"{entity_type}/{entity_id}"
|
||||
existing_entity = self._get(endpoint)
|
||||
if not existing_entity:
|
||||
print(f"❌ Failed to retrieve existing {entity_type} {entity_id} for UDF update.")
|
||||
return None
|
||||
|
||||
if "UserDefinedFields" not in existing_entity:
|
||||
existing_entity["UserDefinedFields"] = {}
|
||||
|
||||
existing_entity["UserDefinedFields"].update(udf_data)
|
||||
|
||||
print(f"Updating {entity_type} {entity_id} UDFs: {udf_data}...")
|
||||
return self._put(endpoint, existing_entity)
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
# test_client.py
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
from superoffice_client import SuperOfficeClient
|
||||
|
||||
print("--- Testing Core SuperOfficeClient ---")
|
||||
|
||||
# Load environment variables from the root .env file
|
||||
load_dotenv(dotenv_path="/home/node/clawd/.env")
|
||||
|
||||
try:
|
||||
# Initialize the client
|
||||
client = SuperOfficeClient()
|
||||
|
||||
# Perform a simple read operation
|
||||
person_id = 1
|
||||
print(f"Fetching person with ID: {person_id}...")
|
||||
person_data = client.get_person(person_id)
|
||||
|
||||
if person_data:
|
||||
print(f"✅ SUCCESS! Found Person: {person_data.get('firstname')} {person_data.get('lastname')}")
|
||||
else:
|
||||
print(f"❌ ERROR: Could not fetch person {person_id}, but connection was successful.")
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ FATAL ERROR during client initialization or request: {e}")
|
||||
|
||||
print("--- Test complete ---")
|
||||
@@ -1,78 +0,0 @@
|
||||
import holidays
|
||||
from datetime import date, timedelta, datetime
|
||||
|
||||
class BusinessCalendar:
|
||||
"""
|
||||
Handles business day calculations, considering weekends and holidays
|
||||
(specifically for Bavaria/Germany).
|
||||
"""
|
||||
def __init__(self, country='DE', state='BY'):
|
||||
# Initialize holidays for Germany, Bavaria
|
||||
self.holidays = holidays.country_holidays(country, subdiv=state)
|
||||
|
||||
def is_business_day(self, check_date: date) -> bool:
|
||||
"""
|
||||
Checks if a given date is a business day (Mon-Fri) and not a holiday.
|
||||
"""
|
||||
# Check for weekend (Saturday=5, Sunday=6)
|
||||
if check_date.weekday() >= 5:
|
||||
return False
|
||||
|
||||
# Check for holiday
|
||||
if check_date in self.holidays:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def get_next_business_day(self, start_date: date) -> date:
|
||||
"""
|
||||
Returns the next valid business day starting from (and including) start_date.
|
||||
If start_date is a business day, it is returned.
|
||||
Otherwise, it searches forward.
|
||||
"""
|
||||
current_date = start_date
|
||||
# Safety limit to prevent infinite loops in case of misconfiguration
|
||||
# (though 365 days of holidays is unlikely)
|
||||
for _ in range(365):
|
||||
if self.is_business_day(current_date):
|
||||
return current_date
|
||||
current_date += timedelta(days=1)
|
||||
|
||||
return current_date
|
||||
|
||||
def get_next_send_time(self, scheduled_time: datetime) -> datetime:
|
||||
"""
|
||||
Calculates the next valid timestamp for sending emails.
|
||||
If scheduled_time falls on a holiday or weekend, it moves to the
|
||||
next business day at the same time.
|
||||
"""
|
||||
original_date = scheduled_time.date()
|
||||
next_date = self.get_next_business_day(original_date)
|
||||
|
||||
if next_date == original_date:
|
||||
return scheduled_time
|
||||
|
||||
# Combine the new date with the original time
|
||||
return datetime.combine(next_date, scheduled_time.time())
|
||||
|
||||
# Example usage for testing
|
||||
if __name__ == "__main__":
|
||||
calendar = BusinessCalendar()
|
||||
|
||||
# Test dates
|
||||
dates_to_test = [
|
||||
date(2026, 5, 1), # Holiday (Labor Day)
|
||||
date(2026, 12, 25), # Holiday (Christmas)
|
||||
date(2026, 4, 6), # Holiday (Easter Monday 2026)
|
||||
date(2026, 2, 10), # Likely a Tuesday (Business Day)
|
||||
date(2026, 2, 14) # Saturday
|
||||
]
|
||||
|
||||
print("--- Business Day Check (Bayern 2026) ---")
|
||||
for d in dates_to_test:
|
||||
is_biz = calendar.is_business_day(d)
|
||||
next_biz = calendar.get_next_business_day(d)
|
||||
holiday_name = calendar.holidays.get(d) if d in calendar.holidays else ""
|
||||
|
||||
status = "✅ Business Day" if is_biz else f"❌ Blocked ({holiday_name if holiday_name else 'Weekend'})"
|
||||
print(f"Date: {d} | {status} -> Next: {next_biz}")
|
||||
@@ -1,56 +0,0 @@
|
||||
from fastapi import FastAPI, Request, HTTPException, BackgroundTasks
|
||||
import logging
|
||||
import os
|
||||
import json
|
||||
from queue_manager import JobQueue
|
||||
|
||||
# Logging Setup
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger("connector-webhook")
|
||||
|
||||
app = FastAPI(title="SuperOffice Connector Webhook", version="2.0")
|
||||
queue = JobQueue()
|
||||
|
||||
WEBHOOK_SECRET = os.getenv("WEBHOOK_SECRET", "changeme")
|
||||
|
||||
@app.post("/webhook")
|
||||
async def receive_webhook(request: Request, background_tasks: BackgroundTasks):
|
||||
"""
|
||||
Endpoint for SuperOffice Webhooks.
|
||||
"""
|
||||
# 1. Verify Secret (Basic Security)
|
||||
# SuperOffice puts signature in headers, but for custom webhook we might just use query param or header
|
||||
# Let's assume for now a shared secret in header 'X-SuperOffice-Signature' or similar
|
||||
# Or simply a secret in the URL: /webhook?token=...
|
||||
|
||||
token = request.query_params.get("token")
|
||||
if token != WEBHOOK_SECRET:
|
||||
logger.warning(f"Invalid webhook token attempt: {token}")
|
||||
raise HTTPException(403, "Invalid Token")
|
||||
|
||||
try:
|
||||
payload = await request.json()
|
||||
logger.info(f"Received webhook payload: {payload}")
|
||||
|
||||
event_type = payload.get("Event", "unknown")
|
||||
|
||||
# Add to local Queue
|
||||
queue.add_job(event_type, payload)
|
||||
|
||||
return {"status": "queued"}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing webhook: {e}", exc_info=True)
|
||||
raise HTTPException(500, "Internal Server Error")
|
||||
|
||||
@app.get("/health")
|
||||
def health():
|
||||
return {"status": "ok"}
|
||||
|
||||
@app.get("/stats")
|
||||
def stats():
|
||||
return queue.get_stats()
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run("webhook_app:app", host="0.0.0.0", port=8000, reload=True)
|
||||
@@ -1,128 +0,0 @@
|
||||
from flask import Flask, request, jsonify
|
||||
import os
|
||||
import sqlite3
|
||||
import hashlib
|
||||
from superoffice_client import SuperOfficeClient # Our new shiny client class
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
# --- CONFIGURATION ---
|
||||
DB_FILE = "marketing_matrix.db"
|
||||
|
||||
# UDF ProgIds (from our plan)
|
||||
PROG_ID_CONTACT_VERTICAL = "SuperOffice:5"
|
||||
PROG_ID_PERSON_ROLE = "SuperOffice:3"
|
||||
PROG_ID_CONTACT_CHALLENGE = "SuperOffice:6"
|
||||
PROG_ID_PERSON_SUBJECT = "SuperOffice:5"
|
||||
PROG_ID_PERSON_INTRO = "SuperOffice:6"
|
||||
PROG_ID_PERSON_PROOF = "SuperOffice:7"
|
||||
PROG_ID_PERSON_HASH = "SuperOffice:8"
|
||||
|
||||
# --- CORE LOGIC ---
|
||||
def get_text_from_matrix(vertical_id, role_id):
|
||||
"""Fetches the pre-generated text block from the local SQLite DB."""
|
||||
conn = sqlite3.connect(DB_FILE)
|
||||
c = conn.cursor()
|
||||
c.execute("SELECT subject, intro, social_proof FROM text_blocks WHERE vertical_id = ? AND role_id = ?", (vertical_id, role_id))
|
||||
row = c.fetchone()
|
||||
conn.close()
|
||||
return row if row else (None, None, None)
|
||||
|
||||
def process_single_person(client: SuperOfficeClient, person_id: int):
|
||||
"""Central logic to update marketing copy for a single person."""
|
||||
print(f"Processing Person ID: {person_id}")
|
||||
|
||||
person_data = client.get_person(person_id)
|
||||
if not person_data:
|
||||
raise ValueError(f"Person {person_id} not found")
|
||||
|
||||
contact_id = person_data.get('contact', {}).get('contactId')
|
||||
if not contact_id:
|
||||
raise ValueError("Person is not linked to a Contact")
|
||||
|
||||
contact_data = client.get_contact(contact_id)
|
||||
if not contact_data:
|
||||
raise ValueError(f"Contact {contact_id} not found")
|
||||
|
||||
# Extract and clean Vertical and Role IDs
|
||||
vertical_id_raw = contact_data["UserDefinedFields"].get(PROG_ID_CONTACT_VERTICAL, "")
|
||||
role_id_raw = person_data["UserDefinedFields"].get(PROG_ID_PERSON_ROLE, "")
|
||||
|
||||
if not vertical_id_raw or not role_id_raw:
|
||||
raise ValueError("Vertical or Role ID is not set.")
|
||||
|
||||
vertical_id = int(vertical_id_raw.replace("[I:", "").replace("]", ""))
|
||||
role_id = int(role_id_raw.replace("[I:", "").replace("]", ""))
|
||||
|
||||
# Get text from matrix
|
||||
subject, intro, proof = get_text_from_matrix(vertical_id, role_id)
|
||||
if not subject:
|
||||
raise ValueError(f"No text found in matrix for V:{vertical_id}, R:{role_id}")
|
||||
|
||||
# Generate Hash
|
||||
text_concat = f"{subject}{intro}{proof}"
|
||||
copy_hash = hashlib.md5(text_concat.encode()).hexdigest()
|
||||
|
||||
# Prepare payloads
|
||||
contact_payload = {PROG_ID_CONTACT_CHALLENGE: intro}
|
||||
person_payload = {
|
||||
PROG_ID_PERSON_SUBJECT: subject,
|
||||
PROG_ID_PERSON_INTRO: intro,
|
||||
PROG_ID_PERSON_PROOF: proof,
|
||||
PROG_ID_PERSON_HASH: copy_hash
|
||||
}
|
||||
|
||||
# Inject data
|
||||
client.update_udfs("Contact", contact_id, contact_payload)
|
||||
client.update_udfs("Person", person_id, person_payload)
|
||||
|
||||
return f"Updated Person {person_id} with texts for V:{vertical_id}/R:{role_id}"
|
||||
|
||||
# --- WEBHOOK ENDPOINTS ---
|
||||
@app.route('/regenerate_for_person', methods=['POST'])
|
||||
def webhook_person():
|
||||
data = request.get_json()
|
||||
if not data or "person_id" not in data:
|
||||
return jsonify({"error": "Missing person_id"}), 400
|
||||
|
||||
try:
|
||||
client = SuperOfficeClient()
|
||||
message = process_single_person(client, data['person_id'])
|
||||
return jsonify({"status": "success", "message": message}), 200
|
||||
except Exception as e:
|
||||
print(f"❌ Error processing person: {e}")
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
@app.route('/regenerate_for_contact', methods=['POST'])
|
||||
def webhook_contact():
|
||||
data = request.get_json()
|
||||
if not data or "contact_id" not in data:
|
||||
return jsonify({"error": "Missing contact_id"}), 400
|
||||
|
||||
contact_id = data['contact_id']
|
||||
print(f"Received request to regenerate for all persons in Contact ID: {contact_id}")
|
||||
|
||||
try:
|
||||
client = SuperOfficeClient()
|
||||
contact = client.get_contact(contact_id)
|
||||
if not contact or not contact.get('persons'):
|
||||
return jsonify({"status": "success", "message": "No persons found for this contact."}), 200
|
||||
|
||||
updated_count = 0
|
||||
for person_summary in contact['persons']:
|
||||
try:
|
||||
process_single_person(client, person_summary['personId'])
|
||||
updated_count += 1
|
||||
except Exception as e:
|
||||
print(f" - Skipping Person {person_summary.get('personId')}: {e}")
|
||||
|
||||
return jsonify({"status": "success", "message": f"Processed {updated_count} persons for Contact {contact_id}"}), 200
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Error processing contact: {e}")
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
if __name__ == '__main__':
|
||||
# For local dev. Use a proper WSGI server (Gunicorn) for production.
|
||||
# Needs pip install Flask
|
||||
app.run(host='0.0.0.0', port=5001, debug=True)
|
||||
@@ -1,280 +0,0 @@
|
||||
import time
|
||||
import logging
|
||||
import os
|
||||
import requests
|
||||
import json
|
||||
from queue_manager import JobQueue
|
||||
from superoffice_client import SuperOfficeClient
|
||||
|
||||
# Setup Logging
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||
)
|
||||
logger = logging.getLogger("connector-worker")
|
||||
|
||||
# Config
|
||||
COMPANY_EXPLORER_URL = os.getenv("COMPANY_EXPLORER_URL", "http://company-explorer:8000")
|
||||
POLL_INTERVAL = 5 # Seconds
|
||||
|
||||
# UDF Mapping (DEV) - Should be moved to config later
|
||||
UDF_MAPPING = {
|
||||
"subject": "SuperOffice:5",
|
||||
"intro": "SuperOffice:6",
|
||||
"social_proof": "SuperOffice:7"
|
||||
}
|
||||
|
||||
def process_job(job, so_client: SuperOfficeClient):
|
||||
"""
|
||||
Core logic for processing a single job.
|
||||
"""
|
||||
logger.info(f"Processing Job {job['id']} ({job['event_type']})")
|
||||
payload = job['payload']
|
||||
event_low = job['event_type'].lower()
|
||||
|
||||
# 1. Extract IDs from Webhook Payload
|
||||
person_id = None
|
||||
contact_id = None
|
||||
|
||||
if "PersonId" in payload:
|
||||
person_id = int(payload["PersonId"])
|
||||
elif "PrimaryKey" in payload and "person" in event_low:
|
||||
person_id = int(payload["PrimaryKey"])
|
||||
|
||||
if "ContactId" in payload:
|
||||
contact_id = int(payload["ContactId"])
|
||||
elif "PrimaryKey" in payload and "contact" in event_low:
|
||||
contact_id = int(payload["PrimaryKey"])
|
||||
|
||||
# Fallback/Deep Lookup
|
||||
if not contact_id and person_id:
|
||||
person_data = so_client.get_person(person_id)
|
||||
if person_data and "Contact" in person_data:
|
||||
contact_id = person_data["Contact"].get("ContactId")
|
||||
|
||||
if not contact_id:
|
||||
raise ValueError(f"Could not identify ContactId in payload: {payload}")
|
||||
|
||||
logger.info(f"Target: Person {person_id}, Contact {contact_id}")
|
||||
|
||||
# --- Cascading Logic ---
|
||||
# If a company changes, we want to update all its persons eventually.
|
||||
# We do this by adding "person.changed" jobs for each person to the queue.
|
||||
if "contact" in event_low and not person_id:
|
||||
logger.info(f"Company event detected. Triggering cascade for all persons of Contact {contact_id}.")
|
||||
try:
|
||||
persons = so_client.search(f"Person?$filter=contact/contactId eq {contact_id}")
|
||||
if persons:
|
||||
q = JobQueue()
|
||||
for p in persons:
|
||||
p_id = p.get("PersonId")
|
||||
if p_id:
|
||||
logger.info(f"Cascading: Enqueueing job for Person {p_id}")
|
||||
q.add_job("person.changed", {"PersonId": p_id, "ContactId": contact_id, "Source": "Cascade"})
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to cascade to persons for contact {contact_id}: {e}")
|
||||
|
||||
# 1b. Fetch full contact details for 'Double Truth' check (Master Data Sync)
|
||||
crm_name = None
|
||||
crm_website = None
|
||||
try:
|
||||
contact_details = so_client.get_contact(contact_id)
|
||||
if contact_details:
|
||||
crm_name = contact_details.get("Name")
|
||||
crm_website = contact_details.get("UrlAddress")
|
||||
if not crm_website and "Urls" in contact_details and contact_details["Urls"]:
|
||||
crm_website = contact_details["Urls"][0].get("Value")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to fetch contact details for {contact_id}: {e}")
|
||||
|
||||
# 2. Call Company Explorer Provisioning API
|
||||
ce_url = f"{COMPANY_EXPLORER_URL}/api/provision/superoffice-contact"
|
||||
ce_req = {
|
||||
"so_contact_id": contact_id,
|
||||
"so_person_id": person_id,
|
||||
"job_title": payload.get("JobTitle"),
|
||||
"crm_name": crm_name,
|
||||
"crm_website": crm_website
|
||||
}
|
||||
|
||||
ce_auth = (os.getenv("API_USER", "admin"), os.getenv("API_PASSWORD", "gemini"))
|
||||
|
||||
try:
|
||||
resp = requests.post(ce_url, json=ce_req, auth=ce_auth)
|
||||
if resp.status_code == 404:
|
||||
logger.warning(f"Company Explorer returned 404. Retrying later.")
|
||||
return "RETRY"
|
||||
|
||||
resp.raise_for_status()
|
||||
provisioning_data = resp.json()
|
||||
|
||||
if provisioning_data.get("status") == "processing":
|
||||
logger.info(f"Company Explorer is processing {provisioning_data.get('company_name', 'Unknown')}. Re-queueing job.")
|
||||
return "RETRY"
|
||||
|
||||
if provisioning_data.get("status") == "processing":
|
||||
logger.info(f"Company Explorer is processing {provisioning_data.get('company_name', 'Unknown')}. Re-queueing job.")
|
||||
return "RETRY"
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
raise Exception(f"Company Explorer API failed: {e}")
|
||||
|
||||
logger.info(f"CE Response for Contact {contact_id}: {json.dumps(provisioning_data)}") # DEBUG
|
||||
|
||||
# 2b. Sync Vertical to SuperOffice (Company Level)
|
||||
vertical_name = provisioning_data.get("vertical_name")
|
||||
|
||||
if vertical_name:
|
||||
# Mappings from README
|
||||
VERTICAL_MAP = {
|
||||
"Logistics - Warehouse": 23,
|
||||
"Healthcare - Hospital": 24,
|
||||
"Infrastructure - Transport": 25,
|
||||
"Leisure - Indoor Active": 26
|
||||
}
|
||||
|
||||
vertical_id = VERTICAL_MAP.get(vertical_name)
|
||||
|
||||
if vertical_id:
|
||||
logger.info(f"Identified Vertical '{vertical_name}' -> ID {vertical_id}")
|
||||
try:
|
||||
# Check current value to avoid loops
|
||||
current_contact = so_client.get_contact(contact_id)
|
||||
current_udfs = current_contact.get("UserDefinedFields", {})
|
||||
current_val = current_udfs.get("SuperOffice:5", "")
|
||||
|
||||
# Normalize SO list ID format (e.g., "[I:26]" -> "26")
|
||||
if current_val and current_val.startswith("[I:"):
|
||||
current_val = current_val.split(":")[1].strip("]")
|
||||
|
||||
if str(current_val) != str(vertical_id):
|
||||
logger.info(f"Updating Contact {contact_id} Vertical: {current_val} -> {vertical_id}")
|
||||
so_client.update_entity_udfs(contact_id, "Contact", {"SuperOffice:5": str(vertical_id)})
|
||||
else:
|
||||
logger.info(f"Vertical for Contact {contact_id} already in sync ({vertical_id}).")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to sync vertical for Contact {contact_id}: {e}")
|
||||
else:
|
||||
logger.warning(f"Vertical '{vertical_name}' not found in internal mapping.")
|
||||
|
||||
# 2c. Sync Website (Company Level)
|
||||
# TEMPORARILY DISABLED TO PREVENT LOOP (SO API Read-after-Write latency or field mapping issue)
|
||||
"""
|
||||
website = provisioning_data.get("website")
|
||||
if website and website != "k.A.":
|
||||
try:
|
||||
# Re-fetch contact to ensure we work on latest version (Optimistic Concurrency)
|
||||
contact_data = so_client.get_contact(contact_id)
|
||||
current_url = contact_data.get("UrlAddress", "")
|
||||
|
||||
# Normalize for comparison
|
||||
def norm(u): return str(u).lower().replace("https://", "").replace("http://", "").strip("/") if u else ""
|
||||
|
||||
if norm(current_url) != norm(website):
|
||||
logger.info(f"Updating Website for Contact {contact_id}: {current_url} -> {website}")
|
||||
|
||||
# Update Urls collection (Rank 1)
|
||||
new_urls = []
|
||||
if "Urls" in contact_data:
|
||||
found = False
|
||||
for u in contact_data["Urls"]:
|
||||
if u.get("Rank") == 1:
|
||||
u["Value"] = website
|
||||
found = True
|
||||
new_urls.append(u)
|
||||
if not found:
|
||||
new_urls.append({"Value": website, "Rank": 1, "Description": "Website"})
|
||||
contact_data["Urls"] = new_urls
|
||||
else:
|
||||
contact_data["Urls"] = [{"Value": website, "Rank": 1, "Description": "Website"}]
|
||||
|
||||
# Also set main field if empty
|
||||
if not current_url:
|
||||
contact_data["UrlAddress"] = website
|
||||
|
||||
# Write back full object
|
||||
so_client._put(f"Contact/{contact_id}", contact_data)
|
||||
else:
|
||||
logger.info(f"Website for Contact {contact_id} already in sync.")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to sync website for Contact {contact_id}: {e}")
|
||||
"""
|
||||
|
||||
# 3. Update SuperOffice (Only if person_id is present)
|
||||
if not person_id:
|
||||
logger.info("Sync complete (Company only). No texts to write back.")
|
||||
return "SUCCESS"
|
||||
|
||||
texts = provisioning_data.get("texts", {})
|
||||
if not any(texts.values()):
|
||||
logger.info("No texts returned from Matrix (yet). Skipping write-back.")
|
||||
return "SUCCESS"
|
||||
|
||||
udf_update = {}
|
||||
if texts.get("subject"): udf_update[UDF_MAPPING["subject"]] = texts["subject"]
|
||||
if texts.get("intro"): udf_update[UDF_MAPPING["intro"]] = texts["intro"]
|
||||
if texts.get("social_proof"): udf_update[UDF_MAPPING["social_proof"]] = texts["social_proof"]
|
||||
|
||||
if udf_update:
|
||||
# Loop Prevention
|
||||
try:
|
||||
current_person = so_client.get_person(person_id)
|
||||
current_udfs = current_person.get("UserDefinedFields", {})
|
||||
needs_update = False
|
||||
for key, new_val in udf_update.items():
|
||||
if current_udfs.get(key, "") != new_val:
|
||||
needs_update = True
|
||||
break
|
||||
|
||||
if needs_update:
|
||||
logger.info(f"Applying update to Person {person_id} (Changes detected).")
|
||||
success = so_client.update_entity_udfs(person_id, "Person", udf_update)
|
||||
if not success:
|
||||
raise Exception("Failed to update SuperOffice UDFs")
|
||||
else:
|
||||
logger.info(f"Skipping update for Person {person_id}: Values match (Loop Prevention).")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error during pre-update check: {e}")
|
||||
raise
|
||||
|
||||
logger.info("Job successfully processed.")
|
||||
return "SUCCESS"
|
||||
|
||||
def run_worker():
|
||||
queue = JobQueue()
|
||||
|
||||
# Initialize SO Client with retry
|
||||
so_client = None
|
||||
while not so_client:
|
||||
try:
|
||||
so_client = SuperOfficeClient()
|
||||
except Exception as e:
|
||||
logger.critical(f"Failed to initialize SuperOffice Client: {e}. Retrying in 30s...")
|
||||
time.sleep(30)
|
||||
|
||||
logger.info("Worker started. Polling queue...")
|
||||
|
||||
while True:
|
||||
try:
|
||||
job = queue.get_next_job()
|
||||
if job:
|
||||
try:
|
||||
result = process_job(job, so_client)
|
||||
if result == "RETRY":
|
||||
queue.retry_job_later(job['id'], delay_seconds=120)
|
||||
else:
|
||||
queue.complete_job(job['id'])
|
||||
except Exception as e:
|
||||
logger.error(f"Job {job['id']} failed: {e}", exc_info=True)
|
||||
queue.fail_job(job['id'], str(e))
|
||||
else:
|
||||
time.sleep(POLL_INTERVAL)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Worker loop error: {e}")
|
||||
time.sleep(POLL_INTERVAL)
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_worker()
|
||||
@@ -1,47 +0,0 @@
|
||||
import base64
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
|
||||
# The XML data provided by the user
|
||||
modulus_b64 = "3PlhZKih1S9AKmsOcnPuS6FfPyYdKg6ltCUypt4EOi2++oM5O26YFxODBtQHO+UmsEoNcz6X2A5BE9kv4y8Xyv+hDxQrHsyavrkq2Yn5Mf/BFAquYuRoX5FtvH6ht+yllfBJQs3wE9m/O8LKHomKE5HXiaV/QMDRLoYeAwzQwcE="
|
||||
exponent_b64 = "AQAB"
|
||||
d_b64 = "i8TdWprjSgHKF0qB59j2WDYpFbtY5RpAq3J/2FZD3DzFOJU55SKt5qK71NzV+oeV8hnU6hkkWE+j0BcnGA7Yf6xGIoVNVhrenU18hrd6vSUPDeOuerkv+u98pNEqs6jcfYwhKKEJ2nFl4AacdQ7RaQPEWb41pVYvP+qaX6PeQAE="
|
||||
p_b64 = "8fGRi846fRCbc8oaUGnw1dR2BXOopzxfAMeKEOCUeRP/Yj1kUcW9k4zUeaFc2upnfAeUbX38Bk5VW5edCDIjAQ=="
|
||||
q_b64 = "6c/usvg8/4quH8Z70tSotmN+N6UxiuaTF51oOeTnIVUjXMqB3gc5sRCbipGj1u+DJUYh4LQLZp+W2LU7uCpewQ=="
|
||||
dp_b64 = "y2q8YVwh5tbYrHCm0SdRWqcIF6tXiEwE4EXkOi5oBqielr1hJDNqIa1NU3os9M4R9cD1tV0wUSj5MUn2uFZXAQ=="
|
||||
dq_b64 = "yc9+8Z0QUWVrC+QvBngls1/HFtKQI5sHRS/JQYdQ9FVfM31bgL/tzOZPytgQebm8EdUp8qCU4pxHAH/Vrw1rQQ=="
|
||||
inverse_q_b64 = "VX4SRxVQ130enAqw9M0Nyl+875vmhc6cbsJQQ3E/fJjQvkB8EgjxBp6JVTeY1U5ga56Hvzngomk335pA6gli0A=="
|
||||
|
||||
def b64_to_int(b64_str):
|
||||
return int.from_bytes(base64.b64decode(b64_str), byteorder='big')
|
||||
|
||||
# Convert components to integers
|
||||
n = b64_to_int(modulus_b64)
|
||||
e = b64_to_int(exponent_b64)
|
||||
d = b64_to_int(d_b64)
|
||||
p = b64_to_int(p_b64)
|
||||
q = b64_to_int(q_b64)
|
||||
dmp1 = b64_to_int(dp_b64)
|
||||
dmq1 = b64_to_int(dq_b64)
|
||||
iqmp = b64_to_int(inverse_q_b64)
|
||||
|
||||
# Reconstruct the private key object
|
||||
private_key = rsa.RSAPrivateNumbers(
|
||||
p=p,
|
||||
q=q,
|
||||
d=d,
|
||||
dmp1=dmp1,
|
||||
dmq1=dmq1,
|
||||
iqmp=iqmp,
|
||||
public_numbers=rsa.RSAPublicNumbers(e, n)
|
||||
).private_key()
|
||||
|
||||
# Serialize to PEM
|
||||
pem_private = private_key.private_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PrivateFormat.PKCS8,
|
||||
encryption_algorithm=serialization.NoEncryption()
|
||||
)
|
||||
|
||||
# Print for the user
|
||||
print(pem_private.decode('utf-8'))
|
||||
@@ -183,17 +183,6 @@
|
||||
</p>
|
||||
<a href="/ca/" class="btn">Starten →</a>
|
||||
</div>
|
||||
|
||||
<!-- Lead Engine: TradingTwins -->
|
||||
<div class="card">
|
||||
<span class="card-icon">📈</span>
|
||||
<h2>Lead Engine: TradingTwins</h2>
|
||||
<p>
|
||||
Zugriff auf die lokale Lead Engine.
|
||||
</p>
|
||||
<a href="/lead/" class="btn" target="_blank">Starten →</a>
|
||||
</div>
|
||||
|
||||
<!-- Meeting Assistant (Transcription) -->
|
||||
<div class="card">
|
||||
<span class="card-icon">🎙️</span>
|
||||
@@ -204,15 +193,6 @@
|
||||
<a href="/tr/" class="btn">Starten →</a>
|
||||
</div>
|
||||
|
||||
<!-- Heatmap Tool -->
|
||||
<div class="card">
|
||||
<span class="card-icon">🗺️</span>
|
||||
<h2>Heatmap Tool</h2>
|
||||
<p>
|
||||
Visualisieren Sie Excel-Daten (PLZ) auf einer interaktiven Deutschlandkarte als Heatmap oder Cluster.
|
||||
</p>
|
||||
<a href="/heatmap/" class="btn">Starten →</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
|
||||
import sqlite3
|
||||
import json
|
||||
import os
|
||||
|
||||
DB_PATH = "transcription-tool/backend/meetings.db"
|
||||
MEETING_ID = 5
|
||||
|
||||
def debug_meeting(db_path, meeting_id):
|
||||
if not os.path.exists(db_path):
|
||||
print(f"ERROR: Database file not found at {db_path}")
|
||||
return
|
||||
|
||||
try:
|
||||
conn = sqlite3.connect(db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Get Meeting Info
|
||||
cursor.execute("SELECT id, title, status, duration_seconds FROM meetings WHERE id = ?", (meeting_id,))
|
||||
meeting = cursor.fetchone()
|
||||
|
||||
if not meeting:
|
||||
print(f"ERROR: No meeting found with ID {meeting_id}")
|
||||
return
|
||||
|
||||
print("--- MEETING INFO ---")
|
||||
print(f"ID: {meeting[0]}")
|
||||
print(f"Title: {meeting[1]}")
|
||||
print(f"Status: {meeting[2]}")
|
||||
print(f"Duration (s): {meeting[3]}")
|
||||
print("-" * 20)
|
||||
|
||||
# Get Chunks
|
||||
cursor.execute("SELECT id, chunk_index, json_content FROM transcript_chunks WHERE meeting_id = ? ORDER BY chunk_index", (meeting_id,))
|
||||
chunks = cursor.fetchall()
|
||||
|
||||
print(f"--- CHUNKS FOUND: {len(chunks)} ---")
|
||||
for chunk in chunks:
|
||||
chunk_id, chunk_index, json_content_str = chunk
|
||||
print(f"\n--- Chunk ID: {chunk_id}, Index: {chunk_index} ---")
|
||||
|
||||
if not json_content_str:
|
||||
print(" -> JSON content is EMPTY.")
|
||||
continue
|
||||
|
||||
try:
|
||||
json_content = json.loads(json_content_str)
|
||||
print(f" -> Number of entries: {len(json_content)}")
|
||||
|
||||
if json_content:
|
||||
# Print first 2 and last 2 entries to check for the "Ja" loop
|
||||
print(" -> First 2 entries:")
|
||||
for entry in json_content[:2]:
|
||||
print(f" - {entry.get('display_time')} [{entry.get('speaker')}]: {entry.get('text')[:80]}...")
|
||||
|
||||
if len(json_content) > 4:
|
||||
print(" -> Last 2 entries:")
|
||||
for entry in json_content[-2:]:
|
||||
print(f" - {entry.get('display_time')} [{entry.get('speaker')}]: {entry.get('text')[:80]}...")
|
||||
|
||||
except json.JSONDecodeError:
|
||||
print(" -> ERROR: Failed to decode JSON content.")
|
||||
|
||||
except sqlite3.Error as e:
|
||||
print(f"Database error: {e}")
|
||||
finally:
|
||||
if 'conn' in locals() and conn:
|
||||
conn.close()
|
||||
|
||||
if __name__ == "__main__":
|
||||
debug_meeting(DB_PATH, MEETING_ID)
|
||||
@@ -1,70 +0,0 @@
|
||||
import sqlite3
|
||||
import json
|
||||
import os
|
||||
|
||||
DB_PATH = "transcripts.db"
|
||||
|
||||
def inspect_latest_meeting():
|
||||
if not os.path.exists(DB_PATH):
|
||||
print(f"Error: Database file '{DB_PATH}' not found.")
|
||||
return
|
||||
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Get latest meeting
|
||||
cursor.execute("SELECT id, title, created_at FROM meetings ORDER BY created_at DESC LIMIT 1")
|
||||
meeting = cursor.fetchone()
|
||||
|
||||
if not meeting:
|
||||
print("No meetings found in DB.")
|
||||
conn.close()
|
||||
return
|
||||
|
||||
meeting_id, title, created_at = meeting
|
||||
print(f"--- Inspecting Latest Meeting: ID {meeting_id} ('{title}') created at {created_at} ---")
|
||||
|
||||
# Get chunks for this meeting
|
||||
cursor.execute("SELECT id, chunk_index, raw_text, json_content FROM transcript_chunks WHERE meeting_id = ? ORDER BY chunk_index", (meeting_id,))
|
||||
chunks = cursor.fetchall()
|
||||
|
||||
if not chunks:
|
||||
print("No chunks found for this meeting.")
|
||||
|
||||
for chunk in chunks:
|
||||
chunk_id, idx, raw_text, json_content = chunk
|
||||
print(f"\n[Chunk {idx} (ID: {chunk_id})]")
|
||||
|
||||
print(f"Stored JSON Content (Length): {len(json.loads(json_content)) if json_content else 'None/Empty'}")
|
||||
|
||||
print("-" * 20 + " RAW TEXT START " + "-" * 20)
|
||||
print(raw_text[:500]) # Print first 500 chars
|
||||
print("..." if len(raw_text) > 500 else "")
|
||||
print("-" * 20 + " RAW TEXT END " + "-" * 20)
|
||||
|
||||
# Try to parse manually to see error
|
||||
try:
|
||||
# Simulate cleaning logic from orchestrator
|
||||
cleaned = raw_text.strip()
|
||||
if cleaned.startswith("```json"):
|
||||
cleaned = cleaned[7:]
|
||||
elif cleaned.startswith("```"):
|
||||
cleaned = cleaned[3:]
|
||||
if cleaned.endswith("```"):
|
||||
cleaned = cleaned[:-3]
|
||||
cleaned = cleaned.strip()
|
||||
|
||||
parsed = json.loads(cleaned)
|
||||
print("✅ Manual Parsing Successful!")
|
||||
except json.JSONDecodeError as e:
|
||||
print(f"❌ Manual Parsing Failed: {e}")
|
||||
# Show context around error
|
||||
if hasattr(e, 'pos'):
|
||||
start = max(0, e.pos - 20)
|
||||
end = min(len(cleaned), e.pos + 20)
|
||||
print(f" Context at error: ...{cleaned[start:end]}...")
|
||||
|
||||
conn.close()
|
||||
|
||||
if __name__ == "__main__":
|
||||
inspect_latest_meeting()
|
||||
386
dev_session.py
386
dev_session.py
@@ -1,10 +1,12 @@
|
||||
import re
|
||||
from typing import List, Dict, Optional, Tuple, Any
|
||||
from getpass import getpass
|
||||
from dotenv import load_dotenv
|
||||
import subprocess
|
||||
from datetime import datetime, timedelta
|
||||
import os
|
||||
import requests
|
||||
import json
|
||||
import re
|
||||
from typing import List, Dict, Optional, Tuple
|
||||
from getpass import getpass
|
||||
from dotenv import load_dotenv
|
||||
import argparse
|
||||
import shutil
|
||||
|
||||
@@ -45,11 +47,10 @@ def find_database_by_title(token: str, title: str) -> Optional[str]:
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
print(f"Fehler bei der Suche nach der Notion-Datenbank '{title}': {e}")
|
||||
if e.response is not None:
|
||||
try:
|
||||
print(f"Antwort des Servers: {json.dumps(e.response.json(), indent=2)}")
|
||||
except:
|
||||
print(f"Antwort des Servers: {e.response.text}")
|
||||
try:
|
||||
print(f"Antwort des Servers: {json.dumps(e.response.json(), indent=2)}")
|
||||
except:
|
||||
print(f"Antwort des Servers: {e.response.text}")
|
||||
return None
|
||||
|
||||
def query_notion_database(token: str, database_id: str, filter_payload: Dict = None) -> List[Dict]:
|
||||
@@ -71,11 +72,10 @@ def query_notion_database(token: str, database_id: str, filter_payload: Dict = N
|
||||
return response.json().get("results", [])
|
||||
except requests.exceptions.RequestException as e:
|
||||
print(f"Fehler bei der Abfrage der Notion-Datenbank {database_id}: {e}")
|
||||
if e.response is not None:
|
||||
try:
|
||||
print(f"Antwort des Servers: {json.dumps(e.response.json(), indent=2)}")
|
||||
except:
|
||||
print(f"Antwort des Servers: {e.response.text}")
|
||||
try:
|
||||
print(f"Antwort des Servers: {json.dumps(e.response.json(), indent=2)}")
|
||||
except:
|
||||
print(f"Antwort des Servers: {e.response.text}")
|
||||
return []
|
||||
|
||||
def get_page_title(page: Dict) -> str:
|
||||
@@ -87,35 +87,49 @@ def get_page_title(page: Dict) -> str:
|
||||
return title_parts[0].get("plain_text", "Unbenannt")
|
||||
return "Unbenannt"
|
||||
|
||||
def get_page_property(page: Dict, prop_name: str, prop_type: str = "rich_text") -> Optional[str]:
|
||||
def get_page_rich_text_property(page: Dict, prop_name: str) -> Optional[str]:
|
||||
"""Extrahiert den Inhalt einer bestimmten Eigenschaft (Property) von einer Seite."""
|
||||
prop = page.get("properties", {}).get(prop_name)
|
||||
if not prop:
|
||||
return None
|
||||
|
||||
if prop_type == "rich_text" and prop.get("type") == "rich_text":
|
||||
if prop.get("type") == "rich_text":
|
||||
text_parts = prop.get("rich_text", [])
|
||||
if text_parts:
|
||||
return text_parts[0].get("plain_text")
|
||||
|
||||
# Hier könnten weitere Typen wie 'select', 'number' etc. behandelt werden
|
||||
return None
|
||||
|
||||
def get_page_number_property(page: Dict, prop_name: str) -> Optional[float]:
|
||||
"""Extrahiert den Wert einer 'number'-Eigenschaft von einer Seite."""
|
||||
"""Extrahiert den Inhalt einer Number-Eigenschaft von einer Seite."""
|
||||
prop = page.get("properties", {}).get(prop_name)
|
||||
if not prop or prop.get("type") != "number":
|
||||
if not prop:
|
||||
return None
|
||||
return prop.get("number")
|
||||
|
||||
def decimal_hours_to_hhmm(decimal_hours: float) -> str:
|
||||
"""Wandelt Dezimalstunden in das Format 'HH:MM' um."""
|
||||
if decimal_hours is None:
|
||||
return "00:00"
|
||||
hours = int(decimal_hours)
|
||||
minutes = int((decimal_hours * 60) % 60)
|
||||
return f"{hours:02d}:{minutes:02d}"
|
||||
|
||||
if prop.get("type") == "number":
|
||||
return prop.get("number")
|
||||
|
||||
return None
|
||||
|
||||
def get_page_date_property(page: Dict, prop_name: str) -> Optional[datetime]:
|
||||
"""Extrahiert den Inhalt einer Date-Eigenschaft von einer Seite."""
|
||||
prop = page.get("properties", {}).get(prop_name)
|
||||
if not prop:
|
||||
return None
|
||||
|
||||
if prop.get("type") == "date":
|
||||
date_info = prop.get("date")
|
||||
if date_info and date_info.get("start"):
|
||||
try:
|
||||
# Notion gibt Datumszeiten im ISO 8601 Format zurück (z.B. '2023-10-27T10:00:00.000+00:00')
|
||||
# oder nur Datum (z.B. '2023-10-27')
|
||||
# datetime.fromisoformat kann beides verarbeiten, aber Zeitzonen können komplex sein.
|
||||
# Für unsere Zwecke reicht es, wenn es als UTC betrachtet wird und wir die Dauer berechnen.
|
||||
return datetime.fromisoformat(date_info["start"].replace('Z', '+00:00'))
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
return None
|
||||
|
||||
def get_page_content(token: str, page_id: str) -> str:
|
||||
"""Ruft den gesamten Textinhalt einer Notion-Seite ab, indem es die Blöcke zusammenfügt, mit Paginierung."""
|
||||
@@ -185,13 +199,12 @@ def get_page_content(token: str, page_id: str) -> str:
|
||||
return "\n".join(full_text)
|
||||
except requests.exceptions.RequestException as e:
|
||||
print(f"Fehler beim Abrufen des Seiteninhalts für Page-ID {page_id}: {e}")
|
||||
if e.response is not None:
|
||||
try:
|
||||
if e.response: # Wenn eine Antwort vorhanden ist
|
||||
error_details = e.response.json()
|
||||
print(f"Notion API Fehlerdetails: {json.dumps(error_details, indent=2)}")
|
||||
except json.JSONDecodeError:
|
||||
print(f"Notion API Rohantwort: {e.response.text}")
|
||||
try:
|
||||
if e.response: # Wenn eine Antwort vorhanden ist
|
||||
error_details = e.response.json()
|
||||
print(f"Notion API Fehlerdetails: {json.dumps(error_details, indent=2)}")
|
||||
except json.JSONDecodeError:
|
||||
print(f"Notion API Rohantwort: {e.response.text}")
|
||||
return ""
|
||||
|
||||
|
||||
@@ -214,9 +227,9 @@ def get_database_status_options(token: str, db_id: str) -> List[str]:
|
||||
print(f"Fehler beim Abrufen der Datenbank-Eigenschaften: {e}")
|
||||
return []
|
||||
|
||||
def update_notion_task_property(token: str, task_id: str, payload: Dict) -> bool:
|
||||
"""Aktualisiert eine beliebige Eigenschaft eines Notion-Tasks."""
|
||||
print(f"\n--- Aktualisiere Eigenschaft von Task '{task_id}'... ---")
|
||||
def update_notion_task_property(token: str, task_id: str, prop_name: str, prop_value: Any, prop_type: str) -> bool:
|
||||
"""Aktualisiert eine bestimmte Eigenschaft (Property) eines Notion-Tasks."""
|
||||
print(f"\n--- Aktualisiere Task '{task_id}', Eigenschaft '{prop_name}' ({prop_type}) mit Wert '{prop_value}'... ---")
|
||||
url = f"https://api.notion.com/v1/pages/{task_id}"
|
||||
headers = {
|
||||
"Authorization": f"Bearer {token}",
|
||||
@@ -224,20 +237,36 @@ def update_notion_task_property(token: str, task_id: str, payload: Dict) -> bool
|
||||
"Notion-Version": "2022-06-28"
|
||||
}
|
||||
|
||||
update_payload = {"properties": payload}
|
||||
payload_properties = {}
|
||||
if prop_type == "status":
|
||||
payload_properties[prop_name] = {"status": {"name": prop_value}}
|
||||
elif prop_type == "number":
|
||||
payload_properties[prop_name] = {"number": prop_value}
|
||||
elif prop_type == "date":
|
||||
# Notion erwartet Datum im ISO 8601 Format
|
||||
if isinstance(prop_value, datetime):
|
||||
payload_properties[prop_name] = {"date": {"start": prop_value.isoformat()}}
|
||||
else:
|
||||
print(f"❌ FEHLER: Ungültiges Datumformat für '{prop_name}'. Erwartet datetime-Objekt.")
|
||||
return False
|
||||
# Weitere Typen können hier hinzugefügt werden (z.B. rich_text, multi_select etc.)
|
||||
else:
|
||||
print(f"❌ FEHLER: Nicht unterstützter Eigenschaftstyp '{prop_type}' für Update.")
|
||||
return False
|
||||
|
||||
payload = {"properties": payload_properties}
|
||||
|
||||
try:
|
||||
response = requests.patch(url, headers=headers, json=update_payload)
|
||||
response = requests.patch(url, headers=headers, json=payload)
|
||||
response.raise_for_status()
|
||||
print(f"✅ Task-Eigenschaft erfolgreich aktualisiert.")
|
||||
print(f"✅ Task-Eigenschaft '{prop_name}' erfolgreich aktualisiert.")
|
||||
return True
|
||||
except requests.exceptions.RequestException as e:
|
||||
print(f"❌ FEHLER beim Aktualisieren der Task-Eigenschaft: {e}")
|
||||
if e.response is not None:
|
||||
try:
|
||||
print(f"Antwort des Servers: {json.dumps(e.response.json(), indent=2)}")
|
||||
except:
|
||||
print(f"Antwort des Servers: {e.response.text}")
|
||||
print(f"❌ FEHLER beim Aktualisieren der Task-Eigenschaft '{prop_name}': {e}")
|
||||
try:
|
||||
print(f"Antwort des Servers: {json.dumps(e.response.json(), indent=2)}")
|
||||
except:
|
||||
print(f"Antwort des Servers: {e.response.text}")
|
||||
return False
|
||||
|
||||
def create_new_notion_task(token: str, project_id: str, tasks_db_id: str) -> Optional[Dict]:
|
||||
@@ -284,11 +313,10 @@ def create_new_notion_task(token: str, project_id: str, tasks_db_id: str) -> Opt
|
||||
return new_task
|
||||
except requests.exceptions.RequestException as e:
|
||||
print(f"❌ FEHLER beim Erstellen des Tasks: {e}")
|
||||
if e.response is not None:
|
||||
try:
|
||||
print(f"Antwort des Servers: {json.dumps(e.response.json(), indent=2)}")
|
||||
except:
|
||||
print(f"Antwort des Servers: {e.response.text}")
|
||||
try:
|
||||
print(f"Antwort des Servers: {json.dumps(e.response.json(), indent=2)}")
|
||||
except:
|
||||
print(f"Antwort des Servers: {e.response.text}")
|
||||
return None
|
||||
|
||||
def add_comment_to_notion_task(token: str, task_id: str, comment: str) -> bool:
|
||||
@@ -314,11 +342,10 @@ def add_comment_to_notion_task(token: str, task_id: str, comment: str) -> bool:
|
||||
return True
|
||||
except requests.exceptions.RequestException as e:
|
||||
print(f"❌ FEHLER beim Hinzufügen des Kommentars zum Notion-Task: {e}")
|
||||
if e.response is not None:
|
||||
try:
|
||||
print(f"Antwort des Servers: {json.dumps(e.response.json(), indent=2)}")
|
||||
except:
|
||||
print(f"Antwort des Servers: {e.response.text}")
|
||||
try:
|
||||
print(f"Antwort des Servers: {json.dumps(e.response.json(), indent=2)}")
|
||||
except:
|
||||
print(f"Antwort des Servers: {e.response.text}")
|
||||
return False
|
||||
|
||||
def append_blocks_to_notion_page(token: str, page_id: str, blocks: List[Dict]) -> bool:
|
||||
@@ -337,11 +364,10 @@ def append_blocks_to_notion_page(token: str, page_id: str, blocks: List[Dict]) -
|
||||
return True
|
||||
except requests.exceptions.RequestException as e:
|
||||
print(f"❌ FEHLER beim Anhängen des Statusberichts an die Notion-Seite: {e}")
|
||||
if e.response is not None:
|
||||
try:
|
||||
print(f"Antwort des Servers: {json.dumps(e.response.json(), indent=2)}")
|
||||
except:
|
||||
print(f"Antwort des Servers: {e.response.text}")
|
||||
try:
|
||||
print(f"Antwort des Servers: {json.dumps(e.response.json(), indent=2)}")
|
||||
except:
|
||||
print(f"Antwort des Servers: {e.response.text}")
|
||||
return False
|
||||
|
||||
# --- Session Management ---
|
||||
@@ -349,6 +375,16 @@ def append_blocks_to_notion_page(token: str, page_id: str, blocks: List[Dict]) -
|
||||
SESSION_DIR = ".dev_session"
|
||||
SESSION_FILE_PATH = os.path.join(SESSION_DIR, "SESSION_INFO")
|
||||
|
||||
def save_session_info(task_id: str, token: str):
|
||||
"""Speichert die Task-ID und den Token für den Git-Hook."""
|
||||
os.makedirs(SESSION_DIR, exist_ok=True)
|
||||
session_data = {
|
||||
"task_id": task_id,
|
||||
"token": token
|
||||
}
|
||||
with open(SESSION_FILE_PATH, "w") as f:
|
||||
json.dump(session_data, f)
|
||||
|
||||
def install_git_hook():
|
||||
"""Installiert das notion_commit_hook.py Skript als post-commit Git-Hook."""
|
||||
git_hooks_dir = os.path.join(".git", "hooks")
|
||||
@@ -403,7 +439,7 @@ def select_project(token: str) -> Optional[Tuple[Dict, Optional[str]]]:
|
||||
choice = int(input("Bitte wähle eine Nummer: "))
|
||||
if 1 <= choice <= len(projects):
|
||||
selected_project = projects[choice - 1]
|
||||
readme_path = get_page_property(selected_project, "Readme Path")
|
||||
readme_path = get_page_rich_text_property(selected_project, "Readme Path")
|
||||
return selected_project, readme_path
|
||||
else:
|
||||
print("Ungültige Auswahl.")
|
||||
@@ -443,10 +479,6 @@ def select_task(token: str, project_id: str, tasks_db_id: str) -> Optional[Dict]
|
||||
|
||||
import subprocess
|
||||
from datetime import datetime
|
||||
from zoneinfo import ZoneInfo # Für Zeitzonen-Handling
|
||||
|
||||
# Definiere die Zeitzone für Berlin
|
||||
BERLIN_TZ = ZoneInfo("Europe/Berlin")
|
||||
|
||||
# --- Git Summary Generation ---
|
||||
|
||||
@@ -482,34 +514,6 @@ def generate_git_summary() -> Tuple[str, str]:
|
||||
print(f"❌ FEHLER beim Generieren der Git-Zusammenfassung: {e}")
|
||||
return "", ""
|
||||
|
||||
def git_push_with_retry() -> bool:
|
||||
"""Versucht, Änderungen zu pushen, und führt bei einem non-fast-forward-Fehler einen Rebase und erneuten Push durch."""
|
||||
print("\n--- Führe git push aus ---")
|
||||
try:
|
||||
subprocess.run(["git", "push"], check=True)
|
||||
print("✅ Git push erfolgreich.")
|
||||
return True
|
||||
except subprocess.CalledProcessError as e:
|
||||
if "non-fast-forward" in e.stderr.decode("utf-8"):
|
||||
print("⚠️ Git push abgelehnt (non-fast-forward). Versuche git pull --rebase und erneuten Push...")
|
||||
try:
|
||||
subprocess.run(["git", "pull", "--rebase"], check=True)
|
||||
print("✅ Git pull --rebase erfolgreich. Versuche erneuten Push...")
|
||||
subprocess.run(["git", "push"], check=True)
|
||||
print("✅ Git push nach Rebase erfolgreich.")
|
||||
return True
|
||||
except subprocess.CalledProcessError as pull_e:
|
||||
print(f"❌ FEHLER bei git pull --rebase oder erneutem Push: {pull_e}")
|
||||
print("Bitte löse Konflikte manuell und pushe dann.")
|
||||
return False
|
||||
else:
|
||||
print(f"❌ FEHLER bei git push: {e}")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"❌ Unerwarteter Fehler bei git push: {e}")
|
||||
return False
|
||||
|
||||
|
||||
# --- Report Status to Notion ---
|
||||
|
||||
def report_status_to_notion(
|
||||
@@ -531,51 +535,68 @@ def report_status_to_notion(
|
||||
session_data = json.load(f)
|
||||
task_id = session_data.get("task_id")
|
||||
token = session_data.get("token")
|
||||
session_start_time_str = session_data.get("session_start_time")
|
||||
|
||||
if not (task_id and token):
|
||||
print("❌ FEHLER: Session-Daten unvollständig. Kann keinen Statusbericht erstellen.")
|
||||
if not (task_id and token and session_start_time_str):
|
||||
print("❌ FEHLER: Session-Daten unvollständig oder Startzeit fehlt. Kann keinen Statusbericht erstellen.")
|
||||
return
|
||||
|
||||
try:
|
||||
session_start_time = datetime.fromisoformat(session_start_time_str)
|
||||
except ValueError:
|
||||
print(f"❌ FEHLER: Ungültiges Startzeitformat in Session-Daten: {session_start_time_str}")
|
||||
return
|
||||
|
||||
# Time tracking logic
|
||||
session_start_time_str = session_data.get("session_start_time")
|
||||
if session_start_time_str:
|
||||
session_start_time = datetime.fromisoformat(session_start_time_str)
|
||||
elapsed_time = datetime.now() - session_start_time
|
||||
elapsed_hours = elapsed_time.total_seconds() / 3600
|
||||
|
||||
# Get current task page to read existing duration
|
||||
# Note: This is a simplified way. A more robust solution might query the DB
|
||||
# to get the page object without a separate API call if we already have it.
|
||||
# For now, a direct API call is clear and ensures we have the latest data.
|
||||
task_page_url = f"https://api.notion.com/v1/pages/{task_id}"
|
||||
headers = {
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Notion-Version": "2022-06-28"
|
||||
}
|
||||
try:
|
||||
page_response = requests.get(task_page_url, headers=headers)
|
||||
page_response.raise_for_status()
|
||||
task_page = page_response.json()
|
||||
|
||||
current_duration = get_page_number_property(task_page, "Total Duration (h)") or 0.0
|
||||
new_total_duration = current_duration + elapsed_hours
|
||||
|
||||
duration_payload = {
|
||||
"Total Duration (h)": {
|
||||
"number": new_total_duration
|
||||
}
|
||||
}
|
||||
update_notion_task_property(token, task_id, duration_payload)
|
||||
print(f"✅ Zeiterfassung: {elapsed_hours:.2f} Stunden zum Task hinzugefügt. Neue Gesamtdauer: {new_total_duration:.2f} Stunden.")
|
||||
|
||||
# Reset session start time for the next interval
|
||||
save_session_info(task_id, token)
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
print(f"❌ FEHLER beim Abrufen der Task-Details für die Zeiterfassung: {e}")
|
||||
|
||||
print(f"--- Erstelle Statusbericht für Task {task_id} ---")
|
||||
|
||||
tasks_db_id = find_database_by_title(token, "Tasks [UT]")
|
||||
if not tasks_db_id:
|
||||
return
|
||||
|
||||
# 1. Aktuelles Task-Objekt abrufen, um die vorhandene Dauer zu lesen
|
||||
page_url = f"https://api.notion.com/v1/pages/{task_id}"
|
||||
headers = {
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Content-Type": "application/json",
|
||||
"Notion-Version": "2022-06-28"
|
||||
}
|
||||
current_task_page = None
|
||||
try:
|
||||
response = requests.get(page_url, headers=headers)
|
||||
response.raise_for_status()
|
||||
current_task_page = response.json()
|
||||
except requests.exceptions.RequestException as e:
|
||||
print(f"❌ FEHLER beim Abrufen der Task-Seite {task_id}: {e}")
|
||||
return
|
||||
|
||||
if not current_task_page:
|
||||
print(f"❌ FEHLER: Konnte den Task {task_id} in Notion nicht finden. Zeiterfassung wird übersprungen.")
|
||||
return
|
||||
|
||||
# 2. Bestehende Total Duration (h) abrufen
|
||||
existing_duration = get_page_number_property(current_task_page, "Total Duration (h)")
|
||||
if existing_duration is None:
|
||||
existing_duration = 0.0
|
||||
|
||||
# 3. Dauer der aktuellen Arbeitseinheit berechnen
|
||||
current_end_time = datetime.now()
|
||||
time_spent = (current_end_time - session_start_time).total_seconds() / 3600.0 # Dauer in Stunden
|
||||
|
||||
# 4. Neue Gesamtdauer berechnen
|
||||
new_total_duration = existing_duration + time_spent
|
||||
|
||||
# 5. Total Duration (h) in Notion aktualisieren
|
||||
update_notion_task_property(token, task_id, "Total Duration (h)", round(new_total_duration, 2), "number")
|
||||
|
||||
# 6. session_start_time in SESSION_INFO für die nächste Arbeitseinheit aktualisieren
|
||||
# Hier speichern wir die Endzeit als neue Startzeit für die nächste mögliche Einheit
|
||||
# oder löschen sie, wenn die Session als beendet betrachtet wird.
|
||||
# Entsprechend des Plans setzen wir sie zurück (auf die aktuelle Endzeit der Arbeitseinheit).
|
||||
session_data["session_start_time"] = current_end_time.isoformat()
|
||||
with open(SESSION_FILE_PATH, "w") as f:
|
||||
json.dump(session_data, f)
|
||||
|
||||
|
||||
# Git-Zusammenfassung generieren (immer, wenn nicht explizit überschrieben)
|
||||
actual_git_changes = git_changes_override
|
||||
actual_commit_messages = commit_messages_override
|
||||
@@ -608,8 +629,8 @@ def report_status_to_notion(
|
||||
except ValueError:
|
||||
print("Ungültige Eingabe. Bitte eine Zahl eingeben.")
|
||||
else:
|
||||
print("❌ FEHLER: Konnte Status-Optionen nicht abrufen. Abbruch des Berichts.")
|
||||
return
|
||||
print("Warnung: Konnte Status-Optionen nicht abrufen. Bitte Status manuell eingeben.")
|
||||
actual_status = input("Bitte gib den neuen Status manuell ein: ")
|
||||
|
||||
if not actual_status:
|
||||
print("❌ FEHLER: Kein Status festgelegt. Abbruch des Berichts.")
|
||||
@@ -636,12 +657,6 @@ def report_status_to_notion(
|
||||
# Kommentar zusammenstellen
|
||||
report_lines = []
|
||||
# Diese Zeilen werden jetzt innerhalb des Code-Blocks formatiert
|
||||
|
||||
# Add invested time to the report if available
|
||||
if 'elapsed_hours' in locals():
|
||||
elapsed_hhmm = decimal_hours_to_hhmm(elapsed_hours)
|
||||
report_lines.append(f"Investierte Zeit in dieser Session: {elapsed_hhmm}")
|
||||
|
||||
report_lines.append(f"Neuer Status: {actual_status}")
|
||||
|
||||
if actual_summary:
|
||||
@@ -664,13 +679,13 @@ def report_status_to_notion(
|
||||
report_content = "\n".join(report_lines)
|
||||
|
||||
# Notion Blöcke für die API erstellen
|
||||
timestamp = datetime.now(BERLIN_TZ).strftime('%Y-%m-%d %H:%M')
|
||||
timestamp = datetime.now().strftime('%Y-%m-%d %H:%M')
|
||||
notion_blocks = [
|
||||
{
|
||||
"object": "block",
|
||||
"type": "heading_2",
|
||||
"heading_2": {
|
||||
"rich_text": [{"type": "text", "text": {"content": f"🤖 Status-Update ({timestamp} Berlin Time)"}}]
|
||||
"rich_text": [{"type": "text", "text": {"content": f"🤖 Status-Update ({timestamp})"}}]
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -685,37 +700,7 @@ def report_status_to_notion(
|
||||
|
||||
# Notion aktualisieren
|
||||
append_blocks_to_notion_page(token, task_id, notion_blocks)
|
||||
status_payload = {"Status": {"status": {"name": actual_status}}}
|
||||
update_notion_task_property(token, task_id, status_payload)
|
||||
|
||||
# --- Git Operationen ---
|
||||
print("\n--- Führe Git-Operationen aus ---")
|
||||
try:
|
||||
subprocess.run(["git", "add", "."], check=True)
|
||||
print("✅ Alle Änderungen gestaged (git add .).")
|
||||
|
||||
# Prüfen, ob es Änderungen zum Committen gibt
|
||||
git_status_output = subprocess.run(["git", "status", "--porcelain"], capture_output=True, text=True, check=True).stdout.strip()
|
||||
if not git_status_output:
|
||||
print("⚠️ Keine Änderungen zum Committen gefunden. Überspringe git commit.")
|
||||
return # Beende die Funktion, da nichts zu tun ist
|
||||
|
||||
# Commit-Nachricht erstellen
|
||||
commit_subject = actual_summary.splitlines()[0] if actual_summary else "Notion Status Update"
|
||||
commit_message = f"[{task_id.split('-')[0]}] {commit_subject}\n\n{actual_summary}"
|
||||
|
||||
subprocess.run(["git", "commit", "-m", commit_message], check=True)
|
||||
print("✅ Git commit erfolgreich.")
|
||||
|
||||
# Interaktive Abfrage für git push
|
||||
push_choice = input("\n✅ Commit erfolgreich erstellt. Sollen die Änderungen jetzt gepusht werden? (j/n): ").lower()
|
||||
if push_choice == 'j':
|
||||
git_push_with_retry()
|
||||
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f"❌ FEHLER bei Git-Operationen: {e}")
|
||||
except Exception as e:
|
||||
print(f"❌ Unerwarteter Fehler bei Git-Operationen: {e}")
|
||||
update_notion_task_property(token, task_id, "Status", actual_status, "status")
|
||||
|
||||
except (FileNotFoundError, json.JSONDecodeError) as e:
|
||||
print(f"❌ FEHLER beim Lesen der Session-Informationen für Statusbericht: {e}")
|
||||
@@ -726,7 +711,7 @@ def report_status_to_notion(
|
||||
# --- Context Generation ---
|
||||
|
||||
|
||||
def generate_cli_context(project_title: str, task_title: str, task_id: str, readme_path: Optional[str], task_description: Optional[str], total_duration: float) -> str:
|
||||
def generate_cli_context(project_title: str, task_title: str, task_id: str, readme_path: Optional[str], task_description: Optional[str]) -> str:
|
||||
"""Erstellt den reinen Kontext-String für die Gemini CLI."""
|
||||
|
||||
# Fallback, falls kein Pfad in Notion gesetzt ist
|
||||
@@ -739,14 +724,10 @@ def generate_cli_context(project_title: str, task_title: str, task_id: str, read
|
||||
f"\n**Aufgabenbeschreibung:**\n"
|
||||
f"```\n{task_description}\n```\n"
|
||||
)
|
||||
|
||||
duration_hhmm = decimal_hours_to_hhmm(total_duration)
|
||||
duration_part = f"Bisher erfasster Zeitaufwand (Notion): {duration_hhmm} Stunden.\n"
|
||||
|
||||
context = (
|
||||
f"Ich arbeite jetzt am Projekt '{project_title}'. Der Fokus liegt auf dem Task '{task_title}'.\n"
|
||||
f"{description_part}\n"
|
||||
f"{duration_part}"
|
||||
"Die relevanten Dateien für dieses Projekt sind wahrscheinlich:\n"
|
||||
"- Die primäre Projektdokumentation: @readme.md\n"
|
||||
f"- Die spezifische Dokumentation für dieses Modul: @{readme_path}\n\n"
|
||||
@@ -759,20 +740,39 @@ def generate_cli_context(project_title: str, task_title: str, task_id: str, read
|
||||
|
||||
# Die start_gemini_cli Funktion wird entfernt, da das aufrufende Skript jetzt die Gemini CLI startet.
|
||||
|
||||
def save_session_info(task_id: str, token: str):
|
||||
"""Speichert die Task-ID, den Token und den Startzeitpunkt für den Git-Hook."""
|
||||
def save_session_info(task_id: str, token: str, session_start_time: datetime):
|
||||
"""Speichert die Task-ID, den Token und die Startzeit der Session."""
|
||||
os.makedirs(SESSION_DIR, exist_ok=True)
|
||||
session_data = {
|
||||
"task_id": task_id,
|
||||
"token": token,
|
||||
"session_start_time": datetime.now().isoformat()
|
||||
"session_start_time": session_start_time.isoformat() # Speichern als ISO-Format String
|
||||
}
|
||||
with open(SESSION_FILE_PATH, "w") as f:
|
||||
json.dump(session_data, f)
|
||||
|
||||
def install_git_hook():
|
||||
"""Installiert das notion_commit_hook.py Skript als post-commit Git-Hook."""
|
||||
pass
|
||||
git_hooks_dir = os.path.join(".git", "hooks")
|
||||
post_commit_hook_path = os.path.join(git_hooks_dir, "post-commit")
|
||||
source_hook_script = "notion_commit_hook.py"
|
||||
|
||||
if not os.path.exists(git_hooks_dir):
|
||||
# Wahrscheinlich kein Git-Repository, also nichts tun
|
||||
return
|
||||
|
||||
if not os.path.exists(source_hook_script):
|
||||
print(f"Warnung: Hook-Skript {source_hook_script} nicht gefunden. Hook wird nicht installiert.")
|
||||
return
|
||||
|
||||
try:
|
||||
# Kopiere das Skript und mache es ausführbar
|
||||
shutil.copy(source_hook_script, post_commit_hook_path)
|
||||
os.chmod(post_commit_hook_path, 0o755)
|
||||
print("✅ Git-Hook für Notion-Kommentare erfolgreich installiert.")
|
||||
|
||||
except (IOError, OSError) as e:
|
||||
print(f"❌ FEHLER beim Installieren des Git-Hooks: {e}")
|
||||
|
||||
def cleanup_session():
|
||||
"""Bereinigt die Session-Datei und den Git-Hook."""
|
||||
@@ -805,8 +805,7 @@ def complete_session():
|
||||
status_options = get_database_status_options(token, tasks_db_id)
|
||||
if status_options:
|
||||
done_status = status_options[-1]
|
||||
status_payload = {"Status": {"status": {"name": done_status}}}
|
||||
update_notion_task_property(token, task_id, status_payload)
|
||||
update_notion_task_property(token, task_id, "Status", done_status, "status")
|
||||
|
||||
except (FileNotFoundError, json.JSONDecodeError):
|
||||
print("Fehler beim Lesen der Session-Informationen.")
|
||||
@@ -860,14 +859,11 @@ def start_interactive_session():
|
||||
task_id = selected_task["id"]
|
||||
print(f"\nTask '{task_title}' ausgewählt.")
|
||||
|
||||
# NEU: Lade die Task-Beschreibung und die bisherige Dauer
|
||||
# NEU: Lade die Task-Beschreibung
|
||||
task_description = get_page_content(token, task_id)
|
||||
total_duration_decimal = get_page_number_property(selected_task, "Total Duration (h)") or 0.0
|
||||
total_duration_hhmm = decimal_hours_to_hhmm(total_duration_decimal)
|
||||
print(f"> Bisher für diesen Task erfasst: {total_duration_hhmm} Stunden.")
|
||||
|
||||
# Session-Informationen für den Git-Hook speichern
|
||||
save_session_info(task_id, token)
|
||||
save_session_info(task_id, token, datetime.now())
|
||||
|
||||
# Git-Hook installieren, der die Session-Infos nutzt
|
||||
install_git_hook()
|
||||
@@ -878,8 +874,7 @@ def start_interactive_session():
|
||||
|
||||
suggested_branch_name = f"feature/task-{task_id.split('-')[0]}-{title_slug}"
|
||||
|
||||
status_payload = {"Status": {"status": {"name": "Doing"}}}
|
||||
status_updated = update_notion_task_property(token, task_id, status_payload)
|
||||
status_updated = update_notion_task_property(token, task_id, "Status", "Doing", "status")
|
||||
if not status_updated:
|
||||
print("Warnung: Notion-Task-Status konnte nicht aktualisiert werden.")
|
||||
|
||||
@@ -892,7 +887,7 @@ def start_interactive_session():
|
||||
print("------------------------------------------------------------------")
|
||||
|
||||
# CLI-Kontext generieren und an stdout ausgeben, damit das Startskript ihn aufgreifen kann
|
||||
cli_context = generate_cli_context(project_title, task_title, task_id, readme_path, task_description, total_duration_decimal)
|
||||
cli_context = generate_cli_context(project_title, task_title, task_id, readme_path, task_description)
|
||||
print("\n---GEMINI_CLI_CONTEXT_START---")
|
||||
print(cli_context)
|
||||
print("---GEMINI_CLI_CONTEXT_END---")
|
||||
@@ -904,7 +899,6 @@ def start_interactive_session():
|
||||
|
||||
def main():
|
||||
"""Hauptfunktion des Skripts."""
|
||||
# Test-Kommentar für den Workflow-Test
|
||||
parser = argparse.ArgumentParser(description="Interaktiver Session-Manager für die Gemini-Entwicklung mit Notion-Integration.")
|
||||
parser.add_argument("--done", action="store_true", help="Schließt die aktuelle Entwicklungs-Session ab.")
|
||||
parser.add_argument("--add-comment", type=str, help="Fügt einen Kommentar zum aktuellen Notion-Task hinzu.")
|
||||
|
||||
@@ -1,285 +1,229 @@
|
||||
# WICHTIGER HINWEIS FÜR SPRACHMODELLE UND ENTWICKLER:
|
||||
# Diese docker-compose.yml Datei ist die zentrale Orchestrierungsdatei für ALLE Docker-Services dieses Projekts.
|
||||
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
# --- GATEKEEPER (NGINX) ---
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
container_name: gateway_proxy
|
||||
# --- CENTRAL GATEWAY (Reverse Proxy with Auth) ---
|
||||
proxy:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.proxy
|
||||
container_name: gemini-gateway
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8090:80" # Synology Reverse Proxy should point to THIS port (8090)
|
||||
- "8090:80"
|
||||
volumes:
|
||||
# Use clean config to avoid caching issues
|
||||
- ./nginx-proxy-clean.conf:/etc/nginx/nginx.conf:ro
|
||||
- ./.htpasswd:/etc/nginx/.htpasswd:ro
|
||||
- ./nginx-proxy.conf:/etc/nginx/nginx.conf
|
||||
depends_on:
|
||||
dashboard:
|
||||
condition: service_started
|
||||
company-explorer:
|
||||
condition: service_healthy
|
||||
connector-superoffice:
|
||||
condition: service_healthy
|
||||
- dashboard
|
||||
- b2b-app
|
||||
- market-frontend
|
||||
- company-explorer
|
||||
- competitor-analysis
|
||||
- content-app
|
||||
|
||||
# --- DASHBOARD ---
|
||||
dashboard:
|
||||
image: nginx:alpine
|
||||
container_name: dashboard
|
||||
# ... [existing services] ...
|
||||
|
||||
content-app:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: content-engine/Dockerfile
|
||||
container_name: content-app
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./dashboard:/usr/share/nginx/html:ro
|
||||
- ./content-engine:/app/content-engine
|
||||
- ./content-engine/server.cjs:/app/server.cjs
|
||||
- ./content-engine/content_orchestrator.py:/app/content_orchestrator.py
|
||||
- ./content-engine/content_db_manager.py:/app/content_db_manager.py
|
||||
- ./content_engine.db:/app/content_engine.db
|
||||
- ./helpers.py:/app/helpers.py
|
||||
- ./config.py:/app/config.py
|
||||
- ./gtm_projects.db:/app/gtm_projects.db
|
||||
- ./Log_from_docker:/app/Log_from_docker
|
||||
- ./gemini_api_key.txt:/app/gemini_api_key.txt
|
||||
- ./serpapikey.txt:/app/serpapikey.txt
|
||||
environment:
|
||||
- PYTHONUNBUFFERED=1
|
||||
- DB_PATH=/app/content_engine.db
|
||||
- GTM_DB_PATH=/app/gtm_projects.db
|
||||
|
||||
# --- APPS ---
|
||||
|
||||
# --- DASHBOARD (Landing Page) ---
|
||||
dashboard:
|
||||
build:
|
||||
context: ./dashboard
|
||||
dockerfile: Dockerfile.dashboard
|
||||
container_name: gemini-dashboard
|
||||
restart: unless-stopped
|
||||
|
||||
# --- COMPANY EXPLORER (Robotics Edition) ---
|
||||
company-explorer:
|
||||
build:
|
||||
context: ./company-explorer
|
||||
dockerfile: Dockerfile
|
||||
container_name: company-explorer
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8000:8000"
|
||||
environment:
|
||||
API_USER: "admin"
|
||||
API_PASSWORD: "gemini"
|
||||
PYTHONUNBUFFERED: "1"
|
||||
# Correct path for DB inside the mounted volume
|
||||
DATABASE_URL: "sqlite:////data/companies_v3_fixed_2.db"
|
||||
# Keys passed from .env
|
||||
GEMINI_API_KEY: "${GEMINI_API_KEY}"
|
||||
SERP_API_KEY: "${SERP_API}"
|
||||
NOTION_TOKEN: "${NOTION_API_KEY}"
|
||||
volumes:
|
||||
# Sideloading: Source Code (Hot Reload)
|
||||
- ./company-explorer:/app
|
||||
# Mount named volume to a DIRECTORY, not a file
|
||||
- explorer_db_data:/data
|
||||
# DATABASE (Persistence)
|
||||
- ./companies_v3_fixed_2.db:/app/companies_v3_fixed_2.db
|
||||
# Keys
|
||||
- ./gemini_api_key.txt:/app/gemini_api_key.txt
|
||||
- ./serpapikey.txt:/app/serpapikey.txt
|
||||
- ./notion_token.txt:/app/notion_token.txt
|
||||
# Logs (Debug)
|
||||
- ./Log_from_docker:/app/logs_debug
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8000/docs"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
|
||||
connector-superoffice:
|
||||
build:
|
||||
context: ./connector-superoffice
|
||||
dockerfile: Dockerfile
|
||||
container_name: connector-superoffice
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8003:8000" # Expose internal 8000 to host 8003
|
||||
volumes:
|
||||
- ./connector-superoffice:/app
|
||||
# Mount named volume to a DIRECTORY matching the Python code's expectation
|
||||
- connector_db_data:/data
|
||||
environment:
|
||||
PYTHONUNBUFFERED: "1"
|
||||
API_USER: "admin"
|
||||
API_PASSWORD: "gemini"
|
||||
# Correct path for DB inside the mounted volume
|
||||
DB_PATH: "/app/data/connector_queue.db"
|
||||
COMPANY_EXPLORER_URL: "http://company-explorer:8000"
|
||||
# Keys passed from .env
|
||||
GEMINI_API_KEY: "${GEMINI_API_KEY}"
|
||||
SO_CLIENT_ID: "${SO_CLIENT_ID}"
|
||||
SO_CLIENT_SECRET: "${SO_CLIENT_SECRET}"
|
||||
SO_REFRESH_TOKEN: "${SO_REFRESH_TOKEN}"
|
||||
SO_ENVIRONMENT: "${SO_ENVIRONMENT}"
|
||||
SO_CONTEXT_IDENTIFIER: "${SO_CONTEXT_IDENTIFIER}"
|
||||
# Webhook Security
|
||||
WEBHOOK_TOKEN: "${WEBHOOK_TOKEN}"
|
||||
# Mappings
|
||||
VERTICAL_MAP_JSON: "${VERTICAL_MAP_JSON}"
|
||||
PERSONA_MAP_JSON: "${PERSONA_MAP_JSON}"
|
||||
# User Defined Fields (UDFs)
|
||||
UDF_SUBJECT: "${UDF_SUBJECT}"
|
||||
UDF_INTRO: "${UDF_INTRO}"
|
||||
UDF_SOCIAL_PROOF: "${UDF_SOCIAL_PROOF}"
|
||||
UDF_OPENER: "${UDF_OPENER}"
|
||||
UDF_OPENER_SECONDARY: "${UDF_OPENER_SECONDARY}"
|
||||
UDF_VERTICAL: "${UDF_VERTICAL}"
|
||||
UDF_CAMPAIGN: "${UDF_CAMPAIGN}"
|
||||
UDF_UNSUBSCRIBE_LINK: "${UDF_UNSUBSCRIBE_LINK}"
|
||||
UDF_SUMMARY: "${UDF_SUMMARY}"
|
||||
UDF_LAST_UPDATE: "${UDF_LAST_UPDATE}"
|
||||
UDF_LAST_OUTREACH: "${UDF_LAST_OUTREACH}"
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
- PYTHONUNBUFFERED=1
|
||||
# Port 8000 is internal only
|
||||
|
||||
# --- DISABLED SERVICES (Commented out but preserved) ---
|
||||
|
||||
# heatmap-backend:
|
||||
# build: ./heatmap-tool/backend
|
||||
# container_name: heatmap-backend
|
||||
# restart: unless-stopped
|
||||
# volumes:
|
||||
# - ./heatmap-tool/backend:/app
|
||||
# --- TRANSCRIPTION TOOL (Meeting Assistant) ---
|
||||
transcription-app:
|
||||
build:
|
||||
context: ./transcription-tool
|
||||
dockerfile: Dockerfile
|
||||
container_name: transcription-app
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./transcription-tool/backend:/app/backend
|
||||
- ./transcripts.db:/app/transcripts.db
|
||||
- ./uploads_audio:/app/uploads_audio
|
||||
- ./gemini_api_key.txt:/app/gemini_api_key.txt
|
||||
environment:
|
||||
- PYTHONUNBUFFERED=1
|
||||
- DATABASE_URL=sqlite:////app/transcripts.db
|
||||
- GEMINI_API_KEY=AIzaSyCFRmr1rOrkFKiEuh9GOCJNB2zfJsYmR68
|
||||
ports:
|
||||
- "8001:8001"
|
||||
depends_on:
|
||||
- proxy
|
||||
# --- B2B MARKETING ASSISTANT ---
|
||||
b2b-app:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.b2b
|
||||
container_name: b2b-assistant
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
# Sideloading: Python Logic
|
||||
- ./b2b_marketing_orchestrator.py:/app/b2b_marketing_orchestrator.py
|
||||
- ./market_db_manager.py:/app/market_db_manager.py
|
||||
# Sideloading: Server Logic
|
||||
- ./b2b-marketing-assistant/server.cjs:/app/server.cjs
|
||||
# Database Persistence
|
||||
- ./b2b_projects.db:/app/b2b_projects.db
|
||||
# Logs
|
||||
- ./Log_from_docker:/app/Log_from_docker
|
||||
# Keys
|
||||
- ./gemini_api_key.txt:/app/gemini_api_key.txt
|
||||
environment:
|
||||
- PYTHONUNBUFFERED=1
|
||||
- DB_PATH=/app/b2b_projects.db
|
||||
# Port 3002 is internal only
|
||||
|
||||
# heatmap-frontend:
|
||||
# build: ./heatmap-tool/frontend
|
||||
# container_name: heatmap-frontend
|
||||
# restart: unless-stopped
|
||||
# volumes:
|
||||
# - ./heatmap-tool/frontend:/app
|
||||
# depends_on:
|
||||
# - heatmap-backend
|
||||
# --- MARKET INTELLIGENCE BACKEND ---
|
||||
market-backend:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.market
|
||||
container_name: market-backend
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
# Sideloading: Python Logic & Config
|
||||
- ./market_intel_orchestrator.py:/app/market_intel_orchestrator.py
|
||||
- ./market_db_manager.py:/app/market_db_manager.py
|
||||
- ./config.py:/app/config.py
|
||||
- ./helpers.py:/app/helpers.py
|
||||
# Sideloading: Server Logic
|
||||
- ./general-market-intelligence/server.cjs:/app/general-market-intelligence/server.cjs
|
||||
# Database Persistence
|
||||
- ./market_intelligence.db:/app/market_intelligence.db
|
||||
# Logs & Keys
|
||||
- ./Log:/app/Log
|
||||
- ./gemini_api_key.txt:/app/gemini_api_key.txt
|
||||
- ./serpapikey.txt:/app/serpapikey.txt
|
||||
environment:
|
||||
- PYTHONUNBUFFERED=1
|
||||
- DB_PATH=/app/market_intelligence.db
|
||||
# Port 3001 is internal only
|
||||
|
||||
# transcription-app:
|
||||
# build:
|
||||
# context: ./transcription-tool
|
||||
# dockerfile: Dockerfile
|
||||
# container_name: transcription-app
|
||||
# restart: unless-stopped
|
||||
# volumes:
|
||||
# - ./transcription-tool/backend:/app/backend
|
||||
# - ./transcription-tool/frontend/dist:/app/frontend/dist
|
||||
# - ./transcripts.db:/app/transcripts.db
|
||||
# - ./uploads_audio:/app/uploads_audio
|
||||
# environment:
|
||||
# PYTHONUNBUFFERED: "1"
|
||||
# DATABASE_URL: "sqlite:////app/transcripts.db"
|
||||
# GEMINI_API_KEY: "${GEMINI_API_KEY}"
|
||||
# ports:
|
||||
# - "8001:8001"
|
||||
# --- MARKET INTELLIGENCE FRONTEND ---
|
||||
market-frontend:
|
||||
build:
|
||||
context: ./general-market-intelligence
|
||||
dockerfile: Dockerfile
|
||||
container_name: market-frontend
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- market-backend
|
||||
# Port 80 is internal only
|
||||
|
||||
# b2b-app:
|
||||
# build:
|
||||
# context: ./b2b-marketing-assistant
|
||||
# dockerfile: Dockerfile
|
||||
# container_name: b2b-assistant
|
||||
# restart: unless-stopped
|
||||
# volumes:
|
||||
# - ./b2b_marketing_orchestrator.py:/app/b2b_marketing_orchestrator.py
|
||||
# - ./market_db_manager.py:/app/market_db_manager.py
|
||||
# - ./b2b-marketing-assistant/server.cjs:/app/server.cjs
|
||||
# - ./b2b_projects.db:/app/b2b_projects.db
|
||||
# - ./Log_from_docker:/app/Log_from_docker
|
||||
# environment:
|
||||
# PYTHONUNBUFFERED: "1"
|
||||
# DB_PATH: "/app/b2b_projects.db"
|
||||
# GEMINI_API_KEY: "${GEMINI_API_KEY}"
|
||||
gtm-app:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: gtm-architect/Dockerfile
|
||||
container_name: gtm-app
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
# Sideloading for live development
|
||||
- ./gtm-architect:/app/gtm-architect
|
||||
- ./gtm-architect/server.cjs:/app/server.cjs
|
||||
- ./gtm_architect_orchestrator.py:/app/gtm_architect_orchestrator.py
|
||||
- ./helpers.py:/app/helpers.py
|
||||
- ./config.py:/app/config.py
|
||||
- ./gtm_db_manager.py:/app/gtm_db_manager.py
|
||||
- ./gtm_projects.db:/app/gtm_projects.db
|
||||
- ./Log_from_docker:/app/Log_from_docker
|
||||
- ./gemini_api_key.txt:/app/gemini_api_key.txt
|
||||
- ./serpapikey.txt:/app/serpapikey.txt
|
||||
environment:
|
||||
- PYTHONUNBUFFERED=1
|
||||
- DB_PATH=/app/gtm_projects.db
|
||||
|
||||
# market-backend:
|
||||
# build:
|
||||
# context: ./general-market-intelligence
|
||||
# dockerfile: Dockerfile
|
||||
# container_name: market-backend
|
||||
# restart: unless-stopped
|
||||
# volumes:
|
||||
# - ./market_intel_orchestrator.py:/app/market_intel_orchestrator.py
|
||||
# - ./market_db_manager.py:/app/market_db_manager.py
|
||||
# - ./config.py:/app/config.py
|
||||
# - ./helpers.py:/app/helpers.py
|
||||
# - ./general-market-intelligence/server.cjs:/app/general-market-intelligence/server.cjs
|
||||
# - ./market_intelligence.db:/app/market_intelligence.db
|
||||
# - ./Log:/app/Log
|
||||
# environment:
|
||||
# PYTHONUNBUFFERED: "1"
|
||||
# DB_PATH: "/app/market_intelligence.db"
|
||||
# GEMINI_API_KEY: "${GEMINI_API_KEY}"
|
||||
# SERPAPI_KEY: "${SERPAPI_KEY}"
|
||||
# --- COMPETITOR ANALYSIS AGENT ---
|
||||
competitor-analysis:
|
||||
build:
|
||||
context: ./competitor-analysis-app
|
||||
dockerfile: Dockerfile
|
||||
container_name: competitor-analysis
|
||||
restart: unless-stopped
|
||||
dns:
|
||||
- 8.8.8.8
|
||||
- 8.8.4.4
|
||||
volumes:
|
||||
# Sideloading: Python Orchestrator ONLY (to preserve built assets in /app/dist)
|
||||
- ./competitor-analysis-app/competitor_analysis_orchestrator.py:/app/competitor_analysis_orchestrator.py
|
||||
# Keys (passed via environment or file)
|
||||
- ./gemini_api_key.txt:/app/gemini_api_key.txt
|
||||
# Logs
|
||||
- ./Log_from_docker:/app/Log_from_docker
|
||||
environment:
|
||||
- PYTHONUNBUFFERED=1
|
||||
- GEMINI_API_KEY_FILE=/app/gemini_api_key.txt
|
||||
# Port 8000 is internal only
|
||||
|
||||
# market-frontend:
|
||||
# build:
|
||||
# context: ./general-market-intelligence
|
||||
# dockerfile: Dockerfile
|
||||
# container_name: market-frontend
|
||||
# restart: unless-stopped
|
||||
# depends_on:
|
||||
# - market-backend
|
||||
# --- DUCKDNS UPDATER ---
|
||||
duckdns:
|
||||
image: lscr.io/linuxserver/duckdns:latest
|
||||
container_name: duckdns
|
||||
environment:
|
||||
- PUID=1000 # User ID (anpassen falls nötig)
|
||||
- PGID=1000 # Group ID (anpassen falls nötig)
|
||||
- TZ=Europe/Berlin
|
||||
- SUBDOMAINS=floke,floke-ai,floke-gitea,floke-ha,floke-n8n
|
||||
- TOKEN=2e073b27-971e-4847-988c-73ad23e648d4
|
||||
restart: unless-stopped
|
||||
|
||||
# gtm-app:
|
||||
# build:
|
||||
# context: ./gtm-architect
|
||||
# dockerfile: Dockerfile
|
||||
# container_name: gtm-app
|
||||
# restart: unless-stopped
|
||||
# volumes:
|
||||
# - ./gtm-architect:/app/gtm-architect
|
||||
# - ./gtm-architect/server.cjs:/app/server.cjs
|
||||
# - ./gtm_architect_orchestrator.py:/app/gtm_architect_orchestrator.py
|
||||
# - ./helpers.py:/app/helpers.py
|
||||
# - ./config.py:/app/config.py
|
||||
# - ./gtm_db_manager.py:/app/gtm_db_manager.py
|
||||
# - ./gtm_projects.db:/app/gtm_projects.db
|
||||
# - ./Log_from_docker:/app/Log_from_docker
|
||||
# environment:
|
||||
# PYTHONUNBUFFERED: "1"
|
||||
# DB_PATH: "/app/gtm_projects.db"
|
||||
# GEMINI_API_KEY: "${GEMINI_API_KEY}"
|
||||
# SERPAPI_KEY: "${SERPAPI_KEY}"
|
||||
|
||||
# content-app:
|
||||
# build:
|
||||
# context: ./content-engine
|
||||
# dockerfile: Dockerfile
|
||||
# container_name: content-app
|
||||
# restart: unless-stopped
|
||||
# volumes:
|
||||
# - ./content-engine:/app/content-engine
|
||||
# - ./content-engine/server.cjs:/app/server.cjs
|
||||
# - ./content-engine/content_orchestrator.py:/app/content_orchestrator.py
|
||||
# - ./content-engine/content_db_manager.py:/app/content_db_manager.py
|
||||
# - ./content_engine.db:/app/content_engine.db
|
||||
# - ./helpers.py:/app/helpers.py
|
||||
# - ./config.py:/app/config.py
|
||||
# - ./gtm_projects.db:/app/gtm_projects.db
|
||||
# - ./Log_from_docker:/app/Log_from_docker
|
||||
# environment:
|
||||
# PYTHONUNBUFFERED: "1"
|
||||
# DB_PATH: "/app/content_engine.db"
|
||||
# GTM_DB_PATH: "/app/gtm_projects.db"
|
||||
# GEMINI_API_KEY: "${GEMINI_API_KEY}"
|
||||
# SERPAPI_KEY: "${SERPAPI_KEY}"
|
||||
|
||||
# competitor-analysis:
|
||||
# build:
|
||||
# context: ./competitor-analysis-app
|
||||
# dockerfile: Dockerfile
|
||||
# container_name: competitor-analysis
|
||||
# restart: unless-stopped
|
||||
# dns:
|
||||
# - 8.8.8.8
|
||||
# - 8.8.4.4
|
||||
# volumes:
|
||||
# - ./competitor-analysis-app/competitor_analysis_orchestrator.py:/app/competitor_analysis_orchestrator.py
|
||||
# - ./Log_from_docker:/app/Log_from_docker
|
||||
# environment:
|
||||
# PYTHONUNBUFFERED: "1"
|
||||
# GEMINI_API_KEY: "${GEMINI_API_KEY}"
|
||||
|
||||
# duckdns:
|
||||
# image: lscr.io/linuxserver/duckdns:latest
|
||||
# container_name: duckdns
|
||||
# environment:
|
||||
# PUID: "1000" # User ID (anpassen falls nötig)
|
||||
# PGID: "1000" # Group ID (anpassen falls nötig)
|
||||
# TZ: "Europe/Berlin"
|
||||
# SUBDOMAINS: "floke,floke-ai,floke-gitea,floke-ha,floke-n8n"
|
||||
# TOKEN: "2e073b27-971e-4847-988c-73ad23e648d4" # Actual token is in .env or config
|
||||
# restart: unless-stopped
|
||||
|
||||
# dns-monitor:
|
||||
# image: alpine
|
||||
# container_name: dns-monitor
|
||||
# dns:
|
||||
# - 8.8.8.8
|
||||
# - 1.1.1.1
|
||||
# environment:
|
||||
# SUBDOMAINS: "floke,floke-ai,floke-gitea,floke-ha,floke-n8n"
|
||||
# TZ: "Europe/Berlin"
|
||||
# volumes:
|
||||
# - ./dns-monitor:/app
|
||||
# command: "/app/monitor.sh"
|
||||
# restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
# moltbot_data: {}
|
||||
connector_db_data: {}
|
||||
explorer_db_data: {}
|
||||
# --- DNS MONITOR (Sidecar) ---
|
||||
dns-monitor:
|
||||
image: alpine
|
||||
container_name: dns-monitor
|
||||
dns:
|
||||
- 8.8.8.8
|
||||
- 1.1.1.1
|
||||
environment:
|
||||
- SUBDOMAINS=floke,floke-ai,floke-gitea,floke-ha,floke-n8n
|
||||
- TZ=Europe/Berlin
|
||||
volumes:
|
||||
- ./dns-monitor:/app
|
||||
command: /app/monitor.sh
|
||||
restart: unless-stopped
|
||||
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
@@ -1,109 +0,0 @@
|
||||
envelope = f"""<?xml version="1.0" encoding="utf-8"?>
|
||||
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xmlns:xsd="http://www.w3.org/2001/XMLSchema">
|
||||
<s:Header>
|
||||
<ApplicationToken xmlns="http://www.superoffice.com/superid/partnersystemuser/0.1">{APPLICATION_TOKEN}</ApplicationToken>
|
||||
<ContextIdentifier xmlns="http://www.superoffice.com/superid/partnersystemuser/0.1">{CONTEXT_IDENTIFIER}</ContextIdentifier>
|
||||
</s:Header>
|
||||
<s:Body>
|
||||
<AuthenticationRequest xmlns="http://www.superoffice.com/superid/partnersystemuser/0.1">
|
||||
<SignedSystemToken>{SIGNED_SYSTEM_TOKEN}</SignedSystemToken>
|
||||
<ReturnTokenType>{RETURN_TOKEN_TYPE}</ReturnTokenType>
|
||||
</AuthenticationRequest>
|
||||
</s:Body>
|
||||
</s:Envelope>
|
||||
""".strip()
|
||||
|
||||
headers = {
|
||||
"Content-Type": "text/xml; charset=utf-8",
|
||||
"SOAPAction": "http://www.superoffice.com/superid/partnersystemuser/0.1/IPartnerSystemUserService/Authenticate",
|
||||
}
|
||||
|
||||
resp = requests.post(
|
||||
SOAP_URL, data=envelope.encode("utf-8"), headers=headers, timeout=30
|
||||
)
|
||||
print(resp)
|
||||
# --- Useful diagnostics if server responds with HTML or error ---
|
||||
ct = resp.headers.get("Content-Type", "")
|
||||
if "xml" not in ct.lower():
|
||||
print("Unexpected response (not XML). Status:", resp.status_code)
|
||||
print("Content-Type:", ct)
|
||||
print(resp.text[:1200])
|
||||
raise SystemExit("Check URL, SOAPAction, or required SOAP headers/values.")
|
||||
|
||||
# --- Parse SOAP response per WSDL ---
|
||||
root = ET.fromstring(resp.text)
|
||||
ns = {
|
||||
"s": "http://schemas.xmlsoap.org/soap/envelope/",
|
||||
"tns": "http://www.superoffice.com/superid/partnersystemuser/0.1",
|
||||
}
|
||||
|
||||
# Fault?
|
||||
fault = root.find(".//s:Fault", ns)
|
||||
if fault is not None:
|
||||
print(resp.text)
|
||||
raise SystemExit("SOAP Fault returned. See XML above.")
|
||||
|
||||
# Extract AuthenticationResponse
|
||||
is_ok_el = root.find(".//tns:AuthenticationResponse/tns:IsSuccessful", ns)
|
||||
token_el = root.find(".//tns:AuthenticationResponse/tns:Token", ns)
|
||||
err_el = root.find(".//tns:AuthenticationResponse/tns:ErrorMessage", ns)
|
||||
|
||||
is_ok = is_ok_el is not None and (is_ok_el.text or "").strip().lower() == "true"
|
||||
token = token_el.text.strip() if token_el is not None and token_el.text else None
|
||||
error_msg = (err_el.text or "").strip() if err_el is not None else ""
|
||||
|
||||
if not is_ok:
|
||||
print("Authenticate returned IsSuccessful = false")
|
||||
print("ErrorMessage:", error_msg)
|
||||
print(resp.text)
|
||||
raise SystemExit("Authentication failed.")
|
||||
|
||||
print("Authenticate succeeded.")
|
||||
print("Token (truncated):", (token[:50] + "...") if token else None)
|
||||
|
||||
# If ReturnTokenType=Jwt, you can inspect claims to find the SOTicket claim key
|
||||
if RETURN_TOKEN_TYPE.lower() == "jwt" and token and "." in token:
|
||||
|
||||
def b64url_decode(seg: str) -> bytes:
|
||||
seg += "=" * ((4 - len(seg) % 4) % 4)
|
||||
return base64.urlsafe_b64decode(seg.encode("ascii"))
|
||||
|
||||
header_b64, payload_b64, sig_b64 = token.split(".", 2)
|
||||
payload = json.loads(b64url_decode(payload_b64))
|
||||
print("JWT payload keys:", list(payload.keys()))
|
||||
|
||||
# Try to locate a ticket-like claim (key name may vary by env)
|
||||
ticket = None
|
||||
for k, v in payload.items():
|
||||
if isinstance(v, str) and (
|
||||
"ticket" in k.lower() or v.startswith(("7T:", "8A:", "8C:"))
|
||||
):
|
||||
ticket = v
|
||||
break
|
||||
|
||||
if ticket:
|
||||
print("Extracted system-user ticket:", ticket)
|
||||
# Example REST call using SOTicket + SO-AppToken headers:
|
||||
TENANT_BASE = (
|
||||
"https://online.superoffice.com/Cust26703" # use your actual sodN host!
|
||||
)
|
||||
rest_headers = {
|
||||
"Authorization": f"SOTicket {ticket}",
|
||||
"SO-AppToken": APPLICATION_TOKEN, # same value as ApplicationToken
|
||||
"Accept": "application/json",
|
||||
}
|
||||
test = requests.get(
|
||||
f"{TENANT_BASE}/api/v1/contact/1", headers=rest_headers, timeout=30
|
||||
)
|
||||
print("REST test:", test.status_code, test.text)
|
||||
else:
|
||||
print(
|
||||
"No obvious 'ticket' claim found in JWT. Inspect payload above and pick the correct claim manually."
|
||||
)
|
||||
else:
|
||||
print(
|
||||
"Returned token is not a JWT (or you requested SAML). Use the token as intended by your flow."
|
||||
)
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
def make_signed_system_token(system_user_token: str, private_key_pem: str) -> str:
|
||||
# 1) stamp in UTC like yyyyMMddHHmm
|
||||
ts = datetime.now(timezone.utc).strftime("%Y%m%d%H%M")
|
||||
to_sign = f"{system_user_token}.{ts}".encode("utf-8")
|
||||
|
||||
# 2) load your RSA private key (PEM, PKCS#1 or PKCS#8)
|
||||
key = serialization.load_pem_private_key(
|
||||
private_key_pem.encode("utf-8"), password=None
|
||||
)
|
||||
|
||||
# 3) RSA-SHA256, PKCS#1 v1.5 padding, then Base64 (standard, not URL-safe)
|
||||
signature = key.sign(to_sign, padding.PKCS1v15(), hashes.SHA256())
|
||||
sig_b64 = base64.b64encode(signature).decode("ascii")
|
||||
|
||||
# 4) final SignedSystemToken
|
||||
return f"{system_user_token}.{ts}.{sig_b64}"
|
||||
|
||||
|
||||
# print(load_rsa_private_key_pem(PEM_STR)) # test loading key
|
||||
print(make_signed_system_token(SYSTEM_USER_TOKEN, PEM_STR))
|
||||
|
||||
@@ -1,112 +0,0 @@
|
||||
from typing import Dict, Optional, final
|
||||
|
||||
import requests
|
||||
import xmltodict
|
||||
from rest_framework import status
|
||||
|
||||
|
||||
class BaseAPI:
|
||||
__api_name__ = "BaseAPI"
|
||||
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
def get_host(self) -> str:
|
||||
raise NotImplementedError("Subclasses must implement _get_host() method")
|
||||
|
||||
def build_headers(self) -> Dict[str, str]:
|
||||
return {}
|
||||
|
||||
def build_params(self) -> Dict[str, str]:
|
||||
return {}
|
||||
|
||||
def repair_authentication(self) -> bool:
|
||||
"""Callback to repair authentication, e.g. refresh token or re-login."""
|
||||
return False
|
||||
|
||||
@final
|
||||
def request(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
data: Optional[Dict | str | bytes] = None,
|
||||
json: Optional[Dict] = None,
|
||||
headers: Optional[Dict[str, str]] = None,
|
||||
params: Optional[Dict[str, str]] = None,
|
||||
custom_host: Optional[str] = None,
|
||||
timeout: int = 30,
|
||||
use_build_headers: bool = True,
|
||||
) -> Dict:
|
||||
host = custom_host or self.get_host()
|
||||
url = f"{host}{path}"
|
||||
combined_headers: Optional[Dict[str, str]] = {
|
||||
**(headers or {}),
|
||||
}
|
||||
if use_build_headers:
|
||||
combined_headers = {
|
||||
**self.build_headers(),
|
||||
**(headers or {}),
|
||||
}
|
||||
combined_params: Optional[Dict[str, str]] = {
|
||||
**self.build_params(),
|
||||
**(params or {}),
|
||||
}
|
||||
|
||||
if combined_headers == {}:
|
||||
combined_headers = None
|
||||
if combined_params == {}:
|
||||
combined_params = None
|
||||
|
||||
try:
|
||||
response = requests.request(
|
||||
method=method,
|
||||
url=url,
|
||||
headers=combined_headers,
|
||||
params=combined_params,
|
||||
data=data,
|
||||
json=json,
|
||||
timeout=timeout,
|
||||
)
|
||||
if (
|
||||
response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
or response.status_code == status.HTTP_403_FORBIDDEN
|
||||
):
|
||||
if self.repair_authentication():
|
||||
host = custom_host or self.get_host()
|
||||
url = f"{host}{path}"
|
||||
combined_headers: Optional[Dict[str, str]] = {
|
||||
**self.build_headers(),
|
||||
**(headers or {}),
|
||||
}
|
||||
combined_params: Optional[Dict[str, str]] = {
|
||||
**self.build_params(),
|
||||
**(params or {}),
|
||||
}
|
||||
response = requests.request(
|
||||
method=method,
|
||||
url=url,
|
||||
headers=combined_headers,
|
||||
params=combined_params,
|
||||
data=data,
|
||||
timeout=timeout,
|
||||
)
|
||||
response.raise_for_status()
|
||||
if response.status_code == status.HTTP_204_NO_CONTENT:
|
||||
return {}
|
||||
|
||||
content_type = response.headers.get("Content-Type", "")
|
||||
|
||||
if "xml" in content_type:
|
||||
return xmltodict.parse(response.text)
|
||||
if "application/json" in content_type:
|
||||
return response.json()
|
||||
else:
|
||||
raise ValueError(
|
||||
"Unsupported Content-Type: {content_type} must be application/json"
|
||||
)
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
raise e
|
||||
except ValueError as e:
|
||||
raise e
|
||||
|
||||
@@ -1,226 +0,0 @@
|
||||
import base64
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Dict
|
||||
|
||||
import jwt
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import padding
|
||||
from django.conf import settings
|
||||
|
||||
from core.plugins.base_api import BaseAPI
|
||||
from core.plugins.super_office.ticket_store import RedisTicketStore, TicketRecord
|
||||
from core.plugins.super_office.utils import to_iso_z_datetime
|
||||
|
||||
|
||||
class SuperOfficeAPI(BaseAPI):
|
||||
__api_name__ = "SuperOfficeAPI"
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
template_path = Path(__file__).with_name(
|
||||
"partnersystemuser_envelope_template.xml"
|
||||
)
|
||||
self.envelope_template = template_path.read_text(encoding="utf-8").strip()
|
||||
self.webapi_base = None
|
||||
self.ticket = None
|
||||
self.redis_store = RedisTicketStore()
|
||||
|
||||
def get_host(self) -> str:
|
||||
if self.webapi_base is None:
|
||||
self.set_ticket_and_webapi_base()
|
||||
return self.webapi_base
|
||||
|
||||
def build_headers(self) -> Dict[str, str]:
|
||||
if self.ticket is None:
|
||||
self.set_ticket_and_webapi_base()
|
||||
return {
|
||||
"Authorization": f"SOTicket {self.ticket}",
|
||||
"SO-AppToken": settings.SUPER_OFFICE_APPLICATION_TOKEN,
|
||||
"Accept": "application/json",
|
||||
}
|
||||
|
||||
def repair_authentication(self) -> bool:
|
||||
new_ticket_data = self.get_ticket()
|
||||
if (
|
||||
not new_ticket_data
|
||||
or not new_ticket_data.get("ticket")
|
||||
or not new_ticket_data.get("webapi_base")
|
||||
):
|
||||
return False
|
||||
self.redis_store.set(
|
||||
TicketRecord(
|
||||
ticket=new_ticket_data["ticket"],
|
||||
webapi_base=new_ticket_data["webapi_base"],
|
||||
issued_at=datetime.now(timezone.utc).timestamp(),
|
||||
)
|
||||
)
|
||||
self.ticket = new_ticket_data["ticket"]
|
||||
self.webapi_base = new_ticket_data["webapi_base"]
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def generate_signed_system_token():
|
||||
"""
|
||||
# todo: doc
|
||||
"""
|
||||
ts = datetime.now(timezone.utc).strftime("%Y%m%d%H%M")
|
||||
to_sign = f"{settings.SUPER_OFFICE_SYSTEM_USER_TOKEN}.{ts}".encode("utf-8")
|
||||
key = serialization.load_pem_private_key(
|
||||
settings.SUPER_OFFICE_PRIVATE_KEY_PEM.encode("utf-8"), password=None
|
||||
)
|
||||
signature = key.sign(to_sign, padding.PKCS1v15(), hashes.SHA256())
|
||||
sig_b64 = base64.b64encode(signature).decode("ascii")
|
||||
return f"{settings.SUPER_OFFICE_SYSTEM_USER_TOKEN}.{ts}.{sig_b64}"
|
||||
|
||||
def get_ticket(self):
|
||||
"""
|
||||
todo: doc
|
||||
todo: test
|
||||
:return:
|
||||
"""
|
||||
signed_system_token = self.generate_signed_system_token()
|
||||
envelope = self.envelope_template.format(
|
||||
application_token=settings.SUPER_OFFICE_APPLICATION_TOKEN,
|
||||
context_identifier=settings.SUPER_OFFICE_CONTEXT_IDENTIFIER,
|
||||
signed_system_token=signed_system_token,
|
||||
return_token_type=settings.SUPER_OFFICE_RETURN_TOKEN_TYPE,
|
||||
)
|
||||
headers = {
|
||||
"Content-Type": "text/xml; charset=utf-8",
|
||||
"SOAPAction": "http://www.superoffice.com/superid/partnersystemuser/0.1/IPartnerSystemUserService/Authenticate",
|
||||
}
|
||||
|
||||
resp = self.request(
|
||||
method="POST",
|
||||
path="",
|
||||
data=envelope.encode("utf-8"),
|
||||
headers=headers,
|
||||
custom_host=settings.SUPER_OFFICE_SOAP_URL,
|
||||
use_build_headers=False,
|
||||
)
|
||||
token = resp["Envelope"]["Body"]["AuthenticationResponse"]["Token"]
|
||||
is_successful = resp["Envelope"]["Body"]["AuthenticationResponse"][
|
||||
"IsSuccessful"
|
||||
]
|
||||
if not str(is_successful).lower() == "true":
|
||||
return None
|
||||
|
||||
claims = jwt.decode(token, options={"verify_signature": False})
|
||||
ticket = claims.get("http://schemes.superoffice.net/identity/ticket")
|
||||
webapi_base = claims.get("http://schemes.superoffice.net/identity/webapi_url")
|
||||
return {"ticket": ticket, "webapi_base": webapi_base}
|
||||
|
||||
def set_ticket_and_webapi_base(self):
|
||||
ticket_data = self.redis_store.get()
|
||||
if ticket_data:
|
||||
self.ticket = ticket_data.ticket
|
||||
self.webapi_base = ticket_data.webapi_base
|
||||
return
|
||||
|
||||
lock_token = self.redis_store.acquire_refresh_lock()
|
||||
try:
|
||||
if lock_token is None:
|
||||
# someone else is refreshing the ticket, wait and try to get it again
|
||||
import time
|
||||
|
||||
for _ in range(10): # ~2s total
|
||||
time.sleep(0.2)
|
||||
ticket_data = self.redis_store.get()
|
||||
if ticket_data:
|
||||
self.ticket = ticket_data.ticket
|
||||
self.webapi_base = ticket_data.webapi_base
|
||||
return
|
||||
raise RuntimeError("Failed to get ticket after waiting")
|
||||
# we have the lock, refresh the ticket
|
||||
ok = self.repair_authentication()
|
||||
if not ok:
|
||||
raise RuntimeError("SuperOffice Authenticate failed")
|
||||
finally:
|
||||
if lock_token:
|
||||
self.redis_store.release_refresh_lock(lock_token)
|
||||
|
||||
@staticmethod
|
||||
def _filter_since(since: datetime | None) -> str:
|
||||
if since:
|
||||
since = since.astimezone(timezone.utc).replace(microsecond=0)
|
||||
since = since.strftime("%Y-%m-%dT%H:%M:%S")
|
||||
return f"&$filter=(registeredDate+afterTime+'{since}' or updatedDate+afterTime+'{since}')"
|
||||
return ""
|
||||
|
||||
def _query_with_paging(self, base_query: str, since: datetime | None = None):
|
||||
if since:
|
||||
query = base_query + self._filter_since(since)
|
||||
else:
|
||||
query = base_query
|
||||
values = []
|
||||
while True:
|
||||
resp = self.request(method="GET", path=query)
|
||||
values += resp.get("value", [])
|
||||
if "odata.nextLink" in resp and resp["odata.nextLink"]:
|
||||
query = resp["odata.nextLink"].split("Cust26703/api/")[-1]
|
||||
time.sleep(1) # be nice to the API
|
||||
continue
|
||||
else:
|
||||
break
|
||||
return {
|
||||
"value": values,
|
||||
}
|
||||
|
||||
def get_all_projects(self, since: datetime | None = None):
|
||||
return self._query_with_paging(
|
||||
base_query="v1/Project?$select=PrimaryKey,name",
|
||||
since=since,
|
||||
)
|
||||
|
||||
def get_all_contacts(self, since: datetime | None = None):
|
||||
return self._query_with_paging(
|
||||
base_query="v1/Contact?$select=PrimaryKey,name",
|
||||
since=since,
|
||||
)
|
||||
|
||||
def get_all_persons(self, since: datetime | None = None):
|
||||
return self.request(
|
||||
method="GET",
|
||||
path="v1/MDOList/Associate",
|
||||
)
|
||||
|
||||
def get_all_sale_types(self, since: datetime | None = None):
|
||||
return self.request(
|
||||
method="GET",
|
||||
path="v1/List/SaleType/Items",
|
||||
)
|
||||
|
||||
def get_all_sources(self, since: datetime | None = None):
|
||||
return {
|
||||
"value": self.request(
|
||||
method="GET",
|
||||
path="v1/List/Source/Items",
|
||||
)
|
||||
}
|
||||
|
||||
def get_contact_projects(self, contact_id: str):
|
||||
return self.request(
|
||||
method="GET",
|
||||
path=f"v1/Contact/{contact_id}/Projects",
|
||||
)
|
||||
|
||||
def add_sale(self, **kwargs):
|
||||
for key in ["Contact", "Project", "Person", "Associate"]:
|
||||
if key in kwargs:
|
||||
kwargs[key] = {f"{key}Id": kwargs[key]}
|
||||
|
||||
for key in ["SaleType", "Source"]:
|
||||
if key in kwargs:
|
||||
kwargs[key] = {"Id": kwargs[key]}
|
||||
|
||||
if "Saledate" in kwargs:
|
||||
kwargs["Saledate"] = to_iso_z_datetime(kwargs["Saledate"])
|
||||
|
||||
return self.request(
|
||||
method="POST",
|
||||
path="v1/Sale",
|
||||
json=kwargs,
|
||||
)
|
||||
|
||||
@@ -1,161 +0,0 @@
|
||||
Hier ist die wortgetreue Transkription des Gesprächs zwischen Manuel Zierl und Christian Godelmann:
|
||||
|
||||
Manuel Zierl: ... gebaut, wenn die das brauchen. Und die wollten über dieses – ich weiß nicht, ob du das kennst – über dieses TED-Portal... Da stehen so Ausschreibungen auf dem Portal und die von Wackler aus dem, wie heißt das? Irgendwas Center... Proposal Center, genau. Die haben hier quasi so eine Suchvorlage, mit der sie das quasi jeden Tag durchsuchen. Und dann müssen sie sich da durchklicken. Und die wollten eine einfachere Möglichkeit haben, um das in ihr SuperOffice rüberzubekommen. Weil sie müssen es halt alles von Hand quasi übertragen. Und die Idee war auch, dass man möglicherweise dann vielleicht noch irgendwie eine KI dazwischenschalten könnte, die halt quasi schon mal vorfiltert. Aber das haben wir jetzt noch nicht gemacht. Aber das war so quasi die Idee. Und was ich dann gebaut habe für Wackler, also was die jetzt auch schon benutzen, ist so ein Tool, was... Es ist ein bisschen komplizierter, aber ich meine... Das verbindet quasi so verschiedene Steps miteinander. Kriegt am Anfang quasi die Sachen, die von dieser TED-Schnittstelle reinkommen, rein und gibt am Ende dann hier in SuperOffice die Sachen aus.
|
||||
|
||||
Christian Godelmann: Also TED hat schon eine Schnittstelle von sich aus?
|
||||
|
||||
Manuel Zierl: Genau, die haben sogar eine... die haben sogar eine öffentlich verfügbare... ja, egal... die haben sogar eine öffentlich verfügbare Schnittstelle, die du einfach so abgreifen kannst. Kannst auch hier alles suchen, aber wie gesagt, kannst du auch öffentlich suchen. Da haben sie nur gewisse Limits halt drauf. Darfst nur so und so viel pro Minute und sowas, aber ist ja wurscht. Genau, und dann, was die halt dann benutzen, ist quasi dieses Ding hier. Da ist halt... in dem, was man jetzt halt hier gerade gesehen hat, sind halt alle möglichen Filterungen drin, die die halt brauchen. Da ist halt irgendwie sowas zum Beispiel drin wie: Wie hoch ist das Auftragsvolumen? Dann haben sie irgendwelche Bestandskunden, die sie bevorzugen möchten, oder was weiß ich. Oder es gibt irgendwelche Kunden, die auf einer Blacklist stehen, die sie nicht annehmen, und sowas halt. Und wenn dann diese Filterung durch ist, kriegen die hier quasi ihre Ergebnisse und können die dann in SuperOffice übertragen, indem sie sie halt hier entweder mit Häkchen oder mit Kreuzchen versehen. Und das Ganze funktioniert dann noch so, dass die dann eben möglichst wenig machen müssen, dass halt hier... Quasi das sind alle Sachen, die die halt normalerweise bei SuperOffice eintragen. Und davon sind halt viele schon vorgefertigt. Also was jetzt zum Beispiel das Aktenzeichen, das Verkaufsdatum ist klar, dann der Besitzer ist eigentlich immer der Michael Melzer in dem SuperOffice dann. Öffentliche Ausschreibung, Proposal Center, das ist auch immer gleich. Und da haben wir halt dann versucht, das noch quasi so heuristisch zu matchen aus der Ausschreibung, ob es möglicherweise halt diese Firma schon gibt. Ich weiß jetzt nicht, ob es in dem Fall stimmt, keine Ahnung. Manchmal gibt es die, manchmal nicht. Max-Planck-Institut für Mathematik und Naturwissenschaften... Also wenn die quasi schon drin ist in der in SuperOffice, dann findet er die auch und kann die halt matchen. Und dann kann der das quasi einfach automatisch in SuperOffice übertragen von dem. Und das ist das, was ich gemacht habe.
|
||||
|
||||
Christian Godelmann: Das ist in Python geschrieben?
|
||||
|
||||
Manuel Zierl: Ich hab das in Python geschrieben, ja. Ich kann dir auch mal den Code bezüglich SuperOffice schreiben, weil es leider nicht ganz so einfach war und auch ein bisschen komplizierter war. Aber ich meine... Also nur zur Einordnung: Ich bin kein Entwickler, leider. Ja, ja, alles gut. Ich kann nur mal... Ich weiß jetzt auch schon ein bisschen her, dass ich es gemacht habe. Muss ich selbst mal reinschauen. Also ich habe... Es gibt quasi zwei unterschiedliche Schnittstellen, die ich hier jetzt in diesem Fall verwenden musste. Das ist einmal halt ein Exporter, das heißt quasi: Ich schicke was zu SuperOffice rüber und speichere das rein. Und ich brauche aber auch quasi Daten von denen, um halt hier diese Suche durchzuführen, wo ich dann zum Beispiel gucke: Gibt es diesen Kontakt schon? Ne? Und die SuperOffice API, die ist... die ist ja konfigurierbar über dieses dev.irgendwas...
|
||||
|
||||
Christian Godelmann: Ja, den Account habe ich schon, beziehungsweise habe ich mir angelegt.
|
||||
|
||||
Manuel Zierl: Genau, so sieht das aus. Und da kannst du dann ja quasi dieses Ding konfigurieren. Und es ist aber ein bisschen komplizierter, weil du musst quasi... Jetzt lass mich kurz hier reinschauen, das ist jetzt meine Testapplikation... genau, du hast hier eine Konfiguration. Du brauchst dann quasi... Also so wie das bei uns funktioniert hat: Wir konnten das am Anfang nur mit der Staging-Environment machen. Da kriegst du dann, also zumindest hier bei uns, kriegst du dann sozusagen dein eigenes SuperOffice, wo halt nichts drinsteht, was blank ist. Genau, wo du das quasi testen kannst. Und um das dann für Production freizuschalten, mussten wir dann das tatsächlich halt wirklich manuell anfragen bei SuperOffice. Und da gingen dann auch ein paar E-Mails hin und her, weil die überprüfen dann auch sozusagen, welche API-Calls du machst und geben dir dann auch noch mal Vorschläge, was du irgendwie vielleicht besser machen kannst, effizienter machen kannst und so. Und deswegen quasi zuerst dieser Schritt über die Staging-Environment.
|
||||
|
||||
Christian Godelmann: Also hast du es dann als Customer Application eingestellt oder quasi mit deren Standard-Applikation?
|
||||
|
||||
Manuel Zierl: Warte mal, lass mal kurz gucken, ich müsste das noch mal gucken... Also unser Ding war so: Als ich angefangen habe das zu implementieren, hatte ich noch gar keinen Zugriff auf das Wackler-SuperOffice. Ich habe mir quasi einfach nur einen eigenen Account bei SuperOffice gemacht und halt eine Staging-App erstellt. Und dann hatte ich quasi mein komplett separates SuperOffice, mit dem ich halt testen konnte. Und dann, als das funktioniert hatte, haben wir dann eine... beziehungsweise als ich dann von Wackler halt den Zugriff bekommen hatte, haben wir das Ganze auch erst mal auf einer Staging gemacht, die halt mit dem Wackler verbunden war. Dann, als das funktioniert hat, haben wir eben noch mal mit den Leuten von SuperOffice geschrieben und die haben dann noch ein paar Anpassungen gewollt. Und die haben wir dann implementiert und dann konnten wir das quasi in Production rüberschieben. Beziehungsweise gab es noch einen Schritt dazwischen, weil du kriegst dann auch noch mal so eine Pre-Production Environment, wo du quasi ein kopiertes SuperOffice von dem Wackler-SuperOffice bekommst. Also dass du quasi mit Livedaten noch mal testest.
|
||||
|
||||
Christian Godelmann: Über welchen Zeitraum sprechen wir hier ungefähr? Also wie lange hast du gebraucht von...?
|
||||
|
||||
Manuel Zierl: Lass mich überlegen. Ich habe angefangen, das zu implementieren, vermutlich so im November oder so, würde ich jetzt schätzen. Oktober... ja, vielleicht Oktober, ja. Aber also sehr viele Probleme waren tatsächlich am Ende dieses... Also ich meine, es geht ja hier um diese ganze Applikation, wo ja auch noch mehr dabei ist und so, ne? Also auch Frontend und so Sachen, habe ich alles ich alleine gebaut. Aber der größte Teil am Ende war tatsächlich diese SuperOffice-Integration, weil das eben ewig gedauert hat, bis das halt mit den E-Mails hin und her ging. Und dann musste halt irgendwie auch viel über den Dieter... den Dieter Tonch, unser Admin, laufen, weil halt ich ja natürlich keinen Admin-Zugriff auf das Wackler-SuperOffice habe und aber diese API-Geschichten halt über ihn dann freigeschaltet werden mussten und so. Und bis da in der Kommunikation alles hin und her ging, das hat ewig gedauert. Aber...
|
||||
|
||||
Christian Godelmann: Die Frage ist... Also ich kann mal kurz skizzieren, was ich so vorhabe. Ich will mir auch quasi so ein External Enrichment Interface bauen, also so eine Art Schatten-CRM-System, wo ich im Prinzip – genau wie du das auch so ein bisschen vorhattest – durch ein Sprachmodell Accounts anreichern möchte. Also ich möchte, sag mal ganz plump gefragt, zuerst den Account einer Branche zuordnen. Und im Weiteren möchte ich mir die Webseite holen und diesen Webseiteninhalt eben sehr spezifische Fragen stellen zum Einsatz von Robo Planet. Also welcher Roboter geeignet wäre, welche Fläche die haben, also beziehungsweise die Reinigungsfläche möchte ich mir über verschiedene Proxies berechnen können.
|
||||
|
||||
Manuel Zierl: Aber möchtest du das quasi für die Kunden machen, die ihr schon im SuperOffice drin habt? Oder möchtest du das für neue Kunden machen?
|
||||
|
||||
Christian Godelmann: Für Accounts, die schon im CRM-System drin sind, beziehungsweise auch solche, die dann zukünftig hineinpurzeln. Dass die im Prinzip... Weil ich in SuperOffice jetzt selbst kein Sprachmodell zur Verfügung habe. Also ich komme ursprünglich aus der Dynamics-Welt, Dynamics 365, da habe ich ja zumindest die... da gibt es diese...
|
||||
|
||||
Manuel Zierl: Also du möchtest quasi, wenn ich das richtig verstehe: Ihr habt euer Robo Planet SuperOffice, ihr habt da einfach eine Menge an Kunden und du möchtest irgendwie so eine KI, die da durchgehen kann und diese Kunden für dich analysieren kann?
|
||||
|
||||
Christian Godelmann: Das würde ich auf einer... Also ich habe mir schon ein eigenes CRM-System im Prinzip nachgebaut. Das heißt, die beiden Systeme möchte ich über API miteinander verbinden. Das heißt, wenn eben die Daten in SuperOffice noch nicht angereichert sind, sollen die zu mir in meine Enrichment-Engine rüberwandern. Dort werden sie angereichert und die Daten werden dann wieder zurückgespielt nach SuperOffice.
|
||||
|
||||
Manuel Zierl: Ja, das kann man machen, ja. Und das geht auch. Also ich kann dir natürlich dein Szenario nicht eins zu eins anwenden. Zum einen, weil grundsätzlich ihr wahrscheinlich jetzt einen anderen Mandanten habt und ich...
|
||||
|
||||
Christian Godelmann: Ja, und diese SuperOffice, die sind auch irgendwie unterschiedlich konfiguriert. Also ich hatte dann auch Probleme, als ich dann von meinem quasi Developer-SuperOffice in das andere rüber bin, dass Sachen anders waren. Weil irgendwie viel auch damit zusammenhängt, wie euer SuperOffice überhaupt konfiguriert ist. Und ich wusste ja vorher gar nicht, wie das Wackler-SuperOffice aussieht.
|
||||
|
||||
Manuel Zierl: Genau da bin ich jetzt auch. Also ich weiß auch von null, ne? Also ich habe am letzten Montag erst angefangen, das zur Einordnung... Ich meine, ich kann dir... Wo ich dir auf jeden Fall helfen kann, ist – weil da habe ich mich auch ein bisschen damit rumgeschlagen – ist diese SOAP-Token-Geschichte. Also diese API ist keine normale API, also keine schöne irgendwie REST... Also ich glaube, man könnte schon sagen, dass es eine REST-API ist, keine Ahnung. Aber es ist nicht so eine schöne, die dir so JSONs zurückgibt, sondern es ist ein bisschen komplizierter. Du hast hier... Warte mal, ich kann mal in meine Konfiguration reinschauen. Schön, dass du das alles hart reincodest, das ist mir sehr sympathisch. Ja, ja, das ist meine... Developer-Daten... die... also das ist dann nichts, was irgendwie online landet, natürlich. Aber das sieht dann quasi hier so aus. Warte mal, meine ganzen SuperOffice-Variablen sind hier. Genau, du hast einen Super... also okay, Redis egal, da werden bei mir die Sachen lokal gespeichert. Aber du brauchst quasi diesen Context-Identifier und der ist auch wieder kompliziert, weil den machst du über die Dev-Seite. Was ich Freitag halt schon probiert habe, ist – also da habe ich nicht das Server-to-Server genommen, sondern ich habe erst mal Type "None" angegeben bei dieser... bei dem Start. Aber das war wahrscheinlich auch schon nicht der richtige Start. Bis ich da draufgekommen bin, das hat ewig gedauert. Weil du musst diese Seite hier verwenden. Das ist total bescheuert und ich checke das auch nicht, warum die das so kompliziert machen. Aber... die kann ich dir auch einmal schicken. Denn diese Seite... Wenn ich jetzt hier in meine Testapplikation reingehe... ich brauche nämlich dieses... diesen SuperOffice System User Token. Und ich habe ewig nicht gecheckt, was das für ein Ding ist. Und ich bin dann draufgekommen, du musst Folgendes machen: Du hast hier – also hier generierst du deine Client ID, hast die für drei Stages. Und dann hast du hier ein Secret. Das speicherst du einmal ab, speicherst dir das irgendwo zwischen. Genau, und dann musst du auf diese Seite hier gehen, gibst hier deine Environment ein, gibst hier deine Client ID ein... ich kann das auch einmal machen für dich. Client ID... und dann gibst du da dein Secret ein... das habe ich wahrscheinlich irgendwo hier... SuperOffice Sandbox... das ist das hier... Die Environment oben muss auch stimmen. Dann drückst du auf Sign In. Dann wirst du jetzt weitergeleitet an deinen SuperOffice Account. Musst dich da möglicherweise... das habe ich schon gemacht... Genau, dann musst du dich da noch mal einloggen. Beziehungsweise hab ich das dann über Localhost habe ich das gemacht, weil... man braucht ja nur diese ID am Ende, oder? Genau. Ich habe dann jetzt hier zwei Accounts, weil ich halt diesen Wackler und den eigenen habe. Ich glaube, das ist der hier... wenn nicht, dann... ja, was auch immer. Auf jeden Fall wirst du dann weitergeleitet und dann kriegst du diesen SuperOffice System User Token. Das ist wahrscheinlich der, den ich habe. Mit dem man sich dann diese Tokens zur Laufzeit generieren kann? Genau. Wie sieht denn der aus? Weil der sah bei mir immer... der sah bei mir immer irgendwie so aus. Der hat immer dieses... Genau, also das hier ist jetzt der aus der Sandbox, so sah der aus. Der hat immer so diesen Namen von der Applikation vorne, dann minus S2S bei mir, weil es halt Server-to-Server ist. Ja, genau so sieht er aus. Nee, das ist der Refresh Token. Das ist der Refresh Token, das ist er nicht. Nee. Gut, dann war das was Falsches. Aber es mag sein, dass... Also der hat immer vorne so diesen Namen stehen von dem Ding. Und es gibt auch einen Refresh Token, den brauchst du aber nicht unbedingt. Das kannst du dann selber machen. Und dann habe ich das... Es läuft über XML, nicht über JSON, deswegen ist es extrem hässlich. Habe ich das einmal hier so nachgebaut gehabt, wie dieses Ding aussehen muss. Und da musst du so ein paar Sachen beachten, dass halt du hier das Richtige signierst, die richtigen Kontexte setzt, was auch immer. Und dann hier... ja, alles Mögliche an Python-Code. Ich kann dir den theoretisch auch einfach mal schicken.
|
||||
|
||||
Christian Godelmann: Das würde mir wahrscheinlich ungefähr zwei Monate sparen.
|
||||
|
||||
Manuel Zierl: Ja, das kann gut sein. Also da musst du natürlich noch mal durchgucken, weil da steht jetzt halt sowas drin wie hier dieser Customer 2... das ist natürlich meiner. Zwei, aber was haben wir denn? Ich schicke dir mal die... ich schicke dir mal die oben nicht mit, sonst kriege ich Ärger. Nee, also natürlich darfst du mir nichts schicken... Aber die kannst du dir ja dann denken, was die bedeuten. Also die sind schon halt nach dem benannt, was quasi was das ist. Und du musst dann nur einmal... ach so genau, warte mal... genau, dieses Ding zeige ich dir auch gleich noch mal, weil diesen Token musst du nämlich selber signieren dann. Das ist auch noch mal ganz eklig. Also das ist quasi dieser Code hier, mit dem du dieses XML an die schickst. Und ich weiß jetzt nicht, was macht denn dieses Beispiel hier...
|
||||
|
||||
Christian Godelmann: Also mir geht es erst mal darum, einfach einen Durchstich zu bekommen und einen Get-Request zu machen.
|
||||
|
||||
Manuel Zierl: Ja, genau. Ich gucke gerade mal. Genau, also der hier ruft quasi den Kontakt mit der ID 1 auf. Das ist das, was zurückkommen soll. Also wenn das schon mal funktioniert, dann weißt du, du kannst mit der API sprechen quasi, ne? Und dieses Token-Signieren, dafür brauchst du wiederum einen... ich weiß nicht, du weißt wahrscheinlich, was ein Private Key ist, ein Public Key und so. Die musst du ja bei dir abspeichern. Die musst du dann ja auch einmal hier eintragen, dass hier dein Private-Public-Key ist. Und also nicht wundern, dass das alles hier drinsteht, weil wie gesagt, hier habe ich das alles nur getestet in diesen Files. Und damit kannst du dir dann quasi diesen... über diesen System User Token, den du brauchst, und mit deinem Private Key kannst du dir quasi hier dann dieses Ding hier generieren. Das was dann hier steht, was ich hier dann eingetragen habe: Sign System User Token.
|
||||
|
||||
Christian Godelmann: Okay.
|
||||
|
||||
Manuel Zierl: Und das Ding kann ich dir auch noch mal schicken. Das wäre schon mal sehr hilfreich. Also nochmal, wie gesagt, ich bin kein Entwickler, das ist auch nur alles Web-Coding.
|
||||
|
||||
Manuel Zierl: Ja, ich glaube, da kommst du aber auch ganz gut weiter, wenn du diesen Code quasi der ChatGPT oder Copilot, was auch immer gibst. Dann wird er sich da schon einigermaßen zurechtfinden, genau, glaube ich auch. Also ich habe auch die KI zur Hilfe verwendet. Das Problem war nur eher herauszufinden, was die überhaupt wollen, weil die Dokumentation auch nicht ganz so klar an manchen Stellen ist, zumindest meiner Meinung nach. Genau, aber das ist quasi der Schritt. Also ich habe hier mein... ich habe das halt hier so eingetragen, das war halt leichter, wenn du das dann theoretisch wirklich implementieren würdest, würdest du es natürlich anders machen. Aber wenn du es eh nur lokal bei dir laufen hast, dann kannst du es theoretisch auch ins Skript mit eingeben. Also ist auch kein Problem. Wenn du das natürlich irgendwo auf einen Server laden würdest, würdest du das natürlich nicht machen. Aber genau... Jetzt lass mich überlegen, ob ich da sonst noch was habe... ich hatte das auch mal versucht mit irgendwie schöner zu machen, aber das hat nicht funktioniert. Ich kann nur mal gucken, wie das in meiner tatsächlichen Applikation jetzt aussieht. Weil eine Doku hast du dafür nicht, oder? Also es gibt halt die SuperOffice Dokumentation. Die kenne ich ja. Aber ich meine jetzt für deine... Ich habe für mich selbst nicht so viel Dokumentation geschrieben, nee, da ich halt der Einzige war, der das implementiert hat. Ich meine, was ich dir geben könnte... warte mal, ich könnte dir quasi diese... das ist quasi mein... was brauchst denn du? Du brauchst Import und Export eigentlich auch beide, ne? Also ich kann dir diesen Code auch geben. Der ist halt aber ein bisschen spezifischer auf quasi das, was ich gebaut habe. Also da ist halt quasi... Du hast halt hier irgendwie so ein... also musst halt dann dich mit der KI durchfragen. Weil das hier quasi... warte mal, ach so, das ist viel Code. Weil sehr viel von diesem Code, der ist halt spezifisch für das, was ich gebaut habe. Und da gibt es halt dann irgendwie auch so Sachen, die halt irgendwelche Sachen rausfiltern, die für Wackler spezifisch wichtig waren, beziehungsweise halt auch Sachen, die mir das ermöglichen, hier in meinem... in meiner grafischen Oberfläche die Sachen halt einfach für mich zu konfigurieren, aber halt auch angepasst auf das, was Wackler möchte.
|
||||
|
||||
Christian Godelmann: Der Flow hast du dann... ist dann auch Teil deiner App oder...?
|
||||
|
||||
Manuel Zierl: Ja, ja, also das hat Wackler auch. Die benutzen das natürlich nicht, weil ich das hier für die konfiguriere. Aber das macht es halt dann auch für mich einfacher, das für die zu konfigurieren, weil ich halt dann zum Beispiel jetzt hier... die wollen die Bestandskunden getaggt haben. Dann habe ich quasi hier einen Filter, der mir rausfiltert: Was sind Bestandskunden? Und das kann ich halt dann hier quasi konfigurieren über irgendwelche Felder, wo ich sage: Wie heißt der? Hier zum Beispiel... das kann man dann auch alles eintragen und so. Dann taggt der das und gibt das halt an den Nächsten weiter. Und das ist halt so ein bisschen die Idee und so funktio-... also das sind alles nur irgendwelche Filter hier, die das dann irgendwie anders taggen. Und am Ende läuft es dann in dieses Ding hier rein und das ist dann das, was die hier oben in dieser Liste sehen. Und genau, hier kommen noch mal die SuperOffice-Daten rein, die er braucht, um das zu matchen. Und dieses Ding hier quasi, das brauche ich dann nicht mehr wirklich zu konfigurieren. Also ein paar Sachen sind schon noch drin, aber da geht es halt dann noch mal darum, was für Felder die überhaupt eintragen möchten in das SuperOffice und woher sie diese Werte kriegen. Und das ist dann noch mal so konfiguriert, dass es mit dem zusammenpasst, was die quasi hier eingegeben haben. Deswegen ist sehr viel von dem Code wahrscheinlich für dich nicht besonders nützlich, aber es ist trotzdem halt... warte mal, vielleicht kann ich auch noch mal... es gibt hier... der Exporter... Add Sale müsste das irgendwie heißen oder so... Sale... Build Sale Payload, genau. API Add Sale. Ach so, beziehungsweise vielleicht ist das, weil mein Exporter und Importer sehr spezifisch ist, aber das Ding genau... nee, ich schicke dir das Ding, das ist wesentlich besser. Da das habe ich quasi nur um die API herumgebaut. Es kann natürlich auch nur die Sachen, die ich gebraucht habe. Also das sind quasi diese alle hier: Also Get All Projects, Context, Person, Sale Type, Source...
|
||||
|
||||
Christian Godelmann: Ja, mehr gibt es ja nicht, ja.
|
||||
|
||||
Manuel Zierl: Es gibt Tausende mehr. Aber davon brauchte ich halt nichts. Und ich weiß halt nicht, aber wenn du jetzt auch quasi nur einen Sale einfügen willst und vielleicht irgendwie noch ein paar Kontaktdaten haben willst...
|
||||
|
||||
Christian Godelmann: Kontakt ist ein Account in dem Sinne, das ist...
|
||||
|
||||
Manuel Zierl: Kontakt ist ein... ist das, was du bei SuperOffice quasi Firma nennst, genau. Ja, genau. Das hat mich auch total verwirrt, weil genau bei Get Persons ist nämlich dann dieses tatsächliche Person im SuperOffice. Muss man auch erst mal draufkommen, genau. Und hier ist tatsächlich auch schon das Meiste drin, was du brauchst, weil der macht dir auch schon... also der... ja gut, der erbt halt wieder von der hier, ne... Ich schicke dir mal... ich weiß nicht, Vererbung sagt dir was? Schon, ja. Okay. Also es gibt quasi eine Base-Klasse, die ich mir geschrieben habe, weil ich benutze ja mehrere APIs, die halt so ein paar Grundfunktionalitäten drin hatte. Und...
|
||||
|
||||
Christian Godelmann: Schreibst du mir dazu, welche Klasse das jeweils ist, weil oder welche Funktion...?
|
||||
|
||||
Manuel Zierl: Ach so, das steht oben ja, also immer Class Base API. Das siehst... kannst du dann auch die KI fragen, die merkt das dann. Und hier diese API erbt quasi von der, übernimmt quasi ein paar Funktionen von der, baut halt auch auf der auf. Und die kann tatsächlich auch so ein paar Tricks eben schon, dass sie hier diese Tokens generiert und sowas und die auch richtig einpackt und alles, den Header richtig setzt, alles Mögliche. Wo allerdings du möglicherweise... ja genau, JWT-Tokens und so, ganz kompliziert. Wo du aber aufpassen musst möglicherweise, ich weiß nicht, ob ich das Redis hier auch drin habe... ja, genau. Ich verwende – weil meine Applikation ja auf einem Server läuft – muss ich quasi diese Tokens, die ich habe, irgendwo zwischenspeichern. Und das macht man normalerweise in einem Redis-Store. Das ist so was wie eine Art Datenbank, die aber halt wesentlich schneller ist, weil du aber keine irgendwie relationalen Geschichten hast und so was. Also quasi um einfach schnell irgendwelche Strings reinzuspeichern und wieder rauszuholen. Das heißt quasi, das wirst du vermutlich nicht brauchen beziehungsweise halt irgendwie überschreiben müssen. Aber das ist halt rein server-spezifischer Code quasi, den ich gebraucht habe, aber den du vermutlich nicht brauchen wirst. Aber sonst glaube ich, kannst du ziemlich viel von hier wahrscheinlich wiederverwenden. Genau. Ja. Sonst... ja... es gibt auch einen ganz... also was ganz okay ist, ist die... ist der Support von denen. Man kann denen schreiben und die antworten auch, zumindest. Also ich muss erst mal so die ersten Grundlagen sicherstellen, wie gesagt. Es ist halt, wie gesagt, ziemlich nervig, weil sie sehr kompliziert ist, aber dafür haben sie auch einen Support, der das dann meistens schon irgendwie erklärt. Und du schlägst dir dann meistens danach den Kopf und denkst so: Ja, okay, so wie du es erklärst, macht es ja schon irgendwie Sinn, aber da wäre ich jetzt alleine nicht draufgekommen. Aber genau. Und was ich hier tatsächlich auch implementiert hatte, ich weiß nicht, wie wichtig das für dich ist... und zwar ist bei mir quasi das Problem gewesen, dass ich ja quasi alle Firmen, die im SuperOffice von Wackler drin sind, quasi hier ja durchsuchbar machen möchte. Und die haben keine richtig guten Suchendpunkte. Das heißt, ich lade die quasi alle zu mir auf den Server und durchsuche sie. Dieses Laden von allen Daten, das dauert aber so zehn Minuten und ist auch relativ belastend für den SuperOffice-Server. Und das waren dann auch ein paar von den API-Calls, die die moniert haben, wo sie gesagt haben, das wollen sie nicht, dass irgendwie alle jeden Tag alle Kontakte abgefragt werden und so. Aber es gibt da auch Endpunkte, die... mit denen du quasi filtern kannst, welche... dass du nur nach den Neuesten filterst und so was. Also das gibt es alles schon so, dass du das relativ gut machen kannst. Ich weiß nicht, wie viele Kunden ihr jetzt bei Robo Planet drin habt...
|
||||
|
||||
Christian Godelmann: Leider viel zu viele, also das sind über 60.000.
|
||||
|
||||
Manuel Zierl: Wackler hat, glaube ich, auch irgendwie über 100.000 oder so drin gehabt, deswegen also ist das dann relativ langsam. Aber okay, ja, dann wirst du so was vielleicht auch...
|
||||
|
||||
Christian Godelmann: Ja, aber das Initial-Sync wird halt ein bisschen länger dauern, aber danach dann über ein Diff oder so was...
|
||||
|
||||
Manuel Zierl: Genau, das ist eben die Idee, dass du halt dann immer sagst: Okay... Unserer, der ruft das, glaube ich, alle sechs Stunden oder so auf, nee, alle 12 Stunden, genau. Und fragt aber quasi immer nur, was die in den letzten 12 Stunden reingekommen ist. Und das sind dann natürlich relativ wenige. An neuen Firmen oder auch an Details an den neuen Firmen oder...? Auf alles bezogen jetzt hier. Wobei bei manchen habe ich es, glaube ich, tatsächlich nicht implementiert. Zum Beispiel bei den Sources, die verändern sich aber auch nicht. Also da quasi... und das sind irgendwie nur 10, 20 Stück oder so, deswegen ist das da wurscht. Da habe ich mir die Mühe nicht gemacht. Aber vor allem eben bei den Kontakten und bei den... ich glaube nicht mal bei den Personen war es ein Problem, weil das sind halt auch irgendwie 200, 300, 400, 500 Stück oder so was, aber das juckt den nicht. Das Problem sind dann die Kontakte, wenn du wirklich 100.000 hast und das dann halt wirklich... Und die vor allem auch relativ groß sind, weil irgendwie an einer Person hängt meistens auch nicht ganz so viel Daten dran wie dann an einem Projekt oder an einer Firma dran hängt. Weil da ja wieder... die Firmen ja wieder Projekte haben und so was und dann wird das Ding halt riesig, was da rauskommt.
|
||||
|
||||
Christian Godelmann: Aber das arbeitest du jetzt über einen Diff, also du schaust, wann du das das letzte Mal geholt hast und welche Accounts seitdem dann aktualisiert wurden? Auch wenn jetzt wie gesagt ein Feld geändert wurde, also wenn die Straße oder so was angepasst wurde?
|
||||
|
||||
Manuel Zierl: Ja, das mache ich schon. Das mache ich schon. Also beziehungsweise halt nur auf die Daten bezogen, die ich auch brauche. Weil das sind bei mir nicht so viele. Also so was wie jetzt zum Beispiel die Straße oder so was, das brauche ich nicht. Aber den Namen zum Beispiel, den Namen bräuchte ich halt zum Beispiel, weil der Name ja dann das ist, was hier angezeigt wird, wenn du dann hier was eingibst.
|
||||
|
||||
Christian Godelmann: Jetzt mal für den Namen gefragt, weil du das Max-Planck-Institut hast. Hast du da auch eine Art Fuzzy-Lookup oder intelligenten Lookup? Weil dass der Name eins zu eins übereinstimmt, ist ja...
|
||||
|
||||
Manuel Zierl: Ja, das habe ich... aber das wird für dich wahrscheinlich nicht sinnvoll sein. Ich habe das gemacht mit... muss ich mal überlegen, wo müsste das stehen... das müsste wahrscheinlich vielleicht hier stehen. Also ich, was ich mache ist: Ich lade die Daten quasi einmal zu mir und speichere sie in eine Postgres-Datenbank. Und in der Postgres-Datenbank kannst du eine... so eine Fuzzy-Suche machen, dafür gibt es ganz gute Libraries. Fuzzy-Search-Strings... Aber wie gesagt, dann müsstest du jetzt bei dir Postgres und so was installieren. Ich weiß nicht, wie sinnvoll das jetzt für dich ist. Es ist halt relativ effizient für das, was der Server dann...
|
||||
|
||||
Christian Godelmann: Also ich habe das Thema... es ist häufiger, also zum einen muss ich erst mal gucken, was wir intern an Duplikaten in SuperOffice haben, weil da gibt es auch tonnenweise Duplikate schon, die muss ich erst mal sinnvoll bereinigen. Aber zukünftig ist auch so ein Ongoing-Thema. Weißt du, wenn du auf eine Messe gehst und dann fragen die: Wer von unseren Interessenten ist denn dort? Und so weiter. Da hast du ja ständig dieses Thema, dass du zwei Listen miteinander vergleichen musst. Hatte ich in der Vergangenheit auch schon mal ein Tool für entwickelt.
|
||||
|
||||
Manuel Zierl: Ja, also das... da ist es, wie gesagt, natürlich schneller, wenn du das in einer Datenbank hast, in einer sinnvollen Datenbank wie Postgres. Aber ich weiß nicht, wenn du sagst, du bist kein Entwickler, ob dir das jetzt so viel Spaß machen wird, deine eigene Postgres-Instanz aufzusetzen, weiß ich nicht. Aber theoretisch sinnvoll ist es, keine Frage.
|
||||
|
||||
Christian Godelmann: Ich hatte SQLite-Datenbanken, das reicht für meinen Kram meistens schon.
|
||||
|
||||
Manuel Zierl: Kann auch schon gut reichen. Die haben halt dann meistens nicht so effiziente und smarte Algorithmen wie die hier drin, weil der schon... da kannst du wirklich sehr viel machen. Also da kannst du... ich weiß dann gar nicht mehr, was ich... ich müsste dann... ich müsste noch mal gucken, was ich da genau verwendet habe für einen Algorithmus, der dann sehr gut funktioniert hat. Der quasi über die Heuristik suchen kann, ob dieser String, den du hast, so ähnlich in diesem String drin ist. Weil du ja auch nicht willst, dass der dieselbe Länge haben muss. Es gibt zum Beispiel die Levenshtein-Distance, die halt aber quasi dann auch größer wird, wenn halt die Strings unterschiedliche Längen haben und so.
|
||||
|
||||
Christian Godelmann: Ja, da sind wir genau bei der Thematik. Aber das habe ich... da habe ich in der Vergangenheit auch schon zwei, drei Wochen dran rumgedocktert, bis ich da ein System hatte, was dann...
|
||||
|
||||
Manuel Zierl: Ja, das kann ich mir gut vorstellen.
|
||||
|
||||
Christian Godelmann: Das klingt so trivial, aber da bist du dann schnell mal eine Woche am Testen und gucken und dann findest du den einen und den anderen wieder nicht.
|
||||
|
||||
Manuel Zierl: Hmm... muss mal kurz... ja... doch, doch, doch, das ist es genau. Der Trigram-Match... in Postgres heißt der pg_trgm, Trigram. Das ist irgend so eine Heuristik, die quasi... die quasi eben darauf basiert, dass das nicht von der Länge abhängig ist. Und deswegen hat das jetzt in dem Fall sehr gut funktioniert, weil ich habe da ein paar durchprobiert und für den Use-Case war es auf jeden Fall sehr gut.
|
||||
|
||||
Christian Godelmann: Ja, ich habe mich da auch mit Stopwords und so weiter, oder wenn... ja, ach Gott.
|
||||
|
||||
Manuel Zierl: Genau, aber sonst ja...
|
||||
|
||||
Christian Godelmann: Ja cool. Ich glaube, du hast mir schon super geholfen. Ich werde sicherlich noch mal auf dich zurückkommen. Das wird wahrscheinlich noch ein bisschen dauern, bis ich so weit bin und erst mal gucke, ob die Grundlage, wie gesagt, ob wir die richtige Lizenz haben oder nicht für die Robo Planet Instanz.
|
||||
|
||||
Manuel Zierl: Ja, da musst du erst mal gucken. Genau. Wie gesagt, kann ich dir nicht viel helfen. Ich weiß nur, dass wir von Conclimate beziehungsweise Subtain, wir haben schon irgendwie... Also unser SuperOffice ist irgendwie so eine Unterinstanz vom Wackler-SuperOffice, soweit ich weiß. Und das wird dann irgendwie auch darüber geregelt. Also ich habe dann... Also quasi ich als Entwickler von Subtain stehe auch irgendwo im SuperOffice von Wackler drin. Aber ich kenne mich da jetzt auch nicht so aus. Also ich habe sowieso dann mit dem... mit unserem Conclimate Subtain SuperOffice habe ich sowieso nichts zu tun. Das machen ja eh alle die Sales-Leute oder die Controller, keine Ahnung.
|
||||
|
||||
Christian Godelmann: Wer ist denn eigentlich euer Admin-Kontakt dann für die Lizenz zum Beispiel? Das ist die Frau Grillenberger, oder?
|
||||
|
||||
Manuel Zierl: Also... für SuperOffice? Ja. Also wen ich mir vorstellen könnte, wäre auch der Dieter, weil der quasi halt der Superadmin von Wackler ist. Dass der das von euch auch macht, das weiß ich nicht. Und ansonsten haben wir halt... habe ich halt mit unserem... also mit unserem Subtain Conclimate SuperOffice habe ich halt gar nichts zu tun. Ich habe halt jetzt mehr mit dem Wackler-Office SuperOffice zu tun, weil ich halt für die das gebaut habe. Aber in Subtain gebe ich da... habe ich da keine Schnittstelle dazu. Deswegen weiß ich das nicht. Da müsstest du wahrscheinlich halt einfach mal bei dir im Unternehmen... ich kenne mich ja mit eurer Struktur bei Robo Planet nicht aus.
|
||||
|
||||
Christian Godelmann: Die Webcom sagt dir aber nichts oder...? Weil die hat mir jetzt zumindest meinen Account eingerichtet, die Frau Grillenberger.
|
||||
|
||||
Manuel Zierl: Ja, doch, die sagt mir schon was. Die könnte da auf jeden Fall was drüber wissen. Aber die ist... ist die Grillenberger, die ist ja auch von Wackler, nicht von jetzt Robo Planet, ne? Ist das so? Weiß ich nicht, ich glaube... Was steht denn da in ihrer E-Mail-Adresse? Webcom... Also das ist eine Externe. Ach so, das ist noch mal ein externer Dienstleister, okay, nee, dann weiß ich es wirklich nicht. Keine Ahnung.
|
||||
|
||||
Christian Godelmann: Ja, vielleicht ist das ein Dienstleister von Wackler.
|
||||
|
||||
Manuel Zierl: Nee, da würde ich einfach mal jemanden aus dem Sales bei euch vielleicht fragen oder so, weil die, glaube ich, arbeiten viel mit SuperOffice und die wissen das bestimmt. Also würde mich wundern, wenn nicht, keine Ahnung. Zu dem Dieter habe ich auch einen ganz guten Draht. Wir haben mal früher haben wir zwei Jahre zusammengearbeitet.
|
||||
|
||||
Christian Godelmann: Ach witzig, okay. Wo?
|
||||
|
||||
Manuel Zierl: Mobile X, das war diese Fleetmanagement-Geschichte.
|
||||
|
||||
Manuel Zierl: Ach okay, witzig. Von daher habe ich einen ganz guten Draht. Der hat mir auch so ein bisschen... Ja, der ist auch nett, den mag ich auch ganz gerne. Das ist immer cool drauf und mit dem habe ich auch ab und zu immer mal was gehabt, weil ich bin ja der einzige Entwickler aus Subtain. Und er hat aber ja überall diese Admin-Geschichten und so. Da muss man manchmal dann, wenn es irgendwie über irgendwelche DNS-Geschichten oder so was geht... unsere DNS-Server von Wackler eigentlich Wackler gehören irgendwie oder so.
|
||||
|
||||
Christian Godelmann: Auf welchem Server läuft eigentlich deine Geschichte jetzt hier?
|
||||
|
||||
Manuel Zierl: Das ist ein Heroku-Server. Kennst du Heroku? Ist so... kannst du dir so ein bisschen vorstellen wie halt AWS, aber in ein bisschen einfacher. Ein bisschen teurer theoretisch, wenn du nicht so Credits bekommst, weil du irgendwie ein Unternehmen bist, keine Ahnung, und die dir dann Credits schenken. Aber theoretisch eigentlich ein bisschen teurer, dafür aber sehr viele Sachen gemanagt, die du, wenn du dich mit AWS auseinandersetzt, einfach komplizierter sind. Weil bei AWS brauchst du ja eigentlich fast wieder irgendwie eine eigene Person, musst du einstellen, die sich nur darum kümmert.
|
||||
|
||||
Christian Godelmann: Nee, weil ich hab nämlich jetzt auch... also meine Entwicklungen sind momentan alle Container-basiert und es läuft jetzt bei mir zu Hause auf der DiskStation.
|
||||
|
||||
Manuel Zierl: Ja, das ist auch okay, also...
|
||||
|
||||
Christian Godelmann: Das soll aber perspektivisch dann hier irgendwo in eine Linux-Umgebung...
|
||||
|
||||
Manuel Zierl: Ja, und also wir haben auch tatsächlich hier noch keine schöne URL, deswegen sieht die auch immer noch so aus. Also das ist halt die Heroku, die er verpasst. Aber die haben sowieso so ein bisschen mehr noch vor da, dass die... Also sie sollen dafür Wackler, glaube ich, auch noch weiter dran entwickeln, weil die haben da jetzt schon wieder ein paar Vorstellungen, was sie anders, besser haben wollen und so. Und dann wird das vielleicht auch so eine eigene Wackler-interne Tool-Webseite oder so, keine Ahnung, was die da genau vorhaben. Genau.
|
||||
|
||||
Christian Godelmann: Okay, nee, das hilft mir schon mal sehr, sehr weiter. Habt ihr dann bei... ihr habt bei Robo Planet müsstet ihr aber auch Entwickler haben, ne? Gar nicht? Okay... Also ich bin ja auch kein Entwickler. Schön wär's, aber... Interessant. Na ja, aber irgendwer muss die Roboter programmieren, oder? Oder macht das irgendwie ein externer Dienstleister oder so? Das ist aber natürlich auch was anderes, ist ja Hardware.
|
||||
|
||||
Christian Godelmann: Das ist was anderes, ja. Also ich glaube da... Da macht, glaube ich, auch der Support macht viel mit, aber das ist dann nichts in Richtung Programmierung. Das ist die... ja, quasi Setup-Programm und dann läuft er einmal rum und dann hat er seinen Floor-Plan.
|
||||
|
||||
Manuel Zierl: Okay, ja. Cool.
|
||||
|
||||
Christian Godelmann: Aber vielleicht weiß ich auch noch zu wenig, was die Kollegen so alles machen. Erst die Woche angefangen, von daher... das macht Sinn.
|
||||
|
||||
Manuel Zierl: Ja, das macht Sinn.
|
||||
|
||||
Christian Godelmann: Nee cool. Ich werde mir erst mal anschauen, was du mir geschickt hast. Ich werde es mal durch meine KI jagen, mal gucken, ob sie mir hilft, das irgendwo in die Wege zu leiten, die ich brauche.
|
||||
|
||||
Manuel Zierl: Ja. Genau. Kannst mich auch... kannst mir auch schreiben. Ich bin nicht jeden Tag im Büro, also ich mache auch Homeoffice. Ich habe einen echt weiten Weg hierher, aber so ein, zwei Mal die Woche bin ich schon da. Am Freitag arbeite ich nicht, aber... alles gut. Genau, aber sonst kannst du dich melden, ja.
|
||||
|
||||
Christian Godelmann: Super. Ich hoffe, dass ich nicht zu viel Zeit am Anfang geklaut habe.
|
||||
|
||||
Manuel Zierl: Ach, alles gut. Geholfen?
|
||||
|
||||
Christian Godelmann: Das werde ich beurteilen. Ich hoffe es. Super, Manuel. Dann vielen Dank schon mal.
|
||||
|
||||
Manuel Zierl: Ja, kein Problem. Ich sitze übrigens noch vorne beim Aufzug, im rechten Flügel direkt die erste Tür links. Da wo die... wir ziehen aber auch jetzt im Februar, glaube ich, um. Ja, schon gehört, da tut sich was. Aber schauen wir mal. Also danke schon mal, ne? Ciao.
|
||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@@ -1,324 +0,0 @@
|
||||
# GTM Strategy
|
||||
|
||||
**Recherche-URL:** https://www.inmotionrobotic.com/de/puma
|
||||
|
||||
---
|
||||
|
||||
# GTM STRATEGY REPORT v3.1
|
||||
|
||||
## 1. Strategic Core
|
||||
|
||||
* **Category Definition:** Der PUMA M20 fällt eindeutig in die Kategorie **Sicherheitsroboter**. Diese Kategorie umfasst autonome oder ferngesteuerte Roboter, die primär für Sicherheits-, Überwachungs- und Patrouillenaufgaben konzipiert sind. Sie dienen dazu, menschliche Sicherheitskräfte zu ergänzen oder zu ersetzen, insbesondere in gefährlichen, schwer zugänglichen oder weitläufigen Umgebungen.
|
||||
|
||||
* **Dynamic Service Logic:** Im Bereich der Sicherheitsroboter besteht die dynamische Service-Logik aus zwei wesentlichen Schichten: der "Maschinenschicht" (dem, was der Roboter tut) und der "Menschlichen Serviceschicht" (dem, was Wackler tut).
|
||||
|
||||
* **Maschinenschicht (PUMA M20):** Der Roboter übernimmt die unermüdliche, autonome Überwachung und Datenerfassung. Dies beinhaltet:
|
||||
* **Kontinuierliche Patrouillen:** Autonome Navigation und Überwachung von definierten Gebieten.
|
||||
* **Sensorbasierte Detektion:** Erkennung von Anomalien, Eindringlingen, Gaslecks oder Temperaturabweichungen mithilfe von LiDAR, Kameras (Weitwinkel, Wärmebild) und Gassensoren.
|
||||
* **Echtzeit-Alarmierung:** Sofortige Benachrichtigung bei erkannten Vorfällen.
|
||||
* **Datenerfassung und -analyse:** Sammlung von Daten zur Verbesserung der Sicherheitsprotokolle und zur Vorhersage potenzieller Risiken.
|
||||
* **Menschliche Serviceschicht (Wackler Security):** Wackler Security ergänzt die Fähigkeiten des Roboters durch:
|
||||
* **Alarmbewertung:** Analyse der vom Roboter generierten Alarme durch erfahrene Sicherheitsmitarbeiter in einer Notruf- und Serviceleitstelle (NSL).
|
||||
* **Intervention:** Entsendung von Interventionskräften im Alarmfall, um die Situation vor Ort zu bewerten und geeignete Maßnahmen zu ergreifen (z.B. Täter stellen, Gefahren beseitigen, Rettungsdienste alarmieren).
|
||||
* **Situationsmanagement:** Koordination von Sicherheitsmaßnahmen und Kommunikation mit relevanten Stakeholdern (z.B. Polizei, Feuerwehr).
|
||||
* **Reporting und Analyse:** Erstellung von detaillierten Berichten über Vorfälle und Sicherheitsrisiken zur kontinuierlichen Verbesserung der Sicherheitsstrategie.
|
||||
|
||||
Diese Kombination aus robotergestützter Überwachung und menschlicher Expertise ermöglicht es Wackler Security, einen umfassenden und effektiven Sicherheitsdienst anzubieten, der über die Fähigkeiten herkömmlicher Sicherheitslösungen hinausgeht. Der Roboter "sieht" die Gefahr, Wackler "beseitigt" sie.
|
||||
|
||||
## 2. Executive Summary
|
||||
|
||||
Der Markt für Sicherheitsroboter bietet ein signifikantes Wachstumspotenzial, getrieben durch steigende Sicherheitsbedrohungen, Fachkräftemangel und den Wunsch nach effizienteren Überwachungslösungen. Der PUMA M20, ein geländegängiger, kompakter Sicherheitsroboter, adressiert diese Herausforderungen, indem er autonome Patrouillen, umfassende Sensorik und robuste Bauweise kombiniert. Er ermöglicht eine 24/7-Überwachung in anspruchsvollen Umgebungen, reduziert das Risiko für Mitarbeiter und senkt gleichzeitig die Sicherheitskosten. Wackler Security differenziert sich durch die Integration des PUMA M20 in ein umfassendes Sicherheitskonzept, das die unermüdliche Überwachung des Roboters mit der Expertise und Reaktionsfähigkeit menschlicher Sicherheitskräfte verbindet. Diese Kombination aus "Maschine" und "Mensch" bietet einen Mehrwert, der über reine Technologie hinausgeht und eine ganzheitliche Sicherheitslösung für kritische Infrastrukturen, Industrieanlagen und große Areale darstellt. Der PUMA M20 ist nicht nur ein Roboter, sondern ein integraler Bestandteil einer intelligenten Sicherheitsstrategie.
|
||||
|
||||
## 3. Product Reality Check (Technical Deep Dive)
|
||||
|
||||
* **Core Capabilities:**
|
||||
* **Geländegängigkeit:** Bewältigt unebenes Gelände, Treppen und Steigungen bis zu 45°.
|
||||
* **Autonome Navigation:** SLAM-basierte Navigation für autonome Missionen und Rückkehr zur Basis.
|
||||
* **360°-Umgebungserfassung:** Duale LiDAR-Systeme und Weitwinkelkameras für umfassende Umgebungswahrnehmung.
|
||||
* **Robuste Bauweise:** IP66-Schutz für Staub- und Wasserbeständigkeit, Betriebstemperaturbereich von -20°C bis 55°C.
|
||||
* **Flexible Integration:** Flottenmanagement und API-Integrationen für Datenaustausch.
|
||||
|
||||
* **Technical Constraints:**
|
||||
|
||||
| Feature | Value | Unit | Implication |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| Produkt-ID | puma-m20 | - | Eindeutige Identifikation des Produkts. |
|
||||
| Marke | PUMA | - | Hersteller des Roboters. |
|
||||
| Modellname | M20 | - | Spezifische Modellbezeichnung. |
|
||||
| Beschreibung | Kompakter, geländegängiger Quadruped für Inspektionen, Logistik und Missionen, bei denen Menschen nicht hingehen sollten. | - | Definiert den primären Anwendungsbereich. |
|
||||
| Kategorie | Sicherheitsroboter | - | Klassifizierung des Produkts. |
|
||||
| Hersteller-URL | https://www.inmotionrobotic.com/de/puma | - | Link zur Herstellerseite für weitere Informationen. |
|
||||
| Akkulaufzeit | 180 | min | Maximale Betriebsdauer mit einer Akkuladung. |
|
||||
| Ladezeit | N/A | min | Zeit, die zum vollständigen Aufladen des Akkus benötigt wird (nicht angegeben). |
|
||||
| Gewicht | 33 | kg | Gesamtgewicht des Roboters. |
|
||||
| Abmessungen (Breite) | 50 | cm | Breite des Roboters; wichtig für die Navigation in engen Räumen. |
|
||||
| Maximale Steigung | 45 | ° | Maximale Steigung, die der Roboter bewältigen kann (oberflächenabhängig). |
|
||||
| IP-Schutzart | IP66 | - | Schutz gegen Staub und starkes Strahlwasser. |
|
||||
| Kletterhöhe | 25 | cm | Maximale Höhe, die der Roboter überwinden kann. |
|
||||
| Navigationstyp | SLAM, LiDAR | - | Verwendete Navigationstechnologien. |
|
||||
| Konnektivität | Gigabit Ethernet, USB 3.0 | - | Verfügbare Schnittstellen für Datenübertragung und Steuerung. |
|
||||
| Maximale Zuladung | 12 | kg | Maximale empfohlene Nutzlast. |
|
||||
| Kamera-Typen | Weitwinkel | - | Art der verbauten Kameras. |
|
||||
| Nachtsicht | Ja | - | Fähigkeit, in dunklen Umgebungen zu operieren. |
|
||||
| Gasdetektion | N/A | - | Fähigkeit zur Gasdetektion (nicht angegeben). |
|
||||
| Maximale Geschwindigkeit | 5 | m/s | Höchstgeschwindigkeit des Roboters. |
|
||||
| Kontinuierliche Geschwindigkeit | 3 | m/s | Typische Reisegeschwindigkeit. |
|
||||
| Betriebstemperatur | -20 bis 55 | °C | Betriebstemperaturbereich. |
|
||||
| Maximale Lastkapazität | 50 | kg | Absolute maximale Zuladung (kurzzeitig). |
|
||||
| LiDAR Linien | 96 | Linien | Auflösung des LiDAR-Systems. |
|
||||
| Externe Leistungsabgabe | 300 | W | Verfügbare Leistung für externe Geräte. |
|
||||
|
||||
## 4. Target Architecture (ICPs)
|
||||
|
||||
* **Öl- und Gasindustrie:** Die Öl- und Gasindustrie steht unter enormem Druck, die Sicherheit ihrer Anlagen zu erhöhen und gleichzeitig die Betriebskosten zu senken. Die weitläufigen und oft schwer zugänglichen Anlagen sind anfällig für unbefugten Zutritt, Diebstahl und Sabotage. Der PUMA M20 bietet hier eine ideale Lösung, da er autonome Patrouillen durchführen, Leckagen erkennen und die Sicherheit erhöhen kann, ohne Personal in gefährliche Situationen zu bringen. **Whale Accounts:** Wintershall Dea, BASF, Linde, RWE, Uniper.
|
||||
|
||||
* **Chemieindustrie:** In der Chemieindustrie sind die Risiken durch Gefahrstoffaustritte und Explosionen besonders hoch. Unternehmen sind gefordert, die Sicherheit ihrer Mitarbeiter und Anlagen zu gewährleisten und gleichzeitig die strengen Umweltauflagen einzuhalten. Der PUMA M20 kann in chemischen Anlagen Routineinspektionen und Überwachungsaufgaben übernehmen, Gaskonzentrationen messen und frühzeitig Warnungen ausgeben, was die Anlagensicherheit erheblich erhöht. **Whale Accounts:** BASF, Bayer, Evonik, Merck, Lanxess.
|
||||
|
||||
* **Kritische Infrastruktur & Versorgungsunternehmen:** Kritische Infrastrukturen wie Kraftwerke, Wasserwerke und Umspannwerke sind zunehmend Ziel von Vandalismus, Sabotage und Terrorismus. Der Schutz dieser Anlagen ist von entscheidender Bedeutung für die Versorgungssicherheit. Der PUMA M20 kann autonom um diese Anlagen patrouillieren, verdächtige Aktivitäten erkennen und potenzielle Täter abschrecken. **Whale Accounts:** E.ON, EnBW, Vattenfall, Tennet, Thyssengas.
|
||||
|
||||
* **Sicherheit & Überwachung (Große Areale):** Die Überwachung weitläufiger Industrieanlagen, Logistikzentren, Häfen oder militärischer Areale ist personalintensiv und teuer. Der PUMA M20 bietet hier eine kosteneffiziente Lösung, da er repetitive Patrouillen übernehmen, unbefugtes Betreten erkennen und so den Bedarf an teurem Sicherheitspersonal reduzieren kann. **Whale Accounts:** Securitas, Allianz Sicherheit, Bosch Sicherheitssysteme, Bundeswehr, Flughafen Frankfurt.
|
||||
|
||||
* **Bauwesen & Ingenieurwesen:** Auf großen Baustellen und bei Infrastrukturprojekten herrschen oft raue, unebene Bedingungen mit Hindernissen. Diebstahl von Baumaterialien und Werkzeugen sowie Vandalismus sind häufige Probleme. Der PUMA M20 kann für Baufortschrittsüberwachung, Sicherheitsinspektionen und die Dokumentation von Gelände- oder Bauzuständen eingesetzt werden, um Diebstahl zu verhindern und die Sicherheit zu erhöhen. **Whale Accounts:** Hochtief, Bilfinger, Züblin, Strabag, BAM Deutschland.
|
||||
|
||||
## 5. Strategy Matrix
|
||||
|
||||
| Target Segment | The Pain (Operational) | The Angle (Story) | Differentiation (Service Gap) |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| Öl- und Gasindustrie | Unbefugter Zutritt und Diebstahl auf weitläufigen, schwer zu überwachenden Geländen führen zu Produktionsausfällen und hohen Sicherheitskosten. | Der geländegängige Sicherheitsroboter patrouilliert autonom, erkennt Eindringlinge mit Wärmebildkameras und reduziert so das Risiko von Diebstahl und Sabotage. | Wackler Security bietet mit dem Roboter eine 24/7-Frühwarnung (Detektion & Präsenz) und kombiniert diese mit menschlicher Intervention durch unsere Sicherheitskräfte (Bewertung & Intervention). <br> Der Roboter sieht die Gefahr, Wackler beseitigt sie. |
|
||||
| Chemieindustrie | Gefahrstoffaustritte und Explosionen durch mangelnde Überwachung in potenziell gefährlichen Bereichen gefährden Mitarbeiter und Anlagen. | Der Sicherheitsroboter mit Gassensoren überwacht kontinuierlich die Luftqualität, erkennt Lecks frühzeitig und alarmiert bei Gefahrstoffaustritten, um die Sicherheit von Mitarbeitern und Anlagen zu gewährleisten. | Wackler Security kombiniert die unermüdliche Überwachung des Roboters (Detektion & Präsenz) mit der Expertise unserer Sicherheitsfachkräfte, die die Situation bewerten und gezielte Maßnahmen einleiten (Bewertung & Intervention). <br> Der Roboter sieht die Gefahr, Wackler beseitigt sie. |
|
||||
| Kritische Infrastruktur & Versorgungsunternehmen | Vandalismus, Sabotage und Terrorismus bedrohen die Versorgungssicherheit und führen zu hohen Reparaturkosten und Ausfällen. | Der Sicherheitsroboter patrouilliert autonom um Umspannwerke, Wasserwerke und andere kritische Infrastrukturen, erkennt verdächtige Aktivitäten und schreckt potenzielle Täter ab. | Wackler Security ergänzt die autonome Überwachung des Roboters (Detektion & Präsenz) mit der schnellen Reaktion unserer Interventionskräfte, die im Alarmfall vor Ort sind und die Situation unter Kontrolle bringen (Bewertung & Intervention). <br> Der Roboter sieht die Gefahr, Wackler beseitigt sie. |
|
||||
| Sicherheit & Überwachung (Große Areale) | Hohe Personalkosten und eingeschränkte Effektivität bei der Überwachung großer Gelände wie Lagerplätze, Parkhäuser oder Firmengelände. | Der Sicherheitsroboter übernimmt repetitive Patrouillen, erkennt unbefugtes Betreten und reduziert so den Bedarf an teurem Sicherheitspersonal. | Wackler Security kombiniert die unermüdliche Überwachung des Roboters (Detektion & Präsenz) mit der Erfahrung unserer Sicherheitsmitarbeiter in der NSL, die Alarme bewerten und bei Bedarf Interventionskräfte entsenden (Bewertung & Intervention). <br> Der Roboter sieht die Gefahr, Wackler beseitigt sie. |
|
||||
| Bauwesen & Ingenieurwesen | Diebstahl von Baumaterialien und Werkzeugen sowie Vandalismus auf Baustellen führen zu erheblichen finanziellen Verlusten und Verzögerungen. | Der Sicherheitsroboter überwacht Baustellen außerhalb der Arbeitszeiten, erkennt unbefugtes Betreten und schreckt Diebe und Vandalen ab. | Wackler Security verbindet die autonome Überwachung des Roboters (Detektion & Präsenz) mit der schnellen Reaktion unserer Interventionskräfte, die im Alarmfall vor Ort sind und die Täter stellen (Bewertung & Intervention). <br> Der Roboter sieht die Gefahr, Wackler beseitigt sie. |
|
||||
|
||||
## 6. Operational GTM Roadmap
|
||||
|
||||
* **Step 1: Lead Gen:**
|
||||
* **Inbound:**
|
||||
* **Content Marketing:** Erstellung von Blogartikeln, Whitepapers und Fallstudien, die die Vorteile des PUMA M20 in den jeweiligen Zielsegmenten hervorheben. Themen könnten sein: "Wie Sicherheitsroboter die Sicherheit in der Öl- und Gasindustrie revolutionieren" oder "Die Zukunft der Baustellenüberwachung: Robotik und KI".
|
||||
* **SEO:** Optimierung der Website und der Inhalte für relevante Suchbegriffe wie "Sicherheitsroboter", "autonome Überwachung", "Baustellenüberwachung", "Industriesicherheit".
|
||||
* **Webinare:** Durchführung von Webinaren zu Themen wie "Sicherheitsrisiken in kritischen Infrastrukturen und wie Robotik helfen kann" oder "Die ROI von Sicherheitsrobotern".
|
||||
* **Outbound:**
|
||||
* **Gezielte E-Mail-Kampagnen:** Ansprache von Sicherheitsverantwortlichen, Betriebsleitern und Einkäufern in den Zielunternehmen mit personalisierten E-Mails, die auf ihre spezifischen Herausforderungen eingehen.
|
||||
* **Messen und Konferenzen:** Teilnahme an relevanten Messen und Konferenzen wie der "Security Essen" oder der "A+A" (Arbeitsschutz Aktuell), um den PUMA M20 zu präsentieren und Kontakte zu knüpfen.
|
||||
* **Direktansprache:** Gezielte Ansprache von "Whale Accounts" durch persönliche Treffen und Präsentationen.
|
||||
|
||||
* **Step 2: Consultative Sales:**
|
||||
* **Site-Check:** Vor jedem Angebot ist ein detaillierter Site-Check unerlässlich. Dabei müssen folgende Aspekte berücksichtigt werden:
|
||||
* **Geländegegebenheiten:** Beurteilung der Oberflächenbeschaffenheit, Steigungen, Hindernisse und potenziellen Gefahrenquellen.
|
||||
* **Infrastruktur:** Prüfung der Verfügbarkeit von Stromanschlüssen, WLAN-Abdeckung und geeigneten Standorten für die Ladestation.
|
||||
* **Sicherheitsanforderungen:** Analyse der bestehenden Sicherheitsmaßnahmen und Identifizierung von Schwachstellen.
|
||||
* **Gesetzliche Bestimmungen:** Berücksichtigung von Datenschutzbestimmungen, Arbeitsschutzrichtlinien und anderen relevanten Gesetzen.
|
||||
* **Constraints:** Die folgenden Constraints müssen während des Site-Checks besonders beachtet werden:
|
||||
* **Maximale Steigung:** 45° (oberflächenabhängig).
|
||||
* **Schritt-Höhe:** 25 cm.
|
||||
* **Betriebstemperatur:** -20°C bis 55°C.
|
||||
* **Schutzart:** IP66 (Staub- und Wasserbeständigkeit).
|
||||
* **Maximale Zuladung:** 50 kg (12kg Nennlast).
|
||||
* **Lösungsdesign:** Entwicklung einer maßgeschneiderten Sicherheitslösung, die auf die spezifischen Bedürfnisse und Anforderungen des Kunden zugeschnitten ist.
|
||||
|
||||
* **Step 3: Proof of Value:**
|
||||
* **Pilot Phase:** Um das Vertrauen in die Leistungsfähigkeit des PUMA M20 zu stärken, wird eine Pilotphase empfohlen.
|
||||
* **Paid Pilot:** Eine kostenpflichtige Pilotphase ermöglicht es dem Kunden, den Roboter unter realen Bedingungen zu testen und die Vorteile selbst zu erleben. Dies signalisiert auch das Engagement des Kunden und erhöht die Wahrscheinlichkeit einer langfristigen Partnerschaft.
|
||||
* **Dauer:** Die Pilotphase sollte idealerweise 4-8 Wochen dauern.
|
||||
* **Erfolgsmessung:** Vor Beginn der Pilotphase werden klare Erfolgskriterien definiert (z.B. Reduzierung von Diebstählen, schnellere Reaktionszeiten, verbesserte Datenerfassung).
|
||||
* **Free PoC (Proof of Concept):** In Ausnahmefällen kann ein kostenloser PoC angeboten werden, um das Interesse des Kunden zu wecken und die technischen Möglichkeiten des Roboters zu demonstrieren.
|
||||
|
||||
* **Step 4: Expansion:**
|
||||
* **RaaS (Robot as a Service):** Der PUMA M20 wird idealerweise als RaaS-Modell angeboten, bei dem der Kunde eine monatliche Gebühr für die Nutzung des Roboters und die zugehörigen Dienstleistungen zahlt.
|
||||
* **Serviceverträge:** Die Serviceverträge umfassen Wartung, Reparatur, Software-Updates und Support.
|
||||
* **Upselling:** Nach erfolgreichem Abschluss der Pilotphase können zusätzliche Dienstleistungen wie die Integration von weiteren Sensoren, die Anpassung der Software oder die Schulung von Mitarbeitern angeboten werden.
|
||||
|
||||
## 7. Commercial Logic (ROI Framework)
|
||||
|
||||
* **ROI Calculation Logic:** Die Investition in den PUMA M20 als Sicherheitsroboter sollte sich durch eine Kombination aus Kosteneinsparungen, Effizienzsteigerungen und Risikominimierung auszahlen.
|
||||
|
||||
* **The Formula:**
|
||||
|
||||
**Net Value = (Kosteneinsparungen + Effizienzgewinne + Risikominimierung) - Kosten**
|
||||
|
||||
Wobei:
|
||||
|
||||
* **Kosteneinsparungen:** Reduzierung der Personalkosten für Sicherheitskräfte, geringere Versicherungsprämien aufgrund verbesserter Sicherheit, Reduzierung von Diebstahl und Vandalismus.
|
||||
* **Effizienzgewinne:** Schnellere Reaktionszeiten auf Vorfälle, verbesserte Datenerfassung und -analyse, höhere Verfügbarkeit der Anlagen durch präventive Wartung.
|
||||
* **Risikominimierung:** Reduzierung des Risikos von Unfällen, Gefahrstoffaustritten und Sabotage, verbesserte Einhaltung von Sicherheitsvorschriften.
|
||||
* **Kosten:** Anschaffungskosten (oder monatliche RaaS-Gebühren), Wartungskosten, Energiekosten, Schulungskosten.
|
||||
|
||||
* **Input Variables:**
|
||||
|
||||
Der Kunde muss folgende Variablen bereitstellen, um den ROI zu berechnen:
|
||||
|
||||
* **Aktuelle Personalkosten für Sicherheitskräfte:** Anzahl der Mitarbeiter, Stundensätze, Schichtzulagen.
|
||||
* **Kosten für Diebstahl und Vandalismus:** Jährliche Verluste durch Diebstahl, Vandalismus und Sabotage.
|
||||
* **Versicherungsprämien:** Aktuelle Versicherungsprämien für Sach- und Haftpflichtversicherungen.
|
||||
* **Reaktionszeiten auf Vorfälle:** Durchschnittliche Zeit, die benötigt wird, um auf Sicherheitsvorfälle zu reagieren.
|
||||
* **Kosten für Ausfallzeiten:** Kosten, die durch Produktionsausfälle aufgrund von Sicherheitsvorfällen entstehen.
|
||||
* **Energiekosten:** Stromverbrauch des Roboters.
|
||||
* **Wartungskosten:** Geschätzte Wartungskosten pro Jahr.
|
||||
|
||||
* **Example Calculation:**
|
||||
|
||||
Angenommen, ein Chemieunternehmen setzt derzeit 5 Sicherheitskräfte pro Schicht ein, um eine Anlage zu überwachen. Die jährlichen Personalkosten betragen 500.000 €. Durch den Einsatz des PUMA M20 kann das Unternehmen die Anzahl der Sicherheitskräfte auf 2 pro Schicht reduzieren.
|
||||
|
||||
* **Kosteneinsparungen:** Reduzierung der Personalkosten um 60% = 300.000 € pro Jahr.
|
||||
* **Effizienzgewinne:** Angenommen, der Roboter ermöglicht eine 20% schnellere Reaktionszeit auf Vorfälle, was zu einer Reduzierung der Ausfallzeiten um 10% führt. Dies entspricht einer Einsparung von 50.000 € pro Jahr.
|
||||
* **Risikominimierung:** Angenommen, der Roboter reduziert das Risiko von Diebstahl und Vandalismus um 50%, was zu einer Einsparung von 25.000 € pro Jahr führt.
|
||||
* **Kosten:** Die jährlichen RaaS-Gebühren für den PUMA M20 betragen 100.000 €, die Wartungskosten 5.000 € und die Energiekosten 1.000 €.
|
||||
|
||||
**Net Value = (300.000 € + 50.000 € + 25.000 €) - (100.000 € + 5.000 € + 1.000 €) = 269.000 € pro Jahr.**
|
||||
|
||||
In diesem Beispiel würde die Investition in den PUMA M20 zu einer jährlichen Nettoeinsparung von 269.000 € führen.
|
||||
|
||||
# SALES ENABLEMENT & VISUALS (PHASE 6)
|
||||
|
||||
## Kill-Critique Battlecards
|
||||
|
||||
### Persona: Operativer Entscheider (Anlagenleiter)
|
||||
> **Objection:** "Der Roboter ist zu unzuverlässig und führt zu Produktionsausfällen, wenn er ausfällt oder Fehler macht."
|
||||
|
||||
**Response:** Unsere Sicherheitsroboter sind für den robusten Dauereinsatz konzipiert (IP66-Schutz, -20°C bis 55°C). Durch das Flottenmanagement und die Hot-Swap-Batterien garantieren wir eine hohe Verfügbarkeit. Wackler Security stellt zudem die schnelle Reaktionszeit unserer Interventionskräfte sicher, um Ausfälle zu minimieren. Der Roboter sieht die Gefahr, Wackler beseitigt sie.
|
||||
|
||||
---
|
||||
|
||||
### Persona: Infrastruktur Verantwortlicher (IT-Sicherheitsbeauftragter)
|
||||
> **Objection:** "Der Einsatz von Robotern gefährdet die Datensicherheit und Compliance (DSGVO, DGUV V3), insbesondere bei der Verarbeitung von Bild- und Sensordaten."
|
||||
|
||||
**Response:** Unsere Roboter sind DSGVO-konform. Die Datenverarbeitung erfolgt lokal oder in zertifizierten Rechenzentren in Deutschland. Die Einhaltung der DGUV V3 wird durch regelmäßige Prüfungen und Zertifizierungen gewährleistet. Wackler Security übernimmt die volle Haftung für den Betrieb der Roboter und stellt sicher, dass alle relevanten Sicherheitsstandards eingehalten werden. Der Roboter sieht die Gefahr, Wackler beseitigt sie.
|
||||
|
||||
---
|
||||
|
||||
### Persona: Wirtschaftlicher Entscheider (CFO)
|
||||
> **Objection:** "Die Investition in Sicherheitsroboter ist zu teuer und rechnet sich nicht im Vergleich zu herkömmlichen Sicherheitsmaßnahmen."
|
||||
|
||||
**Response:** Unsere Sicherheitsroboter reduzieren langfristig die Personalkosten und minimieren das Risiko von Diebstahl, Vandalismus und Produktionsausfällen. Eine detaillierte ROI-Analyse, basierend auf Ihren spezifischen Gegebenheiten, zeigt die deutliche Kostenersparnis und den Mehrwert durch den Einsatz unserer Roboter. Wackler Security bietet flexible Finanzierungsmodelle und übernimmt die Wartung und Instandhaltung der Roboter. Der Roboter sieht die Gefahr, Wackler beseitigt sie.
|
||||
|
||||
---
|
||||
|
||||
### Persona: Innovations-Treiber (CDO)
|
||||
> **Objection:** "Die Integration der Roboter in unsere bestehende IT-Infrastruktur ist zu komplex und aufwendig."
|
||||
|
||||
**Response:** Unsere Roboter bieten flexible API-Integrationen und Flottenmanagement-Systeme, die eine nahtlose Integration in Ihre bestehende IT-Infrastruktur ermöglichen. Wir unterstützen Sie bei der Implementierung und bieten umfassende Schulungen für Ihre Mitarbeiter. Die erweiterte Rechenleistung und die anpassbaren Nutzlasten der Roboter ermöglichen innovative Anwendungen und neue Geschäftsmodelle. Der Roboter sieht die Gefahr, Wackler beseitigt sie.
|
||||
|
||||
---
|
||||
|
||||
## Visual Briefings (Prompts)
|
||||
|
||||
### Öl- und Gasindustrie: Autonome Patrouille
|
||||
*Context: Ein geländegängiger Sicherheitsroboter patrouilliert autonom auf einem weitläufigen Ölfeld bei Nacht. Im Hintergrund sind Bohrtürme und Pipelines zu sehen.*
|
||||
|
||||
```
|
||||
Photorealistisches Bild eines robusten, geländegängigen Sicherheitsroboters mit Wärmebildkamera, der auf einem unebenen Ölfeld bei Nacht patrouilliert. Der Roboter ist mit Sensoren und Kameras ausgestattet. Im Hintergrund sind beleuchtete Bohrtürme und Pipelines zu sehen. Der Himmel ist klar und sternenklar. Die Szene ist düster und unheimlich, aber auch technologisch fortschrittlich.
|
||||
```
|
||||
|
||||
### Chemieindustrie: Gefahrstoffüberwachung
|
||||
*Context: Ein Sicherheitsroboter mit Gassensoren überwacht die Luftqualität in einer Chemieanlage. Im Hintergrund sind Tanks und Rohrleitungen zu sehen.*
|
||||
|
||||
```
|
||||
Photorealistisches Bild eines Sicherheitsroboters mit Gassensoren, der in einer Chemieanlage die Luftqualität überwacht. Der Roboter ist mit Sensoren und Kameras ausgestattet. Im Hintergrund sind Tanks, Rohrleitungen und Produktionsanlagen zu sehen. Die Szene ist hell und industriell, aber auch potenziell gefährlich.
|
||||
```
|
||||
|
||||
### Kritische Infrastruktur: Umspannwerk-Überwachung
|
||||
*Context: Ein Sicherheitsroboter patrouilliert autonom um ein Umspannwerk. Im Hintergrund sind Hochspannungsleitungen und Transformatoren zu sehen.*
|
||||
|
||||
```
|
||||
Photorealistisches Bild eines Sicherheitsroboters, der autonom um ein Umspannwerk patrouilliert. Der Roboter ist mit Sensoren und Kameras ausgestattet. Im Hintergrund sind Hochspannungsleitungen, Transformatoren und Schaltanlagen zu sehen. Die Szene ist technisch und industriell, aber auch sicherheitsrelevant.
|
||||
```
|
||||
|
||||
|
||||
|
||||
# VERTICAL LANDING PAGES (PHASE 7)
|
||||
|
||||
## Öl- und Gasindustrie
|
||||
**Headline:** Reduzieren Sie Produktionsausfälle und Sicherheitskosten in der Öl- und Gasindustrie
|
||||
|
||||
**Subline:** Unbefugter Zutritt und Diebstahl auf weitläufigen Geländen führen zu erheblichen Verlusten. Unser geländegängiger Sicherheitsroboter patrouilliert autonom und in Kombination mit Wackler Security wird Ihr Gelände optimal geschützt.
|
||||
|
||||
**Benefits:**
|
||||
- 24/7-Frühwarnung durch unermüdliche Roboter-Patrouillen, die Eindringlinge mit Wärmebildkameras erkennen – auch bei Dunkelheit und schlechtem Wetter.
|
||||
- Minimieren Sie das Risiko von Diebstahl und Sabotage durch die abschreckende Wirkung des Roboters und die sofortige Alarmierung unserer Interventionskräfte.
|
||||
- Senken Sie Ihre Sicherheitskosten, indem Sie den Bedarf an teurem Sicherheitspersonal reduzieren und gleichzeitig die Sicherheit erhöhen.
|
||||
- Verbessern Sie die Reaktionszeiten im Notfall durch die schnelle und präzise Lokalisierung von Vorfällen durch den Roboter.
|
||||
|
||||
**CTA:** Fordern Sie jetzt eine Demo an!
|
||||
|
||||
---
|
||||
|
||||
## Chemieindustrie
|
||||
**Headline:** Maximale Sicherheit für Mitarbeiter und Anlagen in der Chemieindustrie
|
||||
|
||||
**Subline:** Gefahrstoffaustritte und Explosionen gefährden Mitarbeiter und Anlagen. Unser Sicherheitsroboter mit Gassensoren überwacht kontinuierlich die Luftqualität und in Kombination mit Wackler Security wird die Sicherheit Ihrer Mitarbeiter und Anlagen gewährleistet.
|
||||
|
||||
**Benefits:**
|
||||
- Frühzeitige Erkennung von Gefahrstoffaustritten durch kontinuierliche Überwachung der Luftqualität mit hochsensiblen Gassensoren.
|
||||
- Minimieren Sie das Risiko von Unfällen und Explosionen durch die sofortige Alarmierung bei Gefahrstoffaustritten und die schnelle Reaktion unserer Sicherheitsfachkräfte.
|
||||
- Schützen Sie Ihre Mitarbeiter vor gesundheitsschädlichen Einwirkungen durch die Automatisierung von Inspektions- und Überwachungsaufgaben in potenziell gefährlichen Bereichen.
|
||||
- Erfüllen Sie höchste Sicherheitsstandards und gesetzliche Auflagen durch die lückenlose Dokumentation der Überwachungsergebnisse.
|
||||
|
||||
**CTA:** Vereinbaren Sie jetzt ein Beratungsgespräch!
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
# BUSINESS CASE & ROI (PHASE 8)
|
||||
|
||||
## Öl- und Gasindustrie
|
||||
**Cost Driver:** Stunde der manuellen Inspektion von Pipelines und Anlagen.
|
||||
|
||||
**Efficiency Gain:** Schätzung: 40-60% Reduktion der manuellen Inspektionszeit durch autonome Roboterpatrouillen.
|
||||
|
||||
**Risk Argument:** Die Kosten eines unentdeckten Pipeline-Lecks können von Umweltschäden in Millionenhöhe bis hin zu Produktionsausfällen und Strafen reichen. Der Einsatz von Robotern zur frühzeitigen Erkennung minimiert diese potenziellen Verluste erheblich.
|
||||
|
||||
---
|
||||
|
||||
## Chemieindustrie
|
||||
**Cost Driver:** Stunde der manuellen Überwachung und Inspektion gefährlicher Bereiche.
|
||||
|
||||
**Efficiency Gain:** Schätzung: 50-70% Reduktion der manuellen Überwachungszeit in potenziell gefährlichen Bereichen.
|
||||
|
||||
**Risk Argument:** Ein unentdeckter Gasaustritt oder eine Explosion in einer chemischen Anlage kann zu erheblichen Sachschäden, Personenschäden und Produktionsausfällen führen. Die frühzeitige Erkennung durch Roboter kann diese Risiken minimieren und die Anlagensicherheit erhöhen.
|
||||
|
||||
---
|
||||
|
||||
## Kritische Infrastruktur & Versorgungsunternehmen
|
||||
**Cost Driver:** Stunde der manuellen Inspektion von Anlagen in gefährlichen oder schwer zugänglichen Umgebungen.
|
||||
|
||||
**Efficiency Gain:** Schätzung: 30-50% Reduktion der Inspektionszeit durch autonome Datenerfassung und Anomalieerkennung.
|
||||
|
||||
**Risk Argument:** Der Ausfall kritischer Infrastruktur kann zu erheblichen wirtschaftlichen Schäden und Versorgungsengpässen führen. Die kontinuierliche Überwachung durch Roboter kann frühzeitig Anomalien erkennen und Ausfälle verhindern.
|
||||
|
||||
---
|
||||
|
||||
## Sicherheit & Überwachung (Große Areale)
|
||||
**Cost Driver:** Stunde der manuellen Patrouille durch Sicherheitspersonal.
|
||||
|
||||
**Efficiency Gain:** Schätzung: 60-80% Reduktion der manuellen Patrouillenzeit durch autonome 24/7-Patrouillen.
|
||||
|
||||
**Risk Argument:** Ein Einbruch oder Sicherheitsvorfall auf einem großen Areal kann zu erheblichen finanziellen Verlusten und Imageschäden führen. Die autonome Überwachung durch Roboter kann Einbrüche verhindern und die Reaktionszeit im Notfall verkürzen.
|
||||
|
||||
---
|
||||
|
||||
## Bauwesen & Ingenieurwesen
|
||||
**Cost Driver:** Stunde der manuellen Baufortschrittsüberwachung, Vermessung und Sicherheitsinspektion.
|
||||
|
||||
**Efficiency Gain:** Schätzung: 25-45% Reduktion der manuellen Arbeitszeit für Datenerfassung und Dokumentation.
|
||||
|
||||
**Risk Argument:** Bauverzögerungen und Sicherheitsmängel können zu erheblichen Kostenüberschreitungen und Vertragsstrafen führen. Die automatisierte Datenerfassung und Überwachung durch Roboter kann diese Risiken minimieren und die Projektplanung verbessern.
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
# FEATURE-TO-VALUE TRANSLATOR (PHASE 9)
|
||||
|
||||
| Feature | The Story (Benefit) | Headline |
|
||||
| :--- | :--- | :--- |
|
||||
| Geländegängigkeit: Bewältigt unebenes Gelände, Treppen und Steigungen bis zu 45°. | Der Roboter kann sich in anspruchsvollem Gelände bewegen. Das bedeutet, dass er auch schwer zugängliche Bereiche erreichen kann. Dies führt zu einer umfassenderen Überwachung und reduziert das Risiko unentdeckter Sicherheitslücken. | Umfassende Sicherheit auch in unwegsamem Gelände. |
|
||||
| Autonome Navigation: SLAM-basierte Navigation für autonome Missionen und Rückkehr zur Basis. | Der Roboter navigiert selbstständig und kehrt automatisch zur Ladestation zurück. Das bedeutet, dass er ohne ständige menschliche Überwachung patrouillieren kann. Dies spart Personalkosten und ermöglicht eine kontinuierliche Überwachung. | Autonome Patrouillen rund um die Uhr. |
|
||||
| 360°-Umgebungserfassung: Duale LiDAR-Systeme und Weitwinkelkameras für umfassende Umgebungswahrnehmung. | Der Roboter erfasst seine Umgebung lückenlos. Das bedeutet, dass keine toten Winkel entstehen und potenzielle Gefahren frühzeitig erkannt werden. Dies erhöht die Sicherheit und minimiert das Risiko von Schäden. | Lückenlose Überwachung für maximale Sicherheit. |
|
||||
| Nachtsichtfähigkeit: Optionale Nacht- und Wärmebildkamera. | Der Roboter kann auch bei Dunkelheit und schlechten Sichtverhältnissen eingesetzt werden. Das bedeutet, dass er auch nachts effektiv patrouillieren und Gefahren erkennen kann. Dies erhöht die Sicherheit rund um die Uhr. | Sicherheit rund um die Uhr, auch im Dunkeln. |
|
||||
| Robuste Bauweise: IP66-Schutz für Staub- und Wasserbeständigkeit, Betriebstemperaturbereich von -20°C bis 55°C. | Der Roboter ist widerstandsfähig gegen extreme Bedingungen. Das bedeutet, dass er auch in rauen Umgebungen zuverlässig funktioniert. Dies reduziert Ausfallzeiten und Wartungskosten. | Zuverlässige Sicherheit auch unter extremen Bedingungen. |
|
||||
| Hohe Zuladung: Bis zu 50 kg maximale Zuladung. | Der Roboter kann schwere Lasten transportieren. Das bedeutet, dass er zusätzliche Ausrüstung wie Sensoren oder Werkzeuge mitführen kann. Dies erweitert seine Einsatzmöglichkeiten und erhöht seine Effizienz. | Mehr Flexibilität durch hohe Zuladung. |
|
||||
| Lange Betriebsdauer: Bis zu 3 Stunden Betriebsdauer, erweiterbar durch Hot-Swap-Batterien. | Der Roboter kann lange ohne Aufladen betrieben werden. Das bedeutet, dass er lange Patrouillen durchführen kann, ohne unterbrochen zu werden. Durch die Hot-Swap-Batterien ist ein unterbrechungsfreier Betrieb möglich. | Kontinuierliche Sicherheit ohne Unterbrechung. |
|
||||
| Erweiterte Rechenleistung: Duale Octa-Core 64-Bit Industrieprozessoren. | Der Roboter verfügt über hohe Rechenleistung. Das bedeutet, dass er komplexe Aufgaben wie Bildverarbeitung und Datenanalyse schnell und effizient ausführen kann. Dies ermöglicht eine schnellere Reaktion auf potenzielle Gefahren. | Schnelle Reaktion dank hoher Rechenleistung. |
|
||||
| Flexible Integration: Flottenmanagement und API-Integrationen für Datenaustausch. | Der Roboter lässt sich einfach in bestehende Systeme integrieren. Das bedeutet, dass er Daten austauschen und mit anderen Geräten kommunizieren kann. Dies ermöglicht eine zentrale Überwachung und Steuerung der Sicherheitsmaßnahmen. | Einfache Integration in bestehende Systeme. |
|
||||
| Anpassbare Nutzlasten: Unterstützung für LiDAR, Thermal-, PTZ-, Gassensoren und Beacons. | Der Roboter kann mit verschiedenen Sensoren ausgestattet werden. Das bedeutet, dass er an spezifische Sicherheitsanforderungen angepasst werden kann. Dies ermöglicht eine maßgeschneiderte Sicherheitslösung. | Maßgeschneiderte Sicherheit für Ihre spezifischen Bedürfnisse. |
|
||||
@@ -1,73 +0,0 @@
|
||||
# Heatmap Tool (Standalone)
|
||||
|
||||
Eine Webanwendung zur Visualisierung von Excel-Daten (XLSX) auf einer Deutschlandkarte. Das Tool aggregiert Daten basierend auf Postleitzahlen (PLZ) und stellt sie entweder als Punkte-Cluster oder als Heatmap dar.
|
||||
|
||||
## Features
|
||||
|
||||
* **Excel Upload:** Lädt beliebige `.xlsx` Dateien. Erkennt automatisch die PLZ-Spalte (oder fragt nach, wenn unklar).
|
||||
* **Datenschutz:** Daten werden nur temporär im RAM des Containers verarbeitet. Keine Datenbank.
|
||||
* **Visualisierung:**
|
||||
* **Punkte-Karte:** Kreise pro PLZ, Radius = Anzahl der Einträge. Mit Marker-Clustering beim Herauszoomen.
|
||||
* **Heatmap:** Klassische Dichte-Darstellung.
|
||||
* **Interaktive Filter:** Alle anderen Spalten der Excel-Datei werden automatisch zu Filtern (Checkboxen).
|
||||
* **Dynamische Tooltips:** Benutzer können per Drag-and-Drop konfigurieren, welche Daten im Tooltip eines Punktes angezeigt werden.
|
||||
|
||||
## Installation & Start
|
||||
|
||||
Das Projekt ist vollständig dockerisiert.
|
||||
|
||||
1. In das Verzeichnis wechseln:
|
||||
```bash
|
||||
cd heatmap-tool
|
||||
```
|
||||
|
||||
2. Container starten (im Hintergrund):
|
||||
```bash
|
||||
docker-compose up --build -d
|
||||
```
|
||||
|
||||
3. Anwendung öffnen:
|
||||
* Browser: `http://<DEINE-IP>:5173` (Frontend)
|
||||
* Die API läuft intern auf Port 8000, ist aber von außen auf Port `8002` gemappt (wird aber durch den Vite-Proxy auf 5173 getunnelt).
|
||||
|
||||
4. Stoppen:
|
||||
```bash
|
||||
docker-compose down
|
||||
```
|
||||
|
||||
## Architektur
|
||||
|
||||
* **Frontend:** React 19, Vite, Leaflet (`react-leaflet`, `react-leaflet-cluster`, `react-leaflet-heatmap-layer-v3`).
|
||||
* **Backend:** Python FastAPI, Pandas (für Excel-Processing).
|
||||
* **Kommunikation:** Das Frontend nutzt einen Proxy (`vite.config.ts`), um Anfragen an `/api` an das Backend weiterzuleiten.
|
||||
|
||||
## Lessons Learned & Known Issues (WICHTIG!)
|
||||
|
||||
### 1. Docker Networking & Vite Proxy
|
||||
* **Problem:** Frontend-Container konnte Backend nicht unter `localhost` oder `127.0.0.1` erreichen.
|
||||
* **Lösung:**
|
||||
1. Beide Services müssen im selben Docker-Netzwerk sein (`networks: - heatmap-net` in `docker-compose.yml`).
|
||||
2. Das Frontend darf **nicht** versuchen, die URL hartcodiert aufzurufen.
|
||||
3. Stattdessen muss in `vite.config.ts` ein `server.proxy` eingerichtet werden, der `/api` auf `http://backend:8000` (Service-Name!) weiterleitet.
|
||||
4. Im Frontend-Code (`App.tsx`, `axios`) werden dann nur relative Pfade genutzt (z.B. `/api/upload`).
|
||||
|
||||
### 2. React 19 vs. Leaflet Libraries
|
||||
* **Problem:** Viele Leaflet-Plugins (wie `react-leaflet-heatmap-layer-v3`) haben veraltete Peer-Dependencies (z.B. React 17), was bei `npm install` zu Fehlern führt.
|
||||
* **Lösung:**
|
||||
* Lokal: Installation mit `--legacy-peer-deps`.
|
||||
* **Docker Build:** Im `Dockerfile` des Frontends muss zwingend `RUN npm install --legacy-peer-deps` stehen, sonst schlägt der Build fehl.
|
||||
|
||||
### 3. Import/Export Syntax (TypeScript)
|
||||
* **Problem:** `Uncaught SyntaxError: The requested module ... does not provide an export named ...`
|
||||
* **Ursache:** Beim Importieren von TypeScript-Interfaces (z.B. `TooltipColumn`) in einer `.tsx` Datei wurde das Schlüsselwort `type` vergessen.
|
||||
* **Korrekt:** `import type { TooltipColumn } from '../App';`
|
||||
* **Falsch:** `import { TooltipColumn } from '../App';` (Führt zu Runtime-Fehlern im Browser, da Vite versucht, es als JS-Code zu kompilieren).
|
||||
|
||||
### 4. Endlosschleifen bei Karten-Events (Vorsicht!)
|
||||
* **Problem:** Versuch, eine "zoom-adaptive Legende" zu bauen, die den `maxCount` basierend auf dem sichtbaren Ausschnitt neu berechnet.
|
||||
* **Fehler:** Ein `useEffect` oder Event-Handler (`useMapEvents`), der den State (`visibleData`) aktualisiert, löst ein Re-Render der Karte aus. Das Re-Render löst erneut das Event aus -> **Endlosschleife / Stack Overflow**.
|
||||
* **Status:** Feature wurde reverted. Wenn wir das wieder einbauen, muss der Handler vom Rendering entkoppelt sein (z.B. via `useCallback` oder komplett außerhalb der Render-Logik der Map-Komponente).
|
||||
|
||||
### 5. Daten-Normalisierung
|
||||
* **Problem:** `KeyError: 'plz'`. Die Excel-Datei hatte "PLZ" (groß), das Backend erwartete "plz" (klein) oder umgekehrt.
|
||||
* **Lösung:** Das Backend normalisiert jetzt die Spaltennamen intern, bevor die Daten an das Frontend gesendet werden. Das Frontend verlässt sich strikt auf klein geschrieben `plz`, `lat`, `lon`.
|
||||
@@ -1,20 +0,0 @@
|
||||
# Use an official Python runtime as a parent image
|
||||
FROM python:3.9-slim
|
||||
|
||||
# Set the working directory in the container
|
||||
WORKDIR /app
|
||||
|
||||
# Copy the requirements file into the container at /app
|
||||
COPY requirements.txt .
|
||||
|
||||
# Install any needed packages specified in requirements.txt
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy the rest of the application's code from the host to the container at /app
|
||||
COPY . .
|
||||
|
||||
# Expose port 8000 to the outside world
|
||||
EXPOSE 8000
|
||||
|
||||
# Command to run the application
|
||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
@@ -1,234 +0,0 @@
|
||||
from fastapi import FastAPI, File, UploadFile, HTTPException
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
import pandas as pd
|
||||
import io
|
||||
from pydantic import BaseModel
|
||||
from typing import Dict, List
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
# Configure CORS
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"], # Allows all origins
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"], # Allows all methods
|
||||
allow_headers=["*"], # Allows all headers
|
||||
)
|
||||
|
||||
# --- In-memory Storage & Data Loading ---
|
||||
df_storage = None
|
||||
plz_column_name = None
|
||||
plz_geocoord_df = None
|
||||
|
||||
@app.on_event("startup")
|
||||
def load_plz_data():
|
||||
global plz_geocoord_df
|
||||
try:
|
||||
print("--- Loading PLZ geocoordinates dataset... ---")
|
||||
# The CSV has a malformed header. We read it and assign names manually.
|
||||
df = pd.read_csv("plz_geocoord.csv", dtype=str)
|
||||
# Rename the columns based on their expected order: PLZ, Latitude, Longitude
|
||||
df.columns = ['plz', 'y', 'x']
|
||||
|
||||
df['plz'] = df['plz'].str.zfill(5)
|
||||
plz_geocoord_df = df.set_index('plz')
|
||||
print(f"--- Successfully loaded {len(plz_geocoord_df)} PLZ coordinates. ---")
|
||||
except FileNotFoundError:
|
||||
print("--- FATAL ERROR: plz_geocoord.csv not found. Geocoding will not work. ---")
|
||||
plz_geocoord_df = pd.DataFrame()
|
||||
except Exception as e:
|
||||
print(f"--- FATAL ERROR loading plz_geocoord.csv: {e} ---")
|
||||
plz_geocoord_df = pd.DataFrame()
|
||||
|
||||
# --- Pydantic Models ---
|
||||
class TooltipColumnConfig(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
visible: bool
|
||||
|
||||
class FilterRequest(BaseModel):
|
||||
filters: Dict[str, List[str]]
|
||||
tooltip_config: List[TooltipColumnConfig] = []
|
||||
|
||||
class PlzColumnRequest(BaseModel):
|
||||
plz_column: str
|
||||
|
||||
# --- API Endpoints ---
|
||||
@app.get("/ ")
|
||||
def read_root():
|
||||
return {"message": "Heatmap Tool Backend"}
|
||||
|
||||
@app.post("/api/upload")
|
||||
async def upload_file(file: UploadFile = File(...)):
|
||||
global df_storage, plz_column_name
|
||||
print(f"--- Received request to /api/upload for file: {file.filename} ---")
|
||||
if not file.filename.endswith('.xlsx'):
|
||||
raise HTTPException(status_code=400, detail="Invalid file format. Please upload an .xlsx file.")
|
||||
|
||||
try:
|
||||
contents = await file.read()
|
||||
df = pd.read_excel(io.BytesIO(contents), dtype=str) # Read all as string to be safe
|
||||
df.fillna('N/A', inplace=True)
|
||||
df_storage = df # Store dataframe temporarily
|
||||
|
||||
# --- PLZ Column Detection ---
|
||||
temp_plz_col = None
|
||||
for col in df.columns:
|
||||
if 'plz' in col.lower():
|
||||
temp_plz_col = col
|
||||
break
|
||||
|
||||
if not temp_plz_col:
|
||||
print("PLZ column not found automatically. Asking user for selection.")
|
||||
return {"plz_column_needed": True, "columns": list(df.columns)}
|
||||
|
||||
# If we found a column, proceed as before
|
||||
plz_column_name = temp_plz_col
|
||||
df[plz_column_name] = df[plz_column_name].str.strip().str.zfill(5)
|
||||
df_storage = df # Update storage with normalized PLZ
|
||||
|
||||
filters = {}
|
||||
for col in df.columns:
|
||||
if col != plz_column_name:
|
||||
unique_values = df[col].unique().tolist()
|
||||
filters[col] = sorted(unique_values)
|
||||
|
||||
print(f"Successfully processed file. Found PLZ column: '{plz_column_name}'.")
|
||||
return {"plz_column_needed": False, "filters": filters, "plz_column": plz_column_name}
|
||||
|
||||
except Exception as e:
|
||||
print(f"ERROR processing file: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"An error occurred while processing the file: {e}")
|
||||
|
||||
|
||||
@app.post("/api/set-plz-column")
|
||||
async def set_plz_column(request: PlzColumnRequest):
|
||||
global df_storage, plz_column_name
|
||||
print(f"--- Received request to set PLZ column to: {request.plz_column} ---")
|
||||
if df_storage is None:
|
||||
raise HTTPException(status_code=400, detail="No data available. Please upload a file first.")
|
||||
|
||||
plz_column_name = request.plz_column
|
||||
if plz_column_name not in df_storage.columns:
|
||||
raise HTTPException(status_code=400, detail=f"Column '{plz_column_name}' not found in the uploaded file.")
|
||||
|
||||
# Normalize PLZ data
|
||||
df_storage[plz_column_name] = df_storage[plz_column_name].str.strip().str.zfill(5)
|
||||
|
||||
# --- Dynamic Filter Detection ---
|
||||
filters = {}
|
||||
for col in df_storage.columns:
|
||||
if col != plz_column_name:
|
||||
unique_values = df_storage[col].unique().tolist()
|
||||
filters[col] = sorted(unique_values)
|
||||
|
||||
print(f"Successfully set PLZ column. Detected {len(filters)} filterable columns.")
|
||||
return {"plz_column_needed": False, "filters": filters, "plz_column": plz_column_name}
|
||||
|
||||
|
||||
@app.post("/api/heatmap")
|
||||
async def get_heatmap_data(request: FilterRequest):
|
||||
global df_storage, plz_column_name, plz_geocoord_df
|
||||
print(f"--- Received request to /api/heatmap with filters: {request.filters} ---")
|
||||
if df_storage is None:
|
||||
print("ERROR: No data in df_storage. File must be uploaded first.")
|
||||
raise HTTPException(status_code=404, detail="No data available. Please upload a file first.")
|
||||
if plz_geocoord_df.empty:
|
||||
raise HTTPException(status_code=500, detail="Geocoding data is not available on the server.")
|
||||
|
||||
try:
|
||||
filtered_df = df_storage.copy()
|
||||
|
||||
# Apply filters from the request
|
||||
for column, values in request.filters.items():
|
||||
if values:
|
||||
filtered_df = filtered_df[filtered_df[column].isin(values)]
|
||||
|
||||
if filtered_df.empty:
|
||||
return []
|
||||
|
||||
# Aggregate data by PLZ, and also collect attribute summaries
|
||||
plz_grouped = filtered_df.groupby(plz_column_name)
|
||||
plz_counts = plz_grouped.size().reset_index(name='count')
|
||||
|
||||
# Collect unique attributes for each PLZ based on tooltip_config
|
||||
attribute_summaries = {}
|
||||
if request.tooltip_config:
|
||||
visible_columns = [col.name for col in request.tooltip_config if col.visible]
|
||||
ordered_columns = [col.name for col in request.tooltip_config]
|
||||
else:
|
||||
# Fallback if no config is provided
|
||||
visible_columns = [col for col in filtered_df.columns if col != plz_column_name]
|
||||
ordered_columns = visible_columns
|
||||
|
||||
for plz_val, group in plz_grouped:
|
||||
summary = {}
|
||||
for col_name in visible_columns:
|
||||
if col_name in group:
|
||||
unique_attrs = group[col_name].unique().tolist()
|
||||
summary[col_name] = unique_attrs[:3]
|
||||
attribute_summaries[plz_val] = summary
|
||||
|
||||
# Convert summaries to a DataFrame for merging
|
||||
summary_df = pd.DataFrame.from_dict(attribute_summaries, orient='index')
|
||||
summary_df.index.name = plz_column_name
|
||||
|
||||
# --- Geocoding Step ---
|
||||
# Merge the aggregated counts with the geocoding dataframe
|
||||
merged_df = pd.merge(
|
||||
plz_counts,
|
||||
plz_geocoord_df,
|
||||
left_on=plz_column_name,
|
||||
right_index=True,
|
||||
how='inner'
|
||||
)
|
||||
|
||||
# Merge with attribute summaries
|
||||
merged_df = pd.merge(
|
||||
merged_df,
|
||||
summary_df,
|
||||
left_on=plz_column_name,
|
||||
right_index=True,
|
||||
how='left'
|
||||
)
|
||||
|
||||
# Rename columns to match frontend expectations ('lon' and 'lat')
|
||||
merged_df.rename(columns={'x': 'lon', 'y': 'lat'}, inplace=True)
|
||||
|
||||
# Also rename the original PLZ column to the consistent name 'plz'
|
||||
merged_df.rename(columns={plz_column_name: 'plz'}, inplace=True)
|
||||
|
||||
# Convert to the required JSON format, including all remaining columns (which are the attributes)
|
||||
# We'll dynamically collect attribute columns for output
|
||||
output_columns = ['plz', 'lat', 'lon', 'count']
|
||||
for col in merged_df.columns:
|
||||
if col not in output_columns and col != plz_column_name: # Ensure we don't duplicate PLZ or coords
|
||||
output_columns.append(col)
|
||||
|
||||
heatmap_data = merged_df[output_columns].to_dict(orient='records')
|
||||
|
||||
# The frontend expects 'attributes_summary' as a single field, so let's restructure for that
|
||||
# For each record, pick out the attributes that are not 'plz', 'lat', 'lon', 'count'
|
||||
final_heatmap_data = []
|
||||
for record in heatmap_data:
|
||||
# Order the attributes based on tooltip_config
|
||||
ordered_attrs = {
|
||||
col_name: record.get(col_name)
|
||||
for col_name in ordered_columns
|
||||
if col_name in record and record.get(col_name) is not None
|
||||
}
|
||||
final_heatmap_data.append({
|
||||
"plz": record['plz'],
|
||||
"lat": record['lat'],
|
||||
"lon": record['lon'],
|
||||
"count": record['count'],
|
||||
"attributes_summary": ordered_attrs
|
||||
})
|
||||
|
||||
print(f"Generated heatmap data with {len(final_heatmap_data)} PLZ points, respecting tooltip config.")
|
||||
return final_heatmap_data
|
||||
|
||||
except Exception as e:
|
||||
print(f"ERROR generating heatmap: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"An error occurred while generating heatmap data: {e}")
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +0,0 @@
|
||||
fastapi
|
||||
uvicorn[standard]
|
||||
pandas
|
||||
openpyxl
|
||||
python-multipart
|
||||
@@ -1,26 +0,0 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
backend:
|
||||
build: ./backend
|
||||
ports:
|
||||
- "8002:8000"
|
||||
volumes:
|
||||
- ./backend:/app
|
||||
networks:
|
||||
- heatmap-net
|
||||
|
||||
frontend:
|
||||
build: ./frontend
|
||||
ports:
|
||||
- "5173:5173"
|
||||
volumes:
|
||||
- ./frontend:/app
|
||||
networks:
|
||||
- heatmap-net
|
||||
depends_on:
|
||||
- backend
|
||||
|
||||
networks:
|
||||
heatmap-net:
|
||||
driver: bridge
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user