Compare commits
191 Commits
main
...
35c30bc39a
| Author | SHA1 | Date | |
|---|---|---|---|
| 35c30bc39a | |||
| 4224206b24 | |||
| a39efeb5b1 | |||
|
|
fcedd25372 | ||
|
|
b25d09a65b | ||
|
|
18f74f8903 | ||
| 62aed99503 | |||
|
|
bd072c133a | ||
|
|
ceb7b4ba32 | ||
|
|
c6d0f9b324 | ||
|
|
84283c706d | ||
|
|
42a9e571c8 | ||
|
|
62768b962c | ||
|
|
067e672bff | ||
|
|
c1b9308c05 | ||
|
|
cde3de4a88 | ||
|
|
1374ade35b | ||
|
|
50dbe10249 | ||
|
|
317473b326 | ||
|
|
75deb72525 | ||
|
|
fe8e1aad14 | ||
|
|
5ec9c05151 | ||
|
|
ec9b56d45b | ||
|
|
5081e092a2 | ||
|
|
bdbfaa470e | ||
|
|
f3cf366a6a | ||
| ce5e845a2e | |||
| d38d099393 | |||
| e0de9b3270 | |||
| 2265073c4c | |||
|
|
ca32715185 | ||
|
|
073d3460fe | ||
| 337b804967 | |||
| e43fc93703 | |||
| 59d11fdf26 | |||
| f0b7b1ef34 | |||
| 7a53904952 | |||
| f861ca030c | |||
| 8289cca372 | |||
| a2f5df33c0 | |||
| 5d90832769 | |||
|
|
3d45e03739 | ||
|
|
5040b0b52e | ||
|
|
5a19a9c85f | ||
|
|
5b1939c881 | ||
| 15db0cfe7f | |||
| 07ca427fc8 | |||
| 96875247dd | |||
| 88f9b8efb5 | |||
| 581ec01775 | |||
| d484ab08a0 | |||
| 05c377b40e | |||
| 68e0d31ed4 | |||
| 92fcfd747c | |||
| f2ddd7ac09 | |||
| 2f17a3bec1 | |||
| 312ce8aa1d | |||
| 23559cff08 | |||
| c420bb31c3 | |||
| 47cd047bc8 | |||
| a479058df2 | |||
| 16fc826b5e | |||
|
|
2940843457 | ||
| bc018669cf | |||
| b7afd5c221 | |||
|
|
fe335cd5f3 | ||
|
|
15670a4be3 | ||
| 0bc8a301b9 | |||
| 6ced7f13e6 | |||
| a04d92253a | |||
| ca726ee7a4 | |||
|
|
0241bc1265 | ||
|
|
ff3bbc6791 | ||
|
|
622689e2f5 | ||
|
|
cdb8bacb32 | ||
|
|
b71733d8c0 | ||
|
|
1f162138e4 | ||
| d48311ca3e | |||
| 2172b9a1c2 | |||
| b332964ba5 | |||
| 91b084d66d | |||
| 898ae98d5a | |||
| 2d2e05aa2a | |||
| 6bd21e7323 | |||
| d46b30a205 | |||
| 6c755f186c | |||
| e187a4ea52 | |||
| 97a2bac4be | |||
| 25307f3ed4 | |||
| 6d65b782b8 | |||
| 9e947b6dfb | |||
| 1c5db811a8 | |||
| 959946319a | |||
| aab0ba9461 | |||
| c2c1e12236 | |||
| 10206b2c3f | |||
| 56728f3f00 | |||
| 9cd0e806db | |||
| 052a1d4a98 | |||
| 41cca0c98b | |||
| b5c0d10f17 | |||
| de527b0cc7 | |||
| 931b589466 | |||
| 61f7f0586c | |||
| 3aacac97f0 | |||
| e006fcfa17 | |||
| d9eef04732 | |||
| e181cc3a61 | |||
| 34f156e7e7 | |||
| 96505b68fc | |||
| 29c5ba8a87 | |||
| d63bc64d8a | |||
| d3e561faf0 | |||
| 24dd9c3be2 | |||
| 504e20477f | |||
| b4c5995131 | |||
| 101dd194b7 | |||
| 75724ec142 | |||
| f5ba056b92 | |||
| 5fa8acaea4 | |||
| 6f6493da4a | |||
| 9300923665 | |||
| 674de3d4ff | |||
|
|
11421f5871 | ||
|
|
0dc2e12150 | ||
|
|
ca0ef5d741 | ||
|
|
dd0d2353d2 | ||
|
|
e3d2e0b945 | ||
|
|
086a4a0842 | ||
| fd3e3a3414 | |||
| 6ffa34381f | |||
|
|
9d7f8b37e8 | ||
|
|
2907121f78 | ||
|
|
a3bbb98a50 | ||
|
|
46f428fea9 | ||
|
|
caf59e603e | ||
|
|
ebe13f13aa | ||
| 0ba21c1098 | |||
| a83db7ddee | |||
|
|
e35152c486 | ||
|
|
7b70c80c71 | ||
|
|
4e8ca12c05 | ||
|
|
01eb090268 | ||
|
|
01036eccf8 | ||
|
|
05190da910 | ||
|
|
61a11e2de8 | ||
| 7e852ac7f7 | |||
|
|
e4b01218b3 | ||
|
|
a61865cdd8 | ||
|
|
027e5fb3a7 | ||
| 30b3042e85 | |||
| 3c3632b9fa | |||
| b9d4e32f21 | |||
| 4f462147af | |||
| 875c6ffe94 | |||
| 6f95bce179 | |||
| 4f78012df7 | |||
| 314cfb2805 | |||
| 4b9f302bff | |||
| d88e82eaca | |||
| 7166c5aeea | |||
| 415ca2594a | |||
| be86775c78 | |||
| a4f9f522bf | |||
| 81d6da1622 | |||
| 226882affd | |||
| acf64fcfe8 | |||
| 6b4901f981 | |||
| fea8790474 | |||
| 856a583dbc | |||
| 661d6c08e2 | |||
| b03a33eede | |||
| 4ef000d051 | |||
| c9c462e035 | |||
| 89ee96a5ca | |||
| 7172051803 | |||
| 6ae0d5a963 | |||
| 87ae3623f3 | |||
| 3c06312ad9 | |||
| 8e8fe5fb99 | |||
| 45b5ec2d3f | |||
| 758118f7f0 | |||
| 7d384fafdd | |||
| 62fec8f078 | |||
| 4d26cca645 | |||
| c410c9b3c0 | |||
| a1a0d46b63 | |||
| 6e38c85de8 | |||
| 5d6bcbd0dd | |||
| fbf59bfb71 | |||
| 9019a801ed |
1
.dev_session/SESSION_INFO
Normal file
1
.dev_session/SESSION_INFO
Normal file
@@ -0,0 +1 @@
|
||||
{"task_id": "2ff88f42-8544-8000-8314-c9013414d1d0", "token": "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", "session_start_time": "2026-02-19T08:32:53.260193"}
|
||||
33
.env.example
Normal file
33
.env.example
Normal file
@@ -0,0 +1,33 @@
|
||||
# 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
.gemini/env_old
Normal file
1
.gemini/env_old
Normal file
@@ -0,0 +1 @@
|
||||
GEMINI_API_KEY=AIzaSyBNg5yQ-dezfDs6j9DGn8qJ8SImNCGm9Ds
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -68,3 +68,6 @@ Log_from_docker/
|
||||
# Node.js specific
|
||||
!package.json
|
||||
!package-lock.json
|
||||
|
||||
.gemini/.env
|
||||
gemini_api_key.txt
|
||||
|
||||
@@ -1 +1 @@
|
||||
admin:$1$RzTlC0sX$L2VQ31MyQ0Wefz1vNG7Yf1
|
||||
admin:$6$un97dUtWx4rc/Qcr$Xo7oatwiWn8F7lPiSCHI56K3OK7k0rHztVp2kfl78Kk6juw5KTwWlwU07PGgDGY5mQiGZzDy4O0UhIVvR5HsC.
|
||||
277
ARCHITEKTUR_GCP_SETUP.md
Normal file
277
ARCHITEKTUR_GCP_SETUP.md
Normal file
@@ -0,0 +1,277 @@
|
||||
# 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 |
|
||||
467
Direktive_Zusammenarbeit.md
Normal file
467
Direktive_Zusammenarbeit.md
Normal file
@@ -0,0 +1,467 @@
|
||||
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?
|
||||
36
Dockerfile.moltbot
Normal file
36
Dockerfile.moltbot
Normal file
@@ -0,0 +1,36 @@
|
||||
# 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,6 +20,24 @@ 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).
|
||||
|
||||
81
KONVER_STRATEGY.md
Normal file
81
KONVER_STRATEGY.md
Normal file
@@ -0,0 +1,81 @@
|
||||
# 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.7.4)
|
||||
# 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.
|
||||
@@ -29,12 +29,25 @@ 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.)
|
||||
|
||||
@@ -95,142 +108,54 @@ 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.
|
||||
|
||||
## 8. Eingesetzte Prompts (Account-Analyse v0.7.4)
|
||||
## 14. Upgrade v2.0 (Feb 18, 2026): "Lead-Fabrik" Erweiterung
|
||||
|
||||
### 8.1 Strict Industry Classification
|
||||
Dieses Upgrade transformiert den Company Explorer in das zentrale Gehirn der Lead-Generierung (Vorratskammer).
|
||||
|
||||
Ordnet das Unternehmen einer definierten Branche zu.
|
||||
### 14.1 Detaillierte Logik der neuen Datenfelder
|
||||
|
||||
```python
|
||||
prompt = f"""
|
||||
Act as a strict B2B Industry Classifier.
|
||||
Company: {company_name}
|
||||
Context: {website_text[:3000]}
|
||||
Um Gemini CLI (dem Bautrupp) die Umsetzung zu ermöglichen, hier die semantische Bedeutung der neuen Spalten:
|
||||
|
||||
Available Industries:
|
||||
{json.dumps(industry_definitions, indent=2)}
|
||||
#### Tabelle `companies` (Qualitäts- & Abgleich-Metriken)
|
||||
|
||||
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'.
|
||||
* **`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`.
|
||||
|
||||
Return ONLY the exact name of the industry.
|
||||
"""
|
||||
```
|
||||
#### Tabelle `industries` (Strategie-Parameter)
|
||||
|
||||
### 8.2 Metric Extraction
|
||||
* **`pains` / `gains`:** Strukturierte Textblöcke (getrennt durch `[Primary Product]` und `[Secondary Product]`).
|
||||
* **`ops_focus_secondary` (BOOLEAN):** Steuerung für rollenspezifische Produkt-Priorisierung.
|
||||
|
||||
Extrahiert den spezifischen Zahlenwert ("Scraper Search Term") und liefert JSON für den `MetricParser`.
|
||||
---
|
||||
|
||||
```python
|
||||
prompt = f"""
|
||||
Extract the following metric for the company in industry '{industry_name}':
|
||||
Target Metric: "{search_term}"
|
||||
## 15. Offene Arbeitspakete (Bauleitung)
|
||||
|
||||
Source Text:
|
||||
{text_content[:6000]}
|
||||
Anweisungen für den "Bautrupp" (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 1: UI-Anpassung - Side-by-Side CRM View & Settings
|
||||
(In Arbeit / Teilweise erledigt durch Gemini CLI)
|
||||
|
||||
JSON ONLY.
|
||||
"""
|
||||
```
|
||||
### Task 2: Intelligenter CRM-Importer (Bestandsdaten)
|
||||
|
||||
## 9. Notion Integration (Single Source of Truth)
|
||||
**Ziel:** Importieren der `demo_100.xlsx` in die SQLite-Datenbank.
|
||||
|
||||
Das System nutzt Notion als zentrales Steuerungselement für strategische Definitionen.
|
||||
**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.
|
||||
|
||||
### 9.1 Datenfluss
|
||||
1. **Definition:** Branchen und Robotik-Kategorien werden in Notion gepflegt (Whale Thresholds, Keywords, Definitionen).
|
||||
2. **Synchronisation:** Das Skript `sync_notion_industries.py` zieht die Daten via API und führt einen Upsert in die lokale SQLite-Datenbank aus.
|
||||
3. **App-Nutzung:** Das Web-Interface zeigt diese Daten schreibgeschützt an. Der `ClassificationService` nutzt sie als "System-Anweisung" für das LLM.
|
||||
---
|
||||
|
||||
### 9.2 Technische Details
|
||||
* **Notion Token:** Muss in `/app/notion_token.txt` (Container-Pfad) hinterlegt sein.
|
||||
* **DB-Mapping:** Die Zuordnung erfolgt primär über die `notion_id`, sekundär über den Namen, um Dubletten bei der Migration zu vermeiden.
|
||||
|
||||
## 10. Database Migration
|
||||
|
||||
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.
|
||||
## 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`
|
||||
|
||||
210
MOLTBOT_SYNOLOGY_GUIDE.md
Normal file
210
MOLTBOT_SYNOLOGY_GUIDE.md
Normal file
@@ -0,0 +1,210 @@
|
||||
# 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? 🦞
|
||||
14
NOTION_TASK_SUMMARY.md
Normal file
14
NOTION_TASK_SUMMARY.md
Normal file
@@ -0,0 +1,14 @@
|
||||
**Task Summary: Add a Share Button `[2f488f42]`**
|
||||
|
||||
**Status:** ✅ Done
|
||||
|
||||
**Project:** Meeting Assistant (Transcription Tool)
|
||||
|
||||
**Changes Implemented:**
|
||||
- A new "Share" button has been successfully added to the toolbar in the transcript detail view.
|
||||
- The button is visually aligned with the existing "Copy" and "Download" actions, utilizing the `Share2` icon for consistency.
|
||||
- The feature is UI-only as per the requirements; clicking the button currently triggers a placeholder alert. No backend or sharing logic has been implemented yet.
|
||||
|
||||
**Next Steps:**
|
||||
- The functionality for sharing can be implemented in a future task.
|
||||
- The change is ready to be committed and pushed.
|
||||
@@ -2,40 +2,41 @@
|
||||
|
||||
## Übersicht
|
||||
|
||||
`dev_session.py` ist ein interaktives Kommandozeilen-Tool (CLI), das den Entwicklungs-Workflow durch die Integration mit Notion optimiert. Es ermöglicht Entwicklern, ein Projekt und einen Task auszuwählen und den Task-Status automatisch zu aktualisieren.
|
||||
`dev_session.py` ist ein Kommandozeilen-Tool (CLI), das den Entwicklungs-Workflow durch die Integration mit Notion und Git optimiert. Es dient als Brücke zwischen der interaktiven Gemini CLI-Sitzung und dem Projektmanagement in Notion.
|
||||
|
||||
**Wichtige Architekturänderung:** Das Skript `dev_session.py` startet die Gemini CLI nicht mehr direkt. Stattdessen generiert es einen formatierten Kontext und gibt diesen auf der Standardausgabe aus. Das übergeordnete `start-gemini.sh`-Skript fängt diesen Kontext auf und startet dann die Gemini CLI in einer sauberen, interaktiven Sitzung mit dem korrekten Startkontext. Dieser zweistufige Prozess stellt sicher, dass die Gemini CLI mit allen erforderlichen Tools und in der korrekten Umgebung läuft.
|
||||
**Wichtige Architekturänderung:** Das Skript `dev_session.py` startet die Gemini CLI nicht mehr direkt. Stattdessen durchläuft der Entwickler einen interaktiven Setup-Prozess, an dessen Ende ein Kontext für die Gemini CLI generiert wird. Das übergeordnete `start-gemini.sh`-Skript fängt diesen Kontext auf und startet die Gemini CLI in einer sauberen, isolierten Docker-Sitzung. Dieser zweistufige Prozess gewährleistet eine stabile und kontextbezogene Entwicklungsumgebung.
|
||||
|
||||
## Funktionen
|
||||
|
||||
* **Interaktive Projekt- und Task-Auswahl:** Wähle Projekte und Tasks direkt aus deinen Notion-Datenbanken.
|
||||
* **Automatisches Status-Update:** Der Status des ausgewählten Tasks wird automatisch auf 'Doing' (oder den ersten verfügbaren Status in neuen Projekten) gesetzt, um deinen Fortschritt in Notion widerzuspiegeln.
|
||||
* **Automatischer Start-Status:** Setzt den Status des ausgewählten Tasks beim Start der Sitzung automatisch auf 'Doing'.
|
||||
* **Task-Erstellung:** Erstelle neue Tasks direkt über das CLI im Kontext des ausgewählten Projekts.
|
||||
* **Dynamische Status-Erkennung:** Fragt Notion API nach verfügbaren Status-Optionen ab, um Kompatibilität mit verschiedenen Notion-Templates zu gewährleisten.
|
||||
* **Gemini CLI Kontext-Generierung:** Erzeugt einen strukturierten Kontext-Prompt, der alle relevanten Informationen (Projekt, Task, Task-ID, vorgeschlagener Git-Branch-Name) für die Gemini CLI enthält.
|
||||
* **Vorgeschlagene Git Branch-Benennung:** Erstellt einen konsistenten Git-Branch-Namen basierend auf der Task-ID und dem Task-Titel.
|
||||
* **Agent-gesteuerte Statusberichte:** Ermöglicht dem Gemini-Agenten, nach Abschluss eines Arbeitspakets einen detaillierten Statusbericht an Notion zu senden, Code zu committen und zu pushen.
|
||||
* **Automatisches Commit-Feedback:** Ein `post-commit` Git-Hook sendet jede Commit-Nachricht als kurzen Kommentar an den Notion-Task.
|
||||
* **Kontext-Generierung für Gemini CLI:** Erzeugt einen strukturierten Start-Prompt mit allen relevanten Informationen (Projekt, Task, Beschreibung, Git-Branch).
|
||||
* **Vorgeschlagene Git Branch-Benennung:** Erstellt einen konsistenten Git-Branch-Namen basierend auf der Task-ID und dem Titel.
|
||||
|
||||
## Voraussetzungen
|
||||
|
||||
* **Docker:** Das gesamte Setup ist containerisiert, um Abhängigkeitskonflikte zu vermeiden.
|
||||
* **Docker:** Das gesamte Setup ist containerisiert.
|
||||
* **Notion Integration Token:** Ein Notion API-Token mit Zugriff auf Ihre "Projects"- und "Tasks"-Datenbanken.
|
||||
|
||||
## Installation und Einrichtung
|
||||
|
||||
Das Setup ist so konzipiert, dass es mit minimalem Aufwand sofort einsatzbereit ist.
|
||||
|
||||
1. **Notion API Key:**
|
||||
* Stellen Sie sicher, dass Sie einen Notion Integration Token haben.
|
||||
* Das Skript fragt beim ersten Start interaktiv nach dem Token. Sie können es auch als Umgebungsvariable `NOTION_API_KEY` in einer `.env`-Datei im Hauptverzeichnis speichern, um diesen Schritt zu überspringen.
|
||||
* Gewähren Sie Ihrer Notion-Integration Zugriff auf die relevanten "Projects"- und "Tasks"-Datenbanken.
|
||||
* Das Skript fragt beim ersten Start interaktiv nach dem Token. Alternativ kann er als Umgebungsvariable `NOTION_API_KEY` in einer `.env`-Datei im Hauptverzeichnis gespeichert werden.
|
||||
* Gewähren Sie Ihrer Notion-Integration Zugriff auf die relevanten Datenbanken.
|
||||
|
||||
2. **Abhängigkeiten:** Alle notwendigen Abhängigkeiten (Python, `requests`, `python-dotenv`) sind im Dockerfile (`gemini.Dockerfile`) definiert und werden automatisch im Container installiert. Es ist keine manuelle Installation via `pip` erforderlich.
|
||||
2. **Abhängigkeiten:** Alle Abhängigkeiten sind im `gemini.Dockerfile` definiert und werden automatisch im Container installiert.
|
||||
|
||||
## Nutzung
|
||||
## Der Entwicklungs-Workflow
|
||||
|
||||
Der Workflow ist in drei Hauptphasen unterteilt: Sitzung starten, in der Sitzung arbeiten und Ergebnisse zurückmelden.
|
||||
|
||||
### 1. Sitzung starten
|
||||
|
||||
Das `start-gemini.sh`-Skript orchestriert den gesamten Startvorgang.
|
||||
Der gesamte Startvorgang wird über das `start-gemini.sh`-Skript orchestriert.
|
||||
|
||||
```bash
|
||||
./start-gemini.sh
|
||||
@@ -44,64 +45,72 @@ Das `start-gemini.sh`-Skript orchestriert den gesamten Startvorgang.
|
||||
**Was im Hintergrund passiert:**
|
||||
|
||||
1. Das Skript führt `dev_session.py` in einem temporären Docker-Container aus.
|
||||
2. Sie durchlaufen den interaktiven Auswahlprozess für Projekt und Task wie gewohnt.
|
||||
3. Nach Ihrer Auswahl gibt `dev_session.py` den generierten Kontext für die Gemini CLI aus und beendet sich.
|
||||
4. `start-gemini.sh` fängt diese Ausgabe ab und extrahiert den Kontext.
|
||||
5. Anschließend startet das Skript einen neuen, sauberen Docker-Container mit der Gemini CLI und übergibt den extrahierten Kontext als Start-Prompt.
|
||||
2. Sie durchlaufen den interaktiven Auswahlprozess für Ihr Projekt und Ihren Task in Notion.
|
||||
3. Nach der Auswahl generiert `dev_session.py` den Kontext, speichert die Session-Informationen (z.B. Task-ID) in `.dev_session/SESSION_INFO`, installiert die Git-Hooks und gibt den Kontext an die Standardausgabe aus.
|
||||
4. `start-gemini.sh` fängt diese Ausgabe ab, extrahiert den Kontext und startet einen neuen, sauberen Docker-Container (`gemini-session`) mit der Gemini CLI, wobei der Kontext als Start-Prompt übergeben wird.
|
||||
|
||||
Dieser Prozess stellt sicher, dass Ihre interaktive Gemini-Sitzung von der Notion-API-Logik entkoppelt ist und in einer stabilen Umgebung läuft.
|
||||
Dieser Prozess stellt sicher, dass Ihre interaktive Gemini-Sitzung von der Notion-Logik entkoppelt ist und mit allen relevanten Informationen beginnt.
|
||||
|
||||
### 2. Sitzung abschließen
|
||||
### 2. Arbeiten in der Gemini CLI
|
||||
|
||||
Wenn Sie die Arbeit an einem Task beendet haben, können Sie die Sitzung über den folgenden Befehl abschließen. Dieser Befehl muss **im Terminal des Hosts** ausgeführt werden, während der Container (`gemini-session`) noch läuft.
|
||||
Nach dem Start befinden Sie sich in der Gemini CLI. Hier findet die eigentliche Entwicklungsarbeit statt.
|
||||
|
||||
* **Commits:** Wenn Sie oder der Agent `git commit` ausführen, greift der `post-commit`-Hook. Er liest die Task-ID aus der Session-Datei und sendet die Commit-Nachricht automatisch als kurzen Kommentar an den verknüpften Notion-Task. Dies sorgt für eine granulare Nachverfolgung des Fortschritts.
|
||||
|
||||
### 3. Fortschritt melden und Änderungen pushen (Agent-gesteuerter Workflow)
|
||||
|
||||
Dies ist der primäre Weg, um ein logisches Arbeitspaket abzuschließen und den Fortschritt zu dokumentieren. Anstatt Git-Befehle und Notion-Updates manuell zu koordinieren, geben Sie dem Agenten eine übergeordnete Anweisung.
|
||||
|
||||
**Beispiel-Anweisung an den Gemini-Agenten:**
|
||||
|
||||
> "Okay, die Implementierung ist abgeschlossen. Fasse die Arbeit zusammen, erstelle einen Statusbericht für Notion, committe alle Änderungen und pushe sie."
|
||||
|
||||
**Was der Agent im Hintergrund tut:**
|
||||
|
||||
1. **Informationen sammeln:** Der Agent führt einen kurzen Dialog mit Ihnen, um den neuen Status (`Bereit für Review`, `Blockiert` etc.) und eventuelle offene To-Dos zu erfragen.
|
||||
2. **Git-Änderungen analysieren:** Der Agent generiert eine Zusammenfassung der geänderten Dateien und der neuen Commit-Nachrichten.
|
||||
3. **Notion-Update (nicht-interaktiv):** Der Agent ruft `dev_session.py` mit den gesammelten Informationen als Parameter auf.
|
||||
```bash
|
||||
python3 dev_session.py --report-status \
|
||||
--status "Ready for Review" \
|
||||
--todos "- Finale Doku prüfen" \
|
||||
--git-changes "..." \
|
||||
--commit-messages "..."
|
||||
```
|
||||
4. **Bericht erstellen:** Das Skript formatiert diese Informationen zu einem sauberen Status-Update, postet es als Kommentar an den Notion-Task und aktualisiert gleichzeitig dessen Status-Feld.
|
||||
5. **Git-Operationen:** Nachdem Notion aktualisiert wurde, führt der Agent die `git add`, `git commit` und `git push` Befehle aus.
|
||||
|
||||
Dieser Prozess stellt sicher, dass der Code-Stand im Repository immer synchron mit dem dokumentierten Fortschritt in Notion ist, ohne dass Sie die Gemini CLI verlassen müssen.
|
||||
|
||||
### 4. Sitzung abschließen
|
||||
|
||||
Wenn der gesamte Task abgeschlossen ist, können Sie die Sitzung über den folgenden Befehl **vom Host-Terminal aus** beenden.
|
||||
|
||||
```bash
|
||||
docker exec -it gemini-session python3 dev_session.py --done
|
||||
```
|
||||
|
||||
Dieser Befehl:
|
||||
* Setzt den Status des Notion-Tasks auf "Done" (oder den finalen Status in Ihrer Konfiguration).
|
||||
* Löscht die lokale Session-Datei (`.session_info.json`).
|
||||
* Deinstalliert den Git-Hook.
|
||||
|
||||
### 3. Automatisches Notion-Feedback (Git-Hook)
|
||||
|
||||
Beim Starten einer Sitzung wird automatisch ein `post-commit`-Git-Hook installiert.
|
||||
|
||||
* **Funktion:** Nach jedem `git commit` wird die Commit-Nachricht automatisch als Kommentar an den verknüpften Notion-Task gesendet.
|
||||
* **Voraussetzung:** Funktioniert nur für Commits, die im `gemini-session`-Container oder auf dem Host-System (mit installiertem `requests`) gemacht werden, während die Session aktiv ist.
|
||||
* **Deinstallation:** Der Hook wird beim Abschluss der Sitzung mit `--done` automatisch wieder entfernt.
|
||||
|
||||
### Beispiel-Interaktion (gekürzt)
|
||||
|
||||
Die interaktive Auswahl von Projekt und Task bleibt unverändert:
|
||||
|
||||
```
|
||||
Starte interaktive Entwicklungs-Session...
|
||||
Bitte gib deinen Notion API Key ein (Eingabe wird nicht angezeigt): ****************
|
||||
Suche nach Datenbank 'Projects [UT]' in Notion...
|
||||
Datenbank 'Projects [UT]' gefunden mit ID: <PROJEKTE_DB_ID>
|
||||
|
||||
An welchem Projekt möchtest du arbeiten?
|
||||
[1] My Awesome Project
|
||||
[2] Sync Engine
|
||||
[...]
|
||||
Bitte wähle eine Nummer: 2
|
||||
...
|
||||
```
|
||||
|
||||
Nach der Auswahl wird automatisch die Gemini CLI gestartet.
|
||||
* Setzt den Status des Notion-Tasks auf "Done".
|
||||
* Löscht die lokale Session-Datei (`.dev_session/SESSION_INFO`).
|
||||
* Deinstalliert den `post-commit` Git-Hook.
|
||||
|
||||
## Git Branch Benennungs-Konvention
|
||||
|
||||
Das Skript schlägt automatisch einen Git-Branch-Namen vor, der dem Muster `feature/task-{kurze_task_id}-{task_titel_slug}` folgt.
|
||||
* `feature/task-`: Ein Präfix, das den Branch-Typ und die Beziehung zu einem Notion-Task anzeigt.
|
||||
* `{kurze_task_id}`: Die ersten 8 Zeichen der Notion Task ID, für eine eindeutige Referenz.
|
||||
* `{task_titel_slug}`: Eine "Slugified"-Version des Task-Titels (Kleinbuchstaben, Leerzeichen durch Bindestriche ersetzt, Sonderzeichen entfernt).
|
||||
Das Skript schlägt automatisch einen Git-Branch-Namen vor, der dem Muster `feature/task-{kurze_task_id}-{task_titel_slug}` folgt, um eine direkte Verbindung zwischen Code und Task herzustellen.
|
||||
|
||||
**Beispiel:** `feature/task-2f388f42-create-readmemd-for-devsessionpy`
|
||||
**Beispiel:** `feature/task-2f388f42-update-readme-for-reporting`
|
||||
|
||||
## Wichtige Hinweise
|
||||
## Entwicklung & Troubleshooting des Start-Skripts
|
||||
|
||||
Das `start-gemini.sh`-Skript wurde entwickelt, um mehrere Herausforderungen zu lösen:
|
||||
|
||||
* **Problem: Fehlende Interaktivität:** Frühe Versionen leiteten die Ausgabe von `dev_session.py` direkt in eine Variable um, was die interaktiven `input()`-Aufforderungen blockierte.
|
||||
* **Lösung:** Einsatz des `tee`-Befehls, der die Ausgabe gleichzeitig auf dem Bildschirm anzeigt und in eine temporäre Datei schreibt, aus der der Kontext später gelesen wird.
|
||||
|
||||
* **Problem: Fehlerhafter Start-Parameter:** Die Gemini CLI erwartet den initialen Prompt über `--prompt-interactive`.
|
||||
* **Lösung:** Korrektur des Arguments im `docker run`-Befehl.
|
||||
|
||||
* **Problem: Konflikt durch nicht aufgeräumte Container:** Vorzeitig beendete Skripte hinterließen Container, die den nächsten Start blockierten.
|
||||
* **Lösung:** Explizite `docker rm -f`-Befehle am Anfang des Skripts, um alte Container zu bereinigen.
|
||||
|
||||
* Stelle sicher, dass deine Notion-Datenbanken die Property `Status` mit mindestens einer Statusoption haben.
|
||||
* Der `Readme Path` wird dynamisch aus dem in Notion ausgewählten Projekt geladen. Falls in Notion kein spezifischer Pfad hinterlegt ist, wird standardmäßig `readme.md` verwendet. Dies eliminiert die Notwendigkeit einer manuellen `project_to_path_map` im Skript.
|
||||
|
||||
102
RELOCATION.md
Normal file
102
RELOCATION.md
Normal file
@@ -0,0 +1,102 @@
|
||||
### **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.
|
||||
18
SKILL_TASK_MANAGER.md
Normal file
18
SKILL_TASK_MANAGER.md
Normal file
@@ -0,0 +1,18 @@
|
||||
# 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`.
|
||||
180
SUPEROFFICE_INTEGRATION_PLAN.md
Normal file
180
SUPEROFFICE_INTEGRATION_PLAN.md
Normal file
@@ -0,0 +1,180 @@
|
||||
# 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).
|
||||
27
TASK_STATUS_REPORT_2f388f42.md
Normal file
27
TASK_STATUS_REPORT_2f388f42.md
Normal file
@@ -0,0 +1,27 @@
|
||||
# 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.
|
||||
|
||||
---
|
||||
@@ -19,7 +19,8 @@ Der **Meeting Assistant** ist eine leistungsstarke Suite zur Transkription und B
|
||||
|
||||
## 2. Key Features (v0.6.0)
|
||||
|
||||
### 🚀 **NEU:** AI Insights auf Knopfdruck
|
||||
### 🚀 **NEU:** AI Insights & Translation
|
||||
* **Übersetzung (DE/EN):** Übersetzt das gesamte Transkript mit einem Klick ins Englische.
|
||||
* **Meeting-Protokoll:** Erstellt automatisch ein formelles Protokoll (Meeting Minutes) mit Agenda, Entscheidungen und nächsten Schritten.
|
||||
* **Action Items:** Extrahiert eine Aufgabenliste mit Verantwortlichen und Fälligkeiten direkt aus dem Gespräch.
|
||||
* **Rollenbasierte Zusammenfassungen:** Generiert spezifische Zusammenfassungen, z.B. eine "Sales Summary", die sich auf Kundenbedürfnisse, Kaufsignale und nächste Schritte für das Vertriebsteam konzentriert.
|
||||
@@ -51,6 +52,7 @@ Der **Meeting Assistant** ist eine leistungsstarke Suite zur Transkription und B
|
||||
| `GET` | `/meetings` | Liste aller Meetings. |
|
||||
| `POST` | `/upload` | Audio-Upload & Prozess-Start. |
|
||||
| `POST` | `/meetings/{id}/insights` | **Neu:** Generiert eine Analyse (z.B. Protokoll, Action Items). |
|
||||
| `POST` | `/meetings/{id}/translate` | **Neu:** Übersetzt das Transkript in eine Zielsprache (aktuell: 'English'). |
|
||||
| `POST` | `/meetings/{id}/rename_speaker` | Globale Umbenennung in der DB. |
|
||||
| `PUT` | `/chunks/{id}` | Speichert manuelle Text-Korrekturen. |
|
||||
| `DELETE` | `/meetings/{id}` | Vollständiges Löschen. |
|
||||
@@ -62,3 +64,18 @@ Der **Meeting Assistant** ist eine leistungsstarke Suite zur Transkription und B
|
||||
* **v0.7: Search:** Globale Suche über alle Transkripte hinweg.
|
||||
* **v0.8: Q&A an das Meeting:** Ermöglicht, Fragen direkt an das Transkript zu stellen ("Was wurde zu Thema X beschlossen?").
|
||||
* **v0.9: Export-Formate:** Export der Ergebnisse in verschiedene Formate (z.B. PDF, DOCX).
|
||||
|
||||
---
|
||||
|
||||
## 5. Development Notes & Troubleshooting
|
||||
|
||||
Bei der Implementierung der AI-Insights-Funktion (v0.6.0) traten mehrere Probleme auf, deren Lösungen für die zukünftige Entwicklung wichtig sind:
|
||||
|
||||
* **Isolierung von Microservices:** Der Versuch, eine zentrale `helpers.py`-Datei aus dem `transcription-app`-Container zu importieren, schlug mit einem `ModuleNotFoundError` fehl.
|
||||
* **Lösung:** Kritische Funktionen (wie der Gemini-API-Client) wurden in eine lokale Bibliothek (`/lib/gemini_client.py`) innerhalb des Service-Backends dupliziert, um den Service eigenständig zu machen.
|
||||
* **API-Schlüssel in Docker:** Der neue, isolierte Service konnte den API-Schlüssel nicht aus einer Datei lesen.
|
||||
* **Lösung:** Der `GEMINI_API_KEY` wurde als Umgebungsvariable über die `docker-compose.yml`-Datei an den Container übergeben. Dies ist die Best Practice für die Bereitstellung von "Secrets" für containerisierte Anwendungen. **Wichtig:** Ein `docker-compose restart` reicht nicht aus, um die Änderung zu übernehmen; ein `docker-compose up -d --force-recreate <service_name>` ist erforderlich.
|
||||
* **Modell-Kompatibilität:** API-Aufrufe schlugen mit `404 NOT_FOUND` fehl, weil versucht wurde, ein nicht zum API-Schlüssel passendes Modell (`gemini-1.5-flash`) zu verwenden.
|
||||
* **Lösung:** Der Modellname wurde auf das im Projekt etablierte und funktionierende Modell `gemini-2.0-flash` korrigiert.
|
||||
* **Datenformatierung:** Die KI lieferte generische Antworten, weil das an sie übergebene Transkript leer war.
|
||||
* **Lösung:** Die Analyse des rohen JSON-Outputs aus der Datenbank (`debug_chunks`-Endpunkt) zeigte, dass die Formatierungslogik die `absolute_seconds` zur korrekten chronologischen Sortierung verwenden muss. Die `_format_transcript`-Funktion wurde entsprechend angepasst.
|
||||
|
||||
251
alt_roboplanet-gtm-strategy-2026-01-14.md
Normal file
251
alt_roboplanet-gtm-strategy-2026-01-14.md
Normal file
@@ -0,0 +1,251 @@
|
||||
# GTM Strategy
|
||||
|
||||
**Recherche-URL:** https://www.inmotionrobotic.com/de/puma
|
||||
|
||||
---
|
||||
|
||||
# GTM STRATEGY REPORT
|
||||
|
||||
## 1. Executive Summary
|
||||
Dieser Go-to-Market (GTM)-Strategiebericht konzentriert sich auf die Markteinführung des PUMA M20, eines kompakten, geländegängigen Quadruped-Roboters für Inspektions-, Logistik- und Sicherheitsanwendungen. Die Strategie zielt auf Chemie- und Petrochemieanlagen, Energieversorgungsunternehmen sowie Logistikzentren und große Lagerhäuser ab. Der PUMA M20 wird als Lösung zur Verbesserung der Sicherheit, Effizienz und Kosteneffektivität in diesen Branchen positioniert, wobei der Fokus auf der "Dynamic Hybrid Service"-Logik liegt: Der Roboter detektiert die Gefahr, Wackler beseitigt sie.
|
||||
|
||||
## 2. Product Analysis
|
||||
Der PUMA M20 zeichnet sich durch seine All-Terrain-Mobilität, Wetterfestigkeit und kompakte Bauweise aus. Er ist mit fortschrittlichen Sensoren und Rechenleistung ausgestattet, was ihn ideal für autonome Inspektionen und Sicherheitsüberwachung in anspruchsvollen Umgebungen macht.
|
||||
|
||||
**Key Features:**
|
||||
* All-Terrain-Mobilität: Bewältigt Treppen, Schotter, Schlamm und Stahlroste.
|
||||
* Wetterfestigkeit: IP66-Zertifizierung für Staub- und Wasserdichtigkeit.
|
||||
* Kompakte Bauweise: Passt durch 50 cm breite Gänge und ist rucksackgroß.
|
||||
* Autonome Navigation: SLAM-Navigation für autonome Missionen und Rückkehr zur Basis.
|
||||
* 360°-Umgebungserfassung: Duale LiDAR-Systeme und Weitwinkelkameras.
|
||||
* Nachtsichtfähigkeit: Optionale Nacht- und Wärmebildkameras.
|
||||
* Hohe Rechenleistung: Duale Octa-Core-Prozessoren mit 16 GB RAM und 128 GB Speicher.
|
||||
* Flexible Nutzlastoptionen: LiDAR, Wärmebild, PTZ, Gassensoren, Beacons.
|
||||
* Flottenmanagement und API-Integrationen: Für Datenexport und zentrale Steuerung.
|
||||
* Lange Betriebsdauer: Bis zu 3 Stunden, erweiterbar durch Hot-Swap-Batterien.
|
||||
* Hohe Traglast: 12 kg Nennlast, 50 kg maximale Tragfähigkeit.
|
||||
|
||||
**Constraints:**
|
||||
* Maximale Steigung: 45° (abhängig vom Untergrund)
|
||||
* Maximale Geschwindigkeit: 5 m/s
|
||||
* Schritt-Höhe: 22 cm (kontinuierlich)
|
||||
* Betriebstemperatur: -20°C bis 55°C
|
||||
* Schutzart: IP66
|
||||
* Abmessungen: Rucksackgröße, passt durch 50 cm breite Gänge
|
||||
* Gewicht: 33kg
|
||||
|
||||
## 3. Technical Specifications (Hard Facts)
|
||||
|
||||
| Spezifikation | Wert | Einheit |
|
||||
| :--------------------- | :------------------------------------ | :--------------- |
|
||||
| Akkulaufzeit | 180 | Minuten |
|
||||
| Ladezeit | N/A | Minuten |
|
||||
| Gewicht | 33 | kg |
|
||||
| Breite | 50 | cm |
|
||||
| Max. Steigung | 45 | Grad |
|
||||
| IP-Schutzart | IP66 | |
|
||||
| Kletterhöhe | 25 | cm |
|
||||
| Navigation | SLAM, LiDAR | |
|
||||
| Konnektivität | Gigabit Ethernet, USB 3.0 | |
|
||||
| Max. Zuladung | 50 | kg |
|
||||
| Kamera-Typen | Weitwinkel | |
|
||||
| Nachtsicht | Ja | |
|
||||
| Maximale Geschwindigkeit | 5 | m/s |
|
||||
| Kontinuierliche Geschwindigkeit | 3 | m/s |
|
||||
| Betriebstemperatur | -20 bis 55 | °C |
|
||||
| LiDAR Linien | 96 | Linien |
|
||||
| Externe Leistungsabgabe | 300 | W |
|
||||
| Schritt-Höhe (kontinuierlich) | 22 | cm |
|
||||
|
||||
## 4. Phase 2: ICP Discovery
|
||||
|
||||
**ICPs (Ideal Customer Profiles):**
|
||||
|
||||
* **Chemie- und Petrochemieanlagen:** Anlagen dieser Art erfordern regelmäßige Inspektionen auf Lecks, Korrosion und strukturelle Integrität. Der PUMA M20 kann diese Aufgaben autonom durchführen, auch in schwer zugänglichen oder gefährlichen Bereichen, und so die Sicherheit erhöhen und Ausfallzeiten reduzieren. Die Fähigkeit zur Gassensorik ist hier besonders wertvoll.
|
||||
* **Energieversorgungsunternehmen (z.B. Windparks, Solarparks, Umspannwerke):** Weitläufige Anlagen wie Wind- und Solarparks oder Umspannwerke profitieren von der autonomen Überwachungsfähigkeit des PUMA M20. Er kann Zäune patrouillieren, Einbruchsversuche erkennen, Schäden an Anlagen frühzeitig identifizieren (z.B. durch Wärmebildkameras) und so die Sicherheit und Effizienz erhöhen. Die All-Terrain-Mobilität ist hier entscheidend.
|
||||
* **Logistikzentren und große Lagerhäuser:** Der PUMA M20 kann in Logistikzentren und Lagerhäusern für die Überwachung von Sicherheitsbereichen, die Inspektion von Regalen und die Unterstützung bei Inventurprozessen eingesetzt werden. Seine Fähigkeit, Nutzlasten zu tragen, ermöglicht auch den Transport von kleinen Gütern oder Werkzeugen. Die kompakte Bauweise ermöglicht den Einsatz auch in engen Gängen.
|
||||
|
||||
**Data Proxies:**
|
||||
|
||||
* **Websites von Chemie- und Petrochemieunternehmen:** Suche nach Erwähnungen von 'Anlageninspektion', 'Sicherheitsüberwachung', 'Drohneninspektion', 'Robotik', 'Digitalisierung' und 'Predictive Maintenance' im Kontext von Quadruped Robotern.
|
||||
* **LinkedIn-Profile von Head of Security, Werkschutzleitern, Instandhaltungsleitern in Energieversorgungsunternehmen:** Verwendung von LinkedIn Sales Navigator, um Profile mit den genannten Titeln und Schlüsselwörtern wie 'Robotik', 'Sicherheit', 'Inspektion', 'Autonome Systeme', 'Perimeter Protection' und 'IoT' zu finden.
|
||||
* **Branchenpublikationen und Fachmessen für Logistik und Sicherheit:** Analyse von Artikeln, Whitepapers und Ausstellerlisten auf relevante Unternehmen, die an Robotik-Lösungen für Sicherheitsüberwachung, Inspektion und Materialtransport interessiert sein könnten. Suche nach Unternehmen, die bereits in Automatisierung investieren.
|
||||
|
||||
## 5. Target Accounts
|
||||
|
||||
* **Chemie- und Petrochemieanlagen:** BASF SE, Bayer AG, Evonik Industries AG, LANXESS AG, Covestro AG
|
||||
* **Energieversorgungsunternehmen (z.B. Windparks, Solarparks, Umspannwerke):** E.ON SE, RWE AG, EnBW Energie Baden-Württemberg AG, Vattenfall GmbH, innogy SE (Teil von E.ON)
|
||||
* **Logistikzentren und große Lagerhäuser:** Deutsche Post DHL Group, DB Schenker, Kühne + Nagel, Dachser SE, Amazon (Logistikzentren in DACH)
|
||||
|
||||
## 6. Strategy Matrix
|
||||
|
||||
| Segment | Pain Point | Angle | Differentiation |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| Chemie- und Petrochemieanlagen | Unzureichende Sicherheitsüberwachung großer, komplexer Anlagen; Gefahrstofferkennung; schwer zugängliche Bereiche. | Der Roboter sieht die Gefahr (Gassensoren, Wärmebild), Wackler beseitigt sie. (Automated Perimeter Protection). Autonome Inspektion von schwer zugänglichen Bereichen und frühzeitige Erkennung von Gefahrenstoffen durch den Roboter. Bei Bedarf Intervention durch Wackler Security. | All-Terrain-Mobilität, flexible Nutzlastoptionen (Gassensoren), 360°-Umgebungserfassung, NSL-Aufschaltung und Interventionsdienst durch Wackler Security. |
|
||||
| Energieversorgungsunternehmen (z.B. Windparks, Solarparks, Umspannwerke) | Hohe Inspektionskosten; schwer zugängliches Gelände; Notwendigkeit kontinuierlicher Überwachung gegen Vandalismus und Diebstahl. | Der Roboter sieht die Gefahr, Wackler beseitigt sie. (Automated Perimeter Protection). Autonome Patrouillen zur Überwachung von Anlagen, Erkennung von Schäden oder unbefugtem Zutritt. Alarmierung der Wackler Security bei Bedarf. | Wetterfestigkeit, lange Betriebsdauer, autonome Navigation, NSL-Aufschaltung und Interventionsdienst durch Wackler Security. |
|
||||
| Logistikzentren und große Lagerhäuser | Ineffiziente Überwachung großer Lagerflächen; Diebstahlprävention; Überprüfung von Sicherheitsvorschriften. | Der Roboter sieht die Gefahr, Wackler beseitigt sie. (Automated Perimeter Protection). Autonome Überwachung zur Diebstahlprävention und Einhaltung von Sicherheitsvorschriften. Bei Auffälligkeiten erfolgt die Intervention durch Wackler Security. | Kompakte Bauweise, autonome Navigation, Flottenmanagement, NSL-Aufschaltung und Interventionsdienst durch Wackler Security. |
|
||||
|
||||
## 7. Next Steps
|
||||
|
||||
1. **Leadgenerierung:** Nutzung der identifizierten Data Proxies, um qualifizierte Leads zu generieren.
|
||||
2. **Content-Erstellung:** Entwicklung von zielgerichteten Inhalten (Fallstudien, Whitepapers, Webinare), die die Vorteile des PUMA M20 und des "Dynamic Hybrid Service"-Ansatzes hervorheben.
|
||||
3. **Direktvertrieb:** Ansprache der Target Accounts durch personalisierte E-Mails und Telefonanrufe.
|
||||
4. **Pilotprojekte:** Durchführung von Pilotprojekten mit ausgewählten Kunden, um den Wert des PUMA M20 in der Praxis zu demonstrieren.
|
||||
5. **Partnerschaften:** Aufbau von Partnerschaften mit relevanten Technologieanbietern und Systemintegratoren.
|
||||
|
||||
## 8. Hybrid Service Logic
|
||||
|
||||
Der PUMA M20 ist ein Sicherheits- und Inspektionsroboter. Daher gilt folgende Logik:
|
||||
|
||||
* **Robot Role:** "Detektion & Präsenz". Der Roboter patrouilliert, nutzt Wärmebild/Sensoren, geht in Gefahrenbereiche. Er wird nicht müde und hat keine Angst.
|
||||
* **Human Role (Wackler Security):** "Bewertung & Intervention".
|
||||
* NSL (Notruf- & Serviceleitstelle): Bewertet den Video-Feed des Roboters bei Alarm.
|
||||
* Revierwachdienst: Fährt raus zur Intervention, wenn der Roboter einen Eindringling oder Schaden meldet.
|
||||
* **Pitch:** "Der Roboter sieht die Gefahr, Wackler beseitigt sie. (Automated Perimeter Protection)."
|
||||
|
||||
# SALES ENABLEMENT & VISUALS (PHASE 6)
|
||||
|
||||
## Kill-Critique Battlecards
|
||||
|
||||
### Persona: Head of Security / Werkschutzleiter
|
||||
> **Objection:** "Die Implementierung von Robotern ist zu teuer und der ROI ist unklar."
|
||||
|
||||
**Response:** Wir bieten nicht nur einen Roboter, sondern ein umfassendes Sicherheitssystem. Durch die Kombination aus autonomer Roboterpatrouille und der Interventionskraft von Wackler Security reduzieren Sie Ihre Sicherheitskosten langfristig. Der Roboter übernimmt monotone Überwachungsaufgaben, während unsere Experten sich auf die Bewertung und Intervention konzentrieren. Dies führt zu einer effizienteren Nutzung Ihrer Ressourcen und einer höheren Sicherheit. Wir können Ihnen eine detaillierte ROI-Analyse basierend auf Ihren spezifischen Anforderungen erstellen.
|
||||
|
||||
---
|
||||
|
||||
### Persona: Anlagenleiter / Betriebsleiter
|
||||
> **Objection:** "Ich befürchte, dass der Roboter den Betriebsablauf stört und Ausfallzeiten verursacht."
|
||||
|
||||
**Response:** Unser Roboter ist so konzipiert, dass er den Betrieb nicht stört. Seine kompakte Bauweise und autonome Navigation ermöglichen es ihm, sich sicher in Ihrer Anlage zu bewegen, ohne den laufenden Betrieb zu beeinträchtigen. Im Gegenteil, durch die kontinuierliche Überwachung und frühzeitige Erkennung von Problemen kann er Ausfallzeiten sogar reduzieren. Wir bieten eine gründliche Schulung und Integration, um sicherzustellen, dass der Roboter reibungslos in Ihre bestehenden Prozesse integriert wird.
|
||||
|
||||
---
|
||||
|
||||
### Persona: Instandhaltungsleiter
|
||||
> **Objection:** "Ich bin skeptisch, ob der Roboter zuverlässig ist und ob die Wartung kompliziert ist."
|
||||
|
||||
**Response:** Unser Roboter ist für den industriellen Einsatz konzipiert und verfügt über eine robuste Bauweise und wetterfeste Komponenten. Die Wartung ist unkompliziert und kann von Ihrem Team durchgeführt werden. Wir bieten umfassende Schulungen und Support, um sicherzustellen, dass Sie den Roboter optimal nutzen können. Darüber hinaus bieten wir optionale Wartungsverträge an, um Ihnen zusätzliche Sicherheit zu geben.
|
||||
|
||||
---
|
||||
|
||||
### Persona: Einkaufsleiter
|
||||
> **Objection:** "Das Budget ist begrenzt und ich muss sicherstellen, dass wir die kosteneffizienteste Lösung erhalten."
|
||||
|
||||
**Response:** Wir verstehen, dass das Budget eine wichtige Rolle spielt. Unser Angebot ist darauf ausgerichtet, Ihnen eine kosteneffiziente Lösung zu bieten, die langfristig Ihre Sicherheitskosten senkt. Durch die Automatisierung von Überwachungsaufgaben und die Reduzierung von Risiken können Sie erhebliche Einsparungen erzielen. Wir bieten flexible Finanzierungsoptionen und können Ihnen eine detaillierte Kosten-Nutzen-Analyse erstellen, um Ihnen bei Ihrer Entscheidung zu helfen.
|
||||
|
||||
---
|
||||
|
||||
### Persona: Innovationsmanager / Digitalisierungsbeauftragter
|
||||
> **Objection:** "Ich bin mir nicht sicher, ob der Roboter wirklich einen Mehrwert für unser Unternehmen bietet."
|
||||
|
||||
**Response:** Unser Roboter ist mehr als nur ein Gadget. Er ist ein integraler Bestandteil einer umfassenden Sicherheitsstrategie, die Ihnen hilft, Ihre Anlagen besser zu schützen, Risiken zu reduzieren und die Effizienz zu steigern. Durch die Integration von modernster Technologie und der Expertise von Wackler Security bieten wir Ihnen eine einzigartige Lösung, die Ihnen einen Wettbewerbsvorteil verschafft. Wir laden Sie gerne zu einem Pilotprojekt ein, um die Vorteile des Roboters in Ihrer eigenen Umgebung zu erleben.
|
||||
|
||||
---
|
||||
|
||||
## Visual Briefings (Prompts)
|
||||
|
||||
### Roboter in Chemieanlage
|
||||
*Context: Demonstration des Roboters in einer typischen Chemie- oder Petrochemieanlage.*
|
||||
|
||||
```
|
||||
Erstelle ein Foto-realistisches Bild eines Quadruped-Roboters, der autonom durch eine Chemieanlage navigiert. Der Roboter sollte mit Gassensoren und einer Wärmebildkamera ausgestattet sein. Im Hintergrund sind Rohrleitungen, Tanks und Produktionsanlagen zu sehen. Das Bild soll die Fähigkeit des Roboters zur autonomen Inspektion und Gefahrstofferkennung hervorheben. Füge im Hintergrund einen Wackler Security Mitarbeiter hinzu, der auf dem Weg zu einer Intervention ist.
|
||||
```
|
||||
|
||||
### Roboter in Windpark
|
||||
*Context: Darstellung des Roboters bei der Überwachung eines Windparks.*
|
||||
|
||||
```
|
||||
Erstelle ein Foto-realistisches Bild eines Quadruped-Roboters, der in einem Windpark patrouilliert. Der Roboter sollte wetterfest sein und über eine lange Akkulaufzeit verfügen. Im Hintergrund sind Windkraftanlagen und ein weiter Himmel zu sehen. Das Bild soll die Fähigkeit des Roboters zur kontinuierlichen Überwachung und Erkennung von Vandalismus oder Diebstahl hervorheben. Zeige im Hintergrund einen Wackler Security Wagen, der auf dem Weg zum Einsatzort ist.
|
||||
```
|
||||
|
||||
### Roboter in Logistikzentrum
|
||||
*Context: Visualisierung des Roboters bei der Überwachung eines Logistikzentrums.*
|
||||
|
||||
```
|
||||
Erstelle ein Foto-realistisches Bild eines Quadruped-Roboters, der autonom durch ein Logistikzentrum navigiert. Der Roboter sollte kompakt sein und über eine 360°-Umgebungserfassung verfügen. Im Hintergrund sind Regale, Gabelstapler und Mitarbeiter zu sehen. Das Bild soll die Fähigkeit des Roboters zur Diebstahlprävention und Einhaltung von Sicherheitsvorschriften hervorheben. Zeige im Hintergrund einen Mitarbeiter der Wackler NSL, der einen Alarm bearbeitet.
|
||||
```
|
||||
|
||||
|
||||
|
||||
# VERTICAL LANDING PAGES (PHASE 7)
|
||||
|
||||
## Chemie- und Petrochemieanlagen
|
||||
**Headline:** Maximale Anlagensicherheit: Autonome Inspektion trifft auf menschliche Expertise
|
||||
|
||||
**Subline:** Reduzieren Sie Risiken und Ausfallzeiten mit dem PUMA M20 und der Wackler Security. 24/7 Überwachung, Detektion von Gefahrenstoffen und schnelle Intervention.
|
||||
|
||||
**Benefits:**
|
||||
- Frühzeitige Erkennung von Lecks und Korrosion durch Gassensoren und Wärmebildkameras.
|
||||
- Autonome Inspektion schwer zugänglicher Bereiche, auch in explosionsgefährdeten Zonen.
|
||||
- Nahtlose Integration in die Wackler Notruf- und Serviceleitstelle (NSL) für sofortige Alarmierung.
|
||||
- Schnelle Intervention durch Wackler Security bei erkannten Gefahren oder unbefugtem Zutritt.
|
||||
- All-Terrain-Mobilität für Inspektionen auf dem gesamten Werksgelände.
|
||||
|
||||
**CTA:** Jetzt Sicherheitslösung konfigurieren!
|
||||
|
||||
---
|
||||
|
||||
## Energieversorgungsunternehmen (Windparks, Solarparks, Umspannwerke)
|
||||
**Headline:** Autonome Überwachung für Ihre Energieanlagen: Der PUMA M20 macht den Unterschied
|
||||
|
||||
**Subline:** Schützen Sie Ihre Anlagen vor Vandalismus, Diebstahl und Umweltschäden mit dem PUMA M20 und der Wackler Security. Kontinuierliche Überwachung, auch in unwegsamem Gelände.
|
||||
|
||||
**Benefits:**
|
||||
- Autonome Patrouillen zur Überwachung von Zäunen und Anlagen.
|
||||
- Früherkennung von Schäden durch Wärmebildkameras und andere Sensoren.
|
||||
- Abschreckung von Vandalismus und Diebstahl durch permanente Präsenz.
|
||||
- Wetterfeste Konstruktion für den zuverlässigen Einsatz im Freien.
|
||||
- Aufschaltung auf die Wackler NSL und schnelle Intervention bei Alarmen.
|
||||
|
||||
**CTA:** Sichern Sie Ihre Energieanlagen!
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
# BUSINESS CASE & ROI (PHASE 8)
|
||||
|
||||
## Chemie- und Petrochemieanlagen
|
||||
**Cost Driver:** Regelmäßige manuelle Inspektionen auf Lecks, Korrosion und strukturelle Integrität sind zeitaufwendig und kostspielig. Mitarbeiter müssen in potenziell gefährliche Bereiche vordringen. Stillstandzeiten durch Inspektionen verursachen Produktionsausfälle.
|
||||
|
||||
**Efficiency Gain:** Der PUMA M20 kann Inspektionen autonom und kontinuierlich durchführen, wodurch die Häufigkeit manueller Inspektionen reduziert wird. Frühzeitige Erkennung von Problemen (z.B. Lecks) ermöglicht rechtzeitige Reparaturen und verhindert größere Schäden und Ausfallzeiten. Kontinuierliche Gasüberwachung verbessert die Sicherheit und reduziert das Risiko von Unfällen. Durch die Integration in die Wackler Security NSL kann im Alarmfall direkt interveniert werden.
|
||||
|
||||
**Risk Argument:** Reduzierung des Risikos von Unfällen und Umweltschäden durch frühzeitige Erkennung von Lecks und anderen Problemen. Verbesserung der Compliance mit Sicherheitsvorschriften und -standards. Minimierung von Produktionsausfällen durch proaktive Wartung und Reparaturen. Der Roboter sieht die Gefahr, Wackler beseitigt sie.
|
||||
|
||||
---
|
||||
|
||||
## Energieversorgungsunternehmen (z.B. Windparks, Solarparks, Umspannwerke)
|
||||
**Cost Driver:** Weitläufige Anlagen erfordern umfangreiche Patrouillen zur Überwachung der Sicherheit und zur Erkennung von Schäden. Manuelle Inspektionen sind zeitaufwendig und personalintensiv. Die Überwachung von Zäunen und Anlagen in abgelegenen Gebieten ist schwierig und teuer.
|
||||
|
||||
**Efficiency Gain:** Der PUMA M20 kann autonom Zäune patrouillieren, Einbruchsversuche erkennen und Schäden an Anlagen frühzeitig identifizieren (z.B. durch Wärmebildkameras). Dies reduziert den Bedarf an manuellen Patrouillen und ermöglicht eine schnellere Reaktion auf Sicherheitsvorfälle. Die All-Terrain-Mobilität ermöglicht den Einsatz in unwegsamem Gelände. Durch die Integration in die Wackler Security NSL kann im Alarmfall direkt interveniert werden.
|
||||
|
||||
**Risk Argument:** Verbesserung der Sicherheit durch kontinuierliche Überwachung und schnelle Reaktion auf Sicherheitsvorfälle. Reduzierung des Risikos von Diebstahl, Vandalismus und Sabotage. Minimierung von Ausfallzeiten durch frühzeitige Erkennung von Schäden an Anlagen. Der Roboter sieht die Gefahr, Wackler beseitigt sie.
|
||||
|
||||
---
|
||||
|
||||
## Logistikzentren und große Lagerhäuser
|
||||
**Cost Driver:** Die Überwachung von Sicherheitsbereichen, die Inspektion von Regalen und die Unterstützung bei Inventurprozessen sind personalintensiv. Die manuelle Inspektion von Regalen ist zeitaufwendig und birgt das Risiko von Unfällen. Die Inventur ist ein zeitaufwendiger und fehleranfälliger Prozess.
|
||||
|
||||
**Efficiency Gain:** Der PUMA M20 kann Sicherheitsbereiche autonom überwachen, Regale inspizieren und bei Inventurprozessen unterstützen. Dies reduziert den Bedarf an manuellem Personal und verbessert die Effizienz. Die Fähigkeit, Nutzlasten zu tragen, ermöglicht den Transport von kleinen Gütern oder Werkzeugen. Die kompakte Bauweise ermöglicht den Einsatz auch in engen Gängen. Durch die Integration in die Wackler Security NSL kann im Alarmfall direkt interveniert werden.
|
||||
|
||||
**Risk Argument:** Verbesserung der Sicherheit durch kontinuierliche Überwachung und schnelle Reaktion auf Sicherheitsvorfälle. Reduzierung des Risikos von Diebstahl und Vandalismus. Optimierung der Inventurprozesse und Reduzierung von Fehlbeständen. Der Roboter sieht die Gefahr, Wackler beseitigt sie.
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
# FEATURE-TO-VALUE TRANSLATOR (PHASE 9)
|
||||
|
||||
| Feature | The Story (Benefit) | Headline |
|
||||
| :--- | :--- | :--- |
|
||||
| All-Terrain-Mobilität: Bewältigt Treppen, Schotter, Schlamm und Stahlroste. | So what? Der Roboter kann sich in anspruchsvollem Gelände bewegen. So what? Er erreicht Bereiche, die für Menschen unzugänglich oder gefährlich sind. | Erschließt unzugängliche Bereiche für Inspektion und Sicherheit. |
|
||||
| Wetterfestigkeit: IP66-Zertifizierung für Staub- und Wasserdichtigkeit. | So what? Der Roboter ist vor Umwelteinflüssen geschützt. So what? Er kann auch bei widrigen Bedingungen zuverlässig eingesetzt werden. | Zuverlässige Überwachung bei jedem Wetter. |
|
||||
| Kompakte Bauweise: Passt durch 50 cm breite Gänge und ist rucksackgroß. | So what? Der Roboter ist wendig und mobil. So what? Er kann auch in beengten Umgebungen eingesetzt und leicht transportiert werden. | Überwachung auch in den engsten Bereichen. |
|
||||
| Autonome Navigation: SLAM-Navigation für autonome Missionen und Rückkehr zur Basis. | So what? Der Roboter kann selbstständig navigieren und Aufgaben erledigen. So what? Das reduziert den Bedarf an manueller Steuerung und spart Zeit. | Autonome Patrouillen rund um die Uhr. |
|
||||
| 360°-Umgebungserfassung: Duale LiDAR-Systeme und Weitwinkelkameras. | So what? Der Roboter hat ein umfassendes Situationsbewusstsein. So what? Er erkennt Gefahren und Veränderungen in seiner Umgebung zuverlässig. | Lückenlose Überwachung dank Rundumsicht. |
|
||||
| Nachtsichtfähigkeit: Optionale Nacht- und Wärmebildkameras. | So what? Der Roboter kann auch bei Dunkelheit und schlechten Sichtverhältnissen eingesetzt werden. So what? Er erkennt Wärmequellen und potenzielle Gefahren auch im Verborgenen. | Sicherheit rund um die Uhr, auch im Dunkeln. |
|
||||
| Hohe Rechenleistung: Duale Octa-Core-Prozessoren mit 16 GB RAM und 128 GB Speicher. | So what? Der Roboter kann komplexe Daten schnell verarbeiten. So what? Er ermöglicht Echtzeit-Analysen und schnelle Reaktionen auf Ereignisse. | Intelligente Analysen in Echtzeit. |
|
||||
| Flexible Nutzlastoptionen: LiDAR, Wärmebild, PTZ, Gassensoren, Beacons. | So what? Der Roboter kann an verschiedene Aufgaben angepasst werden. So what? Er ist vielseitig einsetzbar und kann für unterschiedliche Inspektions- und Sicherheitsanforderungen konfiguriert werden. | Anpassbare Sensoren für jede Sicherheitsanforderung. |
|
||||
| Flottenmanagement und API-Integrationen: Für Datenexport und zentrale Steuerung. | So what? Der Roboter kann in bestehende Systeme integriert und zentral verwaltet werden. So what? Das ermöglicht eine effiziente Überwachung und Steuerung mehrerer Roboter. | Zentrale Steuerung für maximale Effizienz. |
|
||||
| Lange Betriebsdauer: Bis zu 3 Stunden, erweiterbar durch Hot-Swap-Batterien. | So what? Der Roboter kann lange autonom arbeiten. So what? Er minimiert Ausfallzeiten und ermöglicht kontinuierliche Überwachung. | Kontinuierliche Überwachung ohne Unterbrechung. |
|
||||
| Hohe Traglast: 12 kg Nennlast, 50 kg maximale Tragfähigkeit. | So what? Der Roboter kann schwere Ausrüstung transportieren. So what? Er kann zusätzliche Sensoren oder Werkzeuge für spezielle Aufgaben mitführen. | Transportiert schwere Lasten für erweiterte Funktionalität. |
|
||||
@@ -57,6 +57,8 @@ 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);
|
||||
@@ -69,6 +71,43 @@ 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;
|
||||
@@ -507,9 +546,10 @@ const App: React.FC = () => {
|
||||
const canAdd = ['offer', 'targetGroups'].includes(stepKey);
|
||||
const canDelete = ['offer', 'targetGroups', 'personas'].includes(stepKey);
|
||||
|
||||
const handleManualAdd = (newRow: string[]) => {
|
||||
const handleManualAdd = () => {
|
||||
const newEmptyRow = Array(step.headers.length).fill('');
|
||||
const currentRows = step.rows || [];
|
||||
handleDataChange(stepKey, { ...step, rows: [...currentRows, newRow] });
|
||||
handleDataChange(stepKey, { ...step, rows: [...currentRows, newEmptyRow] });
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -521,8 +561,8 @@ const App: React.FC = () => {
|
||||
rows={step.rows}
|
||||
onDataChange={(newRows) => handleDataChange(stepKey, { ...step, rows: newRows })}
|
||||
canAddRows={canAdd}
|
||||
onEnrichRow={canAdd ? handleManualAdd : undefined}
|
||||
isEnriching={false}
|
||||
onEnrichRow={stepKey === 'offer' ? handleEnrichRow : handleManualAdd}
|
||||
isEnriching={isEnriching}
|
||||
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 [.env.local](.env.local) to your Gemini API key
|
||||
2. Set the `GEMINI_API_KEY` in the central `.env` file in the project's root directory.
|
||||
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) => Promise<void>;
|
||||
onEnrichRow?: (productName: string, productUrl?: string) => void;
|
||||
isEnriching?: boolean;
|
||||
onRestart?: () => void;
|
||||
t: typeof translations.de;
|
||||
@@ -106,12 +106,7 @@ export const StepDisplay: React.FC<StepDisplayProps> = ({ title, summary, header
|
||||
};
|
||||
|
||||
const handleAddRowClick = () => {
|
||||
if (onEnrichRow) {
|
||||
setIsAddingRow(true);
|
||||
} else {
|
||||
const newEmptyRow = Array(headers.length).fill('');
|
||||
onDataChange([...rows, newEmptyRow]);
|
||||
}
|
||||
setIsAddingRow(true);
|
||||
};
|
||||
|
||||
const handleConfirmAddRow = () => {
|
||||
|
||||
@@ -89,6 +89,15 @@ 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,6 +622,40 @@ 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)
|
||||
@@ -633,10 +667,13 @@ 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)
|
||||
|
||||
53
branchenschema_roboplanet.txt
Normal file
53
branchenschema_roboplanet.txt
Normal file
@@ -0,0 +1,53 @@
|
||||
Land- und Forstwirtschaft, Fischerei Land- und Forstwirtschaft
|
||||
Bergbau und Gewinnung von Steinen und Erden Bergbau
|
||||
Bergbau und Gewinnung von Steinen und Erden Energie
|
||||
Verarbeitendes Gewerbe Holz- Papier- und Druckerzeugnisse
|
||||
Verarbeitendes Gewerbe Industrie
|
||||
Verarbeitendes Gewerbe Kunststoff und Gummiwaren
|
||||
Verarbeitendes Gewerbe Lebensmittelindustrie
|
||||
Verarbeitendes Gewerbe Maschinenbau und Fahrzeugbau
|
||||
Verarbeitendes Gewerbe Automobilzulieferer
|
||||
Verarbeitendes Gewerbe Leder- Textil- und Bekleidungsindustrie
|
||||
Verarbeitendes Gewerbe Pharma
|
||||
Energieversorgung öffentliche Energieversorgung
|
||||
Energieversorgung private Energieversorgung
|
||||
Wasserversorgung, Abwasser- und Abfallentsorgung Abfallentsorgung
|
||||
Wasserversorgung, Abwasser- und Abfallentsorgung Wasserversorgung
|
||||
Baugewerbe Architektur-/Ingenieurbüro
|
||||
Baugewerbe Bau- und Baunebengewerbe
|
||||
Handel, Instandhaltung und Reparatur von Fahrzeugen Baumärkte
|
||||
Handel, Instandhaltung und Reparatur von Fahrzeugen Automobilbranche
|
||||
Handel, Instandhaltung und Reparatur von Fahrzeugen Groß- und Einzelhandel
|
||||
Verkehr und Lagerei Transport und Verkehr
|
||||
Gastgewerbe Gaststätten
|
||||
Gastgewerbe Hotelbetriebe
|
||||
Information und Kommunikation PR, Presse und Marketing
|
||||
Information und Kommunikation Verlage
|
||||
Erbringung von Finanz- und Versicherungsdienstleistungen Banken/Versicherungen
|
||||
Erbringung von Finanz- und Versicherungsdienstleistungen Finanzdienstleister
|
||||
Grundstücks- und Wohnungswesen Hausverwaltung
|
||||
Grundstücks- und Wohnungswesen Immobilien
|
||||
Erbringung von freiberufl., wissenschaftl. und techn. DL Dienstleister freiber., wissensch., technisch
|
||||
Erbringung von freiberufl., wissenschaftl. und techn. DL Elektrotechnik
|
||||
Erbringung von freiberufl., wissenschaftl. und techn. DL Wissenschaftliche Einrichtungen
|
||||
Erbringung von sonstigen wirtschaftl. DL Consulter
|
||||
Erbringung von sonstigen wirtschaftl. DL Dienstleister wirtschaftlich
|
||||
Öffentliche Verwaltung, Verteidigung, Sozialversicherung Bund und Kommunen
|
||||
Öffentliche Verwaltung, Verteidigung, Sozialversicherung Verwaltung öffentlich
|
||||
Erziehung und Unterricht Bildungseinrichtung
|
||||
Erziehung und Unterricht Hochschuleinrichtung
|
||||
Erziehung und Unterricht Kindergärten
|
||||
Erziehung und Unterricht Schulen
|
||||
Gesundheits- und Sozialwesen Soziale Einrichtung
|
||||
Gesundheits- und Sozialwesen Gesundheitswesen
|
||||
Gesundheits- und Sozialwesen Kleinere Medizinische Einrichtungen
|
||||
Gesundheits- und Sozialwesen Kliniken - öffentlich
|
||||
Gesundheits- und Sozialwesen Kliniken - privat
|
||||
Gesundheits- und Sozialwesen Senioreneinrichtungen - öffentlich
|
||||
Gesundheits- und Sozialwesen Senioreneinrichtungen - privat
|
||||
Kunst, Unterhaltung und Erholung Kultur- und Freizeiteinrichtung
|
||||
Erbringung von sonstigen Dienstleistungen Gebäudedienstleister
|
||||
Erbringung von sonstigen Dienstleistungen IT-Unternehmen
|
||||
Erbringung von sonstigen Dienstleistungen Dienstleister sonstige
|
||||
Erbringung von sonstigen Dienstleistungen Steuer- und Rechtsberatung
|
||||
Erbringung von sonstigen Dienstleistungen Verwaltung sonstige
|
||||
45
check_db.py
Normal file
45
check_db.py
Normal file
@@ -0,0 +1,45 @@
|
||||
import sqlite3
|
||||
import os
|
||||
|
||||
dbs = [
|
||||
"/app/companies_v4_notion_sync.db",
|
||||
"/app/companies_v3_final.db",
|
||||
"/app/company-explorer/companies_v3_fixed_2.db",
|
||||
"/app/company-explorer/companies.db"
|
||||
]
|
||||
|
||||
found = False
|
||||
for db_path in dbs:
|
||||
if not os.path.exists(db_path):
|
||||
continue
|
||||
|
||||
print(f"Checking {db_path}...")
|
||||
try:
|
||||
conn = sqlite3.connect(db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Get column names
|
||||
cursor.execute("PRAGMA table_info(companies)")
|
||||
columns = [info[1] for info in cursor.fetchall()]
|
||||
print(f"Columns: {columns}")
|
||||
|
||||
cursor.execute("SELECT * FROM companies WHERE name LIKE '%Wolfra%'")
|
||||
rows = cursor.fetchall()
|
||||
|
||||
if rows:
|
||||
print(f"Found {len(rows)} rows in {db_path}:")
|
||||
for row in rows:
|
||||
# Create a dict for easier reading
|
||||
row_dict = dict(zip(columns, row))
|
||||
print(row_dict)
|
||||
found = True
|
||||
else:
|
||||
print("No matching rows found.")
|
||||
|
||||
conn.close()
|
||||
except Exception as e:
|
||||
print(f"Error reading {db_path}: {e}")
|
||||
print("-" * 20)
|
||||
|
||||
if not found:
|
||||
print("No 'Wolfra' company found in any checked database.")
|
||||
36
check_db_content.py
Normal file
36
check_db_content.py
Normal file
@@ -0,0 +1,36 @@
|
||||
|
||||
import sys
|
||||
import os
|
||||
import logging
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), 'company-explorer')))
|
||||
|
||||
from backend.database import SessionLocal, Company
|
||||
|
||||
def check_db_content():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
print("--- Checking content of 'companies' table ---")
|
||||
companies = db.query(Company).limit(5).all()
|
||||
|
||||
if not companies:
|
||||
print("!!! FATAL: The 'companies' table is EMPTY.")
|
||||
# Let's check if the table is there at all
|
||||
try:
|
||||
count = db.query(Company).count()
|
||||
print(f"Row count is confirmed to be {count}.")
|
||||
except Exception as e:
|
||||
print(f"!!! Could not even count rows. The table might be corrupt. Error: {e}")
|
||||
|
||||
else:
|
||||
print(f"Found {len(companies)} companies. Data seems to be present.")
|
||||
for company in companies:
|
||||
print(f" - ID: {company.id}, Name: {company.name}")
|
||||
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
if __name__ == "__main__":
|
||||
check_db_content()
|
||||
58
check_notion_token.py
Normal file
58
check_notion_token.py
Normal file
@@ -0,0 +1,58 @@
|
||||
import requests
|
||||
from getpass import getpass
|
||||
|
||||
# Interaktive und sichere Abfrage des Tokens
|
||||
print("--- Notion API Token Gültigkeits-Check ---")
|
||||
notion_token = getpass("Bitte gib deinen Notion API Key ein (Eingabe wird nicht angezeigt): ")
|
||||
|
||||
if not notion_token:
|
||||
print("\nFehler: Kein Token eingegeben.")
|
||||
exit()
|
||||
|
||||
# Der einfachste API-Endpunkt, um die Authentifizierung zu testen
|
||||
url = "https://api.notion.com/v1/users/me"
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {notion_token}",
|
||||
"Notion-Version": "2022-06-28"
|
||||
}
|
||||
|
||||
print("\n... Sende Test-Anfrage an Notion...")
|
||||
|
||||
try:
|
||||
# --- TEST 1: Grundlegende Authentifizierung ---
|
||||
print("\n[TEST 1/2] Prüfe grundlegende Authentifizierung (/users/me)...")
|
||||
user_response = requests.get("https://api.notion.com/v1/users/me", headers=headers)
|
||||
user_response.raise_for_status()
|
||||
print("✅ ERFOLG! Der API Token ist gültig.")
|
||||
|
||||
# --- TEST 2: Suche nach der 'Projects' Datenbank ---
|
||||
print("\n[TEST 2/2] Versuche, die 'Projects'-Datenbank über die Suche zu finden (/search)...")
|
||||
search_url = "https://api.notion.com/v1/search"
|
||||
search_payload = {
|
||||
"query": "Projects",
|
||||
"filter": {"value": "database", "property": "object"}
|
||||
}
|
||||
search_response = requests.post(search_url, headers=headers, json=search_payload)
|
||||
search_response.raise_for_status()
|
||||
|
||||
results = search_response.json().get("results", [])
|
||||
if not results:
|
||||
print("🟡 WARNUNG: Die Suche war erfolgreich, hat aber keine Datenbank namens 'Projects' gefunden.")
|
||||
else:
|
||||
print("✅✅✅ ERFOLG! Die Suche funktioniert und hat die 'Projects'-Datenbank gefunden.")
|
||||
print("Gefundene Datenbanken:")
|
||||
for db in results:
|
||||
print(f"- ID: {db['id']}, Titel: {db.get('title', [{}])[0].get('plain_text', 'N/A')}")
|
||||
|
||||
except requests.exceptions.HTTPError as e:
|
||||
print(f"\n❌ FEHLER! Einer der Tests ist fehlgeschlagen.")
|
||||
print(f"URL: {e.request.url}")
|
||||
print(f"HTTP Status Code: {e.response.status_code}")
|
||||
print("Antwort von Notion:")
|
||||
try:
|
||||
print(e.response.json())
|
||||
except:
|
||||
print(e.response.text)
|
||||
except requests.exceptions.RequestException as e:
|
||||
print(f"\n❌ FEHLER! Ein Netzwerk- oder Verbindungsfehler ist aufgetreten: {e}")
|
||||
28
check_schema.py
Normal file
28
check_schema.py
Normal file
@@ -0,0 +1,28 @@
|
||||
import sqlite3
|
||||
|
||||
db_path = "/app/company-explorer/companies_v3_fixed_2.db"
|
||||
conn = sqlite3.connect(db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
for table in ['signals', 'enrichment_data']:
|
||||
print(f"\nSchema of {table}:")
|
||||
cursor.execute(f"PRAGMA table_info({table})")
|
||||
for col in cursor.fetchall():
|
||||
print(col)
|
||||
|
||||
print(f"\nContent of {table} for company_id=12 (guessing FK):")
|
||||
# Try to find FK column
|
||||
cursor.execute(f"PRAGMA table_info({table})")
|
||||
cols = [c[1] for c in cursor.fetchall()]
|
||||
fk_col = next((c for c in cols if 'company_id' in c or 'account_id' in c), None)
|
||||
|
||||
if fk_col:
|
||||
cursor.execute(f"SELECT * FROM {table} WHERE {fk_col}=12")
|
||||
rows = cursor.fetchall()
|
||||
for row in rows:
|
||||
print(dict(zip(cols, row)))
|
||||
else:
|
||||
print(f"Could not guess FK column for {table}")
|
||||
|
||||
conn.close()
|
||||
|
||||
21
check_tables.py
Normal file
21
check_tables.py
Normal file
@@ -0,0 +1,21 @@
|
||||
import sqlite3
|
||||
|
||||
db_path = "/app/company-explorer/companies_v3_fixed_2.db"
|
||||
conn = sqlite3.connect(db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("SELECT name FROM sqlite_master WHERE type='table';")
|
||||
tables = cursor.fetchall()
|
||||
print(f"Tables in {db_path}: {tables}")
|
||||
|
||||
# Check content of 'signals' if it exists
|
||||
if ('signals',) in tables:
|
||||
print("\nChecking 'signals' table for Wolfra (id=12)...")
|
||||
cursor.execute("SELECT * FROM signals WHERE account_id=12")
|
||||
columns = [desc[0] for desc in cursor.description]
|
||||
rows = cursor.fetchall()
|
||||
for row in rows:
|
||||
print(dict(zip(columns, row)))
|
||||
|
||||
conn.close()
|
||||
|
||||
@@ -1,29 +1,51 @@
|
||||
# --- 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 /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"
|
||||
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
|
||||
RUN npm run build
|
||||
|
||||
# --- STAGE 2: Backend & Runtime ---
|
||||
# --- 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 ---
|
||||
FROM python:3.11-slim
|
||||
WORKDIR /app
|
||||
|
||||
# System Dependencies
|
||||
RUN apt-get update && apt-get install -y \
|
||||
build-essential \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
# Set non-interactive to avoid prompts
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
# Copy Requirements & Install
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
# 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 Built Frontend from Stage 1 (To a safe location outside /app)
|
||||
COPY --from=frontend-builder /build/dist /frontend_static
|
||||
# Copy only the installed Python packages
|
||||
COPY --from=backend-builder /install /usr/local
|
||||
ENV PATH=/usr/local/bin:$PATH
|
||||
|
||||
# Copy Backend Source
|
||||
# Copy Built Frontend from the new, correct location
|
||||
COPY --from=frontend-builder /app/frontend/dist /frontend_static
|
||||
|
||||
# Copy only necessary Backend Source
|
||||
COPY backend ./backend
|
||||
|
||||
# Environment Variables
|
||||
@@ -33,5 +55,5 @@ ENV PYTHONUNBUFFERED=1
|
||||
# Expose Port
|
||||
EXPOSE 8000
|
||||
|
||||
# Start FastAPI
|
||||
CMD ["uvicorn", "backend.app:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
|
||||
# Start FastAPI (Production mode without --reload)
|
||||
CMD ["uvicorn", "backend.app:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
|
||||
178
company-explorer/MIGRATION_PLAN.md
Normal file
178
company-explorer/MIGRATION_PLAN.md
Normal file
@@ -0,0 +1,178 @@
|
||||
# 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,6 +8,21 @@ 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
|
||||
@@ -17,7 +32,7 @@ setup_logging()
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
from .database import init_db, get_db, Company, Signal, EnrichmentData, RoboticsCategory, Contact, Industry, JobRoleMapping
|
||||
from .database import init_db, get_db, Company, Signal, EnrichmentData, RoboticsCategory, Contact, Industry, JobRoleMapping, ReportedMistake, MarketingMatrix
|
||||
from .services.deduplication import Deduplicator
|
||||
from .services.discovery import DiscoveryService
|
||||
from .services.scraping import ScraperService
|
||||
@@ -27,8 +42,7 @@ from .services.classification import ClassificationService
|
||||
app = FastAPI(
|
||||
title=settings.APP_NAME,
|
||||
version=settings.VERSION,
|
||||
description="Backend for Company Explorer (Robotics Edition)",
|
||||
root_path="/ce"
|
||||
description="Backend for Company Explorer (Robotics Edition)"
|
||||
)
|
||||
|
||||
app.add_middleware(
|
||||
@@ -50,6 +64,7 @@ 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]
|
||||
@@ -61,6 +76,28 @@ 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():
|
||||
@@ -74,16 +111,152 @@ def on_startup():
|
||||
# --- Routes ---
|
||||
|
||||
@app.get("/api/health")
|
||||
def health_check():
|
||||
def health_check(username: str = Depends(authenticate_user)):
|
||||
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)
|
||||
db: Session = Depends(get_db),
|
||||
username: str = Depends(authenticate_user)
|
||||
):
|
||||
try:
|
||||
query = db.query(Company)
|
||||
@@ -99,13 +272,29 @@ 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)):
|
||||
def export_companies_csv(db: Session = Depends(get_db), username: str = Depends(authenticate_user)):
|
||||
"""
|
||||
Exports a CSV of all companies with their key metrics.
|
||||
"""
|
||||
@@ -147,17 +336,44 @@ def export_companies_csv(db: Session = Depends(get_db)):
|
||||
)
|
||||
|
||||
@app.get("/api/companies/{company_id}")
|
||||
def get_company(company_id: int, db: Session = Depends(get_db)):
|
||||
def get_company(company_id: int, db: Session = Depends(get_db), username: str = Depends(authenticate_user)):
|
||||
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")
|
||||
return company
|
||||
|
||||
# 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}
|
||||
|
||||
@app.post("/api/companies")
|
||||
def create_company(company: CompanyCreate, db: Session = Depends(get_db)):
|
||||
def create_company(company: CompanyCreate, db: Session = Depends(get_db), username: str = Depends(authenticate_user)):
|
||||
db_company = db.query(Company).filter(Company.name == company.name).first()
|
||||
if db_company:
|
||||
raise HTTPException(status_code=400, detail="Company already registered")
|
||||
@@ -167,6 +383,7 @@ def create_company(company: CompanyCreate, db: Session = Depends(get_db)):
|
||||
city=company.city,
|
||||
country=company.country,
|
||||
website=company.website,
|
||||
crm_id=company.crm_id,
|
||||
status="NEW"
|
||||
)
|
||||
db.add(new_company)
|
||||
@@ -175,7 +392,7 @@ def create_company(company: CompanyCreate, db: Session = Depends(get_db)):
|
||||
return new_company
|
||||
|
||||
@app.post("/api/companies/bulk")
|
||||
def bulk_import_companies(req: BulkImportRequest, background_tasks: BackgroundTasks, db: Session = Depends(get_db)):
|
||||
def bulk_import_companies(req: BulkImportRequest, background_tasks: BackgroundTasks, db: Session = Depends(get_db), username: str = Depends(authenticate_user)):
|
||||
imported_count = 0
|
||||
for name in req.names:
|
||||
name = name.strip()
|
||||
@@ -193,7 +410,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)):
|
||||
def override_wikipedia(company_id: int, url: str, background_tasks: BackgroundTasks, 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")
|
||||
@@ -229,26 +446,73 @@ 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)):
|
||||
def list_robotics_categories(db: Session = Depends(get_db), username: str = Depends(authenticate_user)):
|
||||
return db.query(RoboticsCategory).all()
|
||||
|
||||
@app.get("/api/industries")
|
||||
def list_industries(db: Session = Depends(get_db)):
|
||||
def list_industries(db: Session = Depends(get_db), username: str = Depends(authenticate_user)):
|
||||
return db.query(Industry).all()
|
||||
|
||||
@app.get("/api/job_roles")
|
||||
def list_job_roles(db: Session = Depends(get_db)):
|
||||
def list_job_roles(db: Session = Depends(get_db), username: str = Depends(authenticate_user)):
|
||||
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)):
|
||||
def discover_company(req: AnalysisRequest, background_tasks: BackgroundTasks, db: Session = Depends(get_db), username: str = Depends(authenticate_user)):
|
||||
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)):
|
||||
def analyze_company(req: AnalysisRequest, background_tasks: BackgroundTasks, db: Session = Depends(get_db), username: str = Depends(authenticate_user)):
|
||||
company = db.query(Company).filter(Company.id == req.company_id).first()
|
||||
if not company: raise HTTPException(404, "Company not found")
|
||||
|
||||
@@ -260,10 +524,11 @@ 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)
|
||||
db: Session = Depends(get_db),
|
||||
username: str = Depends(authenticate_user)
|
||||
):
|
||||
company = db.query(Company).filter(Company.id == company_id).first()
|
||||
if not company:
|
||||
@@ -281,7 +546,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)):
|
||||
def reevaluate_wikipedia(company_id: int, background_tasks: BackgroundTasks, 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")
|
||||
@@ -291,7 +556,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)):
|
||||
def delete_company(company_id: int, 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")
|
||||
@@ -306,7 +571,7 @@ def delete_company(company_id: int, db: Session = Depends(get_db)):
|
||||
return {"status": "deleted"}
|
||||
|
||||
@app.post("/api/companies/{company_id}/override/website")
|
||||
def override_website(company_id: int, url: str, db: Session = Depends(get_db)):
|
||||
def override_website(company_id: int, url: str, 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")
|
||||
@@ -317,35 +582,116 @@ def override_website(company_id: int, url: str, db: Session = Depends(get_db)):
|
||||
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)):
|
||||
|
||||
def override_impressum(company_id: int, url: str, background_tasks: BackgroundTasks, 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")
|
||||
|
||||
|
||||
|
||||
# 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()
|
||||
@@ -469,10 +815,23 @@ def run_analysis_task(company_id: int):
|
||||
# --- Serve Frontend ---
|
||||
static_path = "/frontend_static"
|
||||
if not os.path.exists(static_path):
|
||||
static_path = os.path.join(os.path.dirname(__file__), "../static")
|
||||
# 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)})")
|
||||
|
||||
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,21 +18,35 @@ class Company(Base):
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
|
||||
# Core Identity
|
||||
# Core Identity (Golden Record - from Research)
|
||||
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
|
||||
# Location (Golden Record)
|
||||
city = Column(String, nullable=True)
|
||||
country = Column(String, default="DE")
|
||||
|
||||
# Workflow Status
|
||||
status = Column(String, default="NEW", index=True)
|
||||
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
|
||||
|
||||
# Granular Process Tracking (Timestamps)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
@@ -58,6 +72,7 @@ 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")
|
||||
|
||||
|
||||
@@ -78,6 +93,10 @@ 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
|
||||
|
||||
@@ -105,6 +124,13 @@ 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)
|
||||
@@ -116,6 +142,10 @@ 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)
|
||||
|
||||
@@ -203,6 +233,49 @@ 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
|
||||
# ==============================================================================
|
||||
|
||||
65
company-explorer/backend/scripts/migrate_ce_db.py
Normal file
65
company-explorer/backend/scripts/migrate_ce_db.py
Normal file
@@ -0,0 +1,65 @@
|
||||
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,6 +69,26 @@ 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.")
|
||||
|
||||
|
||||
185
company-explorer/backend/scripts/sync_notion_to_ce_enhanced.py
Normal file
185
company-explorer/backend/scripts/sync_notion_to_ce_enhanced.py
Normal file
@@ -0,0 +1,185 @@
|
||||
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()
|
||||
137
company-explorer/backend/tests/test_classification_service.py
Normal file
137
company-explorer/backend/tests/test_classification_service.py
Normal file
@@ -0,0 +1,137 @@
|
||||
|
||||
import sys
|
||||
import os
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
# Ensure the app's root is in the path to allow imports
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..')))
|
||||
|
||||
from backend.services.classification import ClassificationService
|
||||
|
||||
class TestClassificationService(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
"""Set up a new ClassificationService instance for each test."""
|
||||
self.service = ClassificationService()
|
||||
|
||||
def test_plausibility_check_hospital_beds(self):
|
||||
"""
|
||||
Tests the _is_metric_plausible method with rules for hospital beds.
|
||||
"""
|
||||
# Plausible value
|
||||
self.assertTrue(self.service._is_metric_plausible("# Planbetten (Krankenhaus)", 150))
|
||||
|
||||
# Implausible value (too low)
|
||||
self.assertFalse(self.service._is_metric_plausible("# Planbetten (Krankenhaus)", 11))
|
||||
|
||||
# Edge case: exactly the minimum
|
||||
self.assertTrue(self.service._is_metric_plausible("# Planbetten (Krankenhaus)", 20))
|
||||
|
||||
# No value
|
||||
self.assertTrue(self.service._is_metric_plausible("# Planbetten (Krankenhaus)", None))
|
||||
|
||||
def test_plausibility_check_no_rule(self):
|
||||
"""
|
||||
Tests that metrics without a specific rule are always considered plausible.
|
||||
"""
|
||||
self.assertTrue(self.service._is_metric_plausible("Some New Metric", 5))
|
||||
self.assertTrue(self.service._is_metric_plausible("Another Metric", 100000))
|
||||
|
||||
@patch('backend.services.classification.run_serp_search')
|
||||
@patch('backend.services.classification.scrape_website_content')
|
||||
@patch('backend.services.classification.ClassificationService._get_wikipedia_content')
|
||||
def test_source_prioritization_erding_case(self, mock_get_wiki, mock_scrape_web, mock_serp):
|
||||
"""
|
||||
Tests that a high-quality Wikipedia result is chosen over a low-quality website result.
|
||||
"""
|
||||
# --- Mocks Setup ---
|
||||
# Mock website to return a bad, implausible value
|
||||
mock_scrape_web.return_value = "Auf unseren 11 Stationen..."
|
||||
# Mock Wikipedia to return a good, plausible value
|
||||
mock_get_wiki.return_value = "Das Klinikum hat 352 Betten."
|
||||
# Mock SerpAPI to return nothing
|
||||
mock_serp.return_value = None
|
||||
|
||||
# Mock the LLM to return different values based on the source content
|
||||
def llm_side_effect(content, search_term, industry_name):
|
||||
if "11 Stationen" in content:
|
||||
return {"raw_text_segment": "11 Stationen", "raw_value": 11, "raw_unit": "Stationen", "confidence_score": 0.6, "calculated_metric_value": 11}
|
||||
if "352 Betten" in content:
|
||||
return {"raw_text_segment": "352 Betten", "raw_value": 352, "raw_unit": "Betten", "confidence_score": 0.95, "calculated_metric_value": 352}
|
||||
return None
|
||||
|
||||
# We need to patch the LLM call within the service instance for the test
|
||||
self.service._run_llm_metric_extraction_prompt = MagicMock(side_effect=llm_side_effect)
|
||||
|
||||
# --- Test Execution ---
|
||||
mock_company = MagicMock()
|
||||
mock_company.website = "http://example.com"
|
||||
mock_company.id = 1
|
||||
|
||||
# We need a mock DB session
|
||||
mock_db = MagicMock()
|
||||
|
||||
results = self.service._extract_and_calculate_metric_cascade(
|
||||
db=mock_db,
|
||||
company=mock_company,
|
||||
industry_name="Krankenhaus",
|
||||
search_term="# Planbetten (Krankenhaus)",
|
||||
standardization_logic=None,
|
||||
standardized_unit="Betten"
|
||||
)
|
||||
|
||||
# --- Assertions ---
|
||||
self.assertIsNotNone(results)
|
||||
self.assertEqual(results['calculated_metric_value'], 352)
|
||||
self.assertEqual(results['metric_source'], 'wikipedia')
|
||||
|
||||
@patch('backend.services.classification.run_serp_search')
|
||||
@patch('backend.services.classification.scrape_website_content')
|
||||
def test_targeted_extraction_spetec_case(self, mock_scrape_web, mock_serp):
|
||||
"""
|
||||
Tests that the correct value is extracted when a text snippet contains multiple numbers.
|
||||
"""
|
||||
# --- Mocks Setup ---
|
||||
# Mock website content with ambiguous numbers
|
||||
mock_scrape_web.return_value = "Wir haben 65 Mitarbeiter auf einer Fläche von 8.000 m²."
|
||||
mock_serp.return_value = None
|
||||
|
||||
|
||||
# Mock the LLM to return the full snippet, letting the parser do the work
|
||||
# The improved prompt should guide the LLM to provide the correct 'raw_value' as a hint
|
||||
llm_result = {
|
||||
"raw_text_segment": "65 Mitarbeiter auf einer Fläche von 8.000 m²",
|
||||
"raw_value": "8000", # The crucial hint from the improved prompt
|
||||
"raw_unit": "m²",
|
||||
"confidence_score": 0.9,
|
||||
"calculated_metric_value": 8000.0
|
||||
}
|
||||
self.service._run_llm_metric_extraction_prompt = MagicMock(return_value=llm_result)
|
||||
|
||||
# --- Test Execution ---
|
||||
mock_company = MagicMock()
|
||||
mock_company.website = "http://spetec.com"
|
||||
mock_company.id = 2
|
||||
mock_db = MagicMock()
|
||||
|
||||
# Set up a mock for _get_wikipedia_content to return None, so we only test the website part
|
||||
self.service._get_wikipedia_content = MagicMock(return_value=None)
|
||||
|
||||
results = self.service._extract_and_calculate_metric_cascade(
|
||||
db=mock_db,
|
||||
company=mock_company,
|
||||
industry_name="Laborausstattung",
|
||||
search_term="Fabrikhalle (m²)",
|
||||
standardization_logic=None,
|
||||
standardized_unit="m²"
|
||||
)
|
||||
|
||||
# --- Assertions ---
|
||||
self.assertIsNotNone(results)
|
||||
self.assertEqual(results['calculated_metric_value'], 8000.0)
|
||||
self.assertEqual(results['metric_source'], 'website')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
48
company-explorer/debug_standardization_logic.py
Normal file
48
company-explorer/debug_standardization_logic.py
Normal file
@@ -0,0 +1,48 @@
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from backend.database import Industry
|
||||
from backend.services.classification import ClassificationService
|
||||
import logging
|
||||
|
||||
# Setup DB connection directly to the file
|
||||
# Note: We need to use the absolute path that works inside the container or relative if running locally
|
||||
# Assuming we run this via 'docker exec' or locally if paths align.
|
||||
# For safety, I'll use the path from config but typically inside container it's /app/...
|
||||
DB_URL = "sqlite:////app/companies_v3_fixed_2.db"
|
||||
|
||||
engine = create_engine(DB_URL)
|
||||
SessionLocal = sessionmaker(bind=engine)
|
||||
db = SessionLocal()
|
||||
|
||||
def test_logic():
|
||||
print("--- DEBUGGING STANDARDIZATION LOGIC ---")
|
||||
|
||||
industry_name = "Healthcare - Hospital"
|
||||
industry = db.query(Industry).filter(Industry.name == industry_name).first()
|
||||
|
||||
if not industry:
|
||||
print(f"ERROR: Industry '{industry_name}' not found!")
|
||||
return
|
||||
|
||||
print(f"Industry: {industry.name}")
|
||||
print(f"Search Term: {industry.scraper_search_term}")
|
||||
print(f"Standardization Logic (Raw DB Value): '{industry.standardization_logic}'")
|
||||
|
||||
if not industry.standardization_logic:
|
||||
print("CRITICAL: Standardization logic is empty/null! That explains the null result.")
|
||||
return
|
||||
|
||||
# Initialize Service to test the exact method
|
||||
service = ClassificationService()
|
||||
test_value = 352.0
|
||||
|
||||
print(f"\nTesting calculation with value: {test_value}")
|
||||
|
||||
try:
|
||||
result = service._parse_standardization_logic(industry.standardization_logic, test_value)
|
||||
print(f"Result: {result}")
|
||||
except Exception as e:
|
||||
print(f"Exception during parsing: {e}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_logic()
|
||||
@@ -22,10 +22,7 @@
|
||||
"@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.8"
|
||||
"vite": "^5.0.10"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
LayoutGrid, List, ChevronLeft, ChevronRight, ArrowDownUp, Flag
|
||||
} from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
@@ -16,6 +16,7 @@ interface Company {
|
||||
industry_ai: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
has_pending_mistakes: boolean
|
||||
}
|
||||
|
||||
interface CompanyTableProps {
|
||||
@@ -124,7 +125,10 @@ 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="font-bold text-slate-900 dark:text-white text-sm truncate" title={c.name}>{c.name}</div>
|
||||
<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="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>
|
||||
@@ -163,7 +167,12 @@ 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">{c.name}</td>
|
||||
<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 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, 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 { 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 clsx from 'clsx'
|
||||
import { ContactsManager, Contact } from './ContactsManager'
|
||||
|
||||
interface InspectorProps {
|
||||
companyId: number | null
|
||||
initialContactId?: number | null // NEW
|
||||
initialContactId?: number | null
|
||||
onClose: () => void
|
||||
apiBase: string
|
||||
}
|
||||
@@ -25,6 +25,14 @@ 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
|
||||
@@ -35,6 +43,20 @@ 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
|
||||
@@ -48,12 +70,34 @@ 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;
|
||||
@@ -63,9 +107,8 @@ export function Inspector({ companyId, initialContactId, onClose, apiBase }: Ins
|
||||
}, 2000)
|
||||
}
|
||||
return () => clearInterval(interval)
|
||||
}, [isProcessing, companyId]) // Dependencies
|
||||
}, [isProcessing, companyId])
|
||||
|
||||
// Auto-switch to contacts tab if initialContactId is present
|
||||
useEffect(() => {
|
||||
if (initialContactId) {
|
||||
setActiveTab('contacts')
|
||||
@@ -74,7 +117,6 @@ 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)
|
||||
@@ -82,7 +124,6 @@ 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("")
|
||||
@@ -91,18 +132,19 @@ export function Inspector({ companyId, initialContactId, onClose, apiBase }: Ins
|
||||
if (!companyId) return
|
||||
if (!silent) setLoading(true)
|
||||
|
||||
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)
|
||||
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
|
||||
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)
|
||||
}
|
||||
@@ -118,9 +160,8 @@ export function Inspector({ companyId, initialContactId, onClose, apiBase }: Ins
|
||||
setIsEditingWebsite(false)
|
||||
setIsEditingImpressum(false)
|
||||
setIsEditingIndustry(false)
|
||||
setIsProcessing(false) // Reset on ID change
|
||||
setIsProcessing(false)
|
||||
|
||||
// Load industries for dropdown
|
||||
axios.get(`${apiBase}/industries`)
|
||||
.then(res => setIndustries(res.data))
|
||||
.catch(console.error)
|
||||
@@ -131,7 +172,6 @@ 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)
|
||||
@@ -143,7 +183,6 @@ 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)
|
||||
@@ -152,8 +191,6 @@ export function Inspector({ companyId, initialContactId, onClose, apiBase }: Ins
|
||||
|
||||
const handleExport = () => {
|
||||
if (!data) return;
|
||||
|
||||
// Prepare full export object
|
||||
const exportData = {
|
||||
metadata: {
|
||||
id: data.id,
|
||||
@@ -259,30 +296,22 @@ 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) // Stop on direct error
|
||||
setIsProcessing(false)
|
||||
}
|
||||
}
|
||||
|
||||
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}`)
|
||||
console.log("[Inspector] Delete successful")
|
||||
onClose() // Close the inspector on success
|
||||
window.location.reload() // Force reload to show updated list
|
||||
onClose()
|
||||
window.location.reload()
|
||||
} catch (e: any) {
|
||||
console.error("[Inspector] Delete failed:", e)
|
||||
alert("Failed to delete company: " + (e.response?.data?.detail || e.message))
|
||||
}
|
||||
}
|
||||
@@ -291,12 +320,57 @@ export function Inspector({ companyId, initialContactId, onClose, apiBase }: Ins
|
||||
if (!companyId) return
|
||||
try {
|
||||
await axios.post(`${apiBase}/enrichment/${companyId}/${sourceType}/lock?locked=${!currentLockStatus}`)
|
||||
fetchData(true) // Silent refresh
|
||||
fetchData(true)
|
||||
} 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 {
|
||||
@@ -335,8 +409,131 @@ 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-[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">
|
||||
<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">
|
||||
{loading ? (
|
||||
<div className="p-8 text-slate-500">Loading details...</div>
|
||||
) : !data ? (
|
||||
@@ -362,6 +559,13 @@ 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"
|
||||
@@ -424,7 +628,76 @@ export function Inspector({ companyId, initialContactId, onClose, apiBase }: Ins
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tab Navigation */}
|
||||
{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>
|
||||
)}
|
||||
|
||||
<div className="mt-6 flex border-b border-slate-200 dark:border-slate-800">
|
||||
<button
|
||||
onClick={() => setActiveTab('overview')}
|
||||
@@ -460,7 +733,6 @@ 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}
|
||||
@@ -480,7 +752,9 @@ export function Inspector({ companyId, initialContactId, onClose, apiBase }: Ins
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Impressum / Legal Data */}
|
||||
{renderDataQualityCard()}
|
||||
{renderStrategyCard()}
|
||||
|
||||
<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">
|
||||
@@ -496,7 +770,6 @@ export function Inspector({ companyId, initialContactId, onClose, apiBase }: Ins
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Lock Button for Impressum */}
|
||||
{scrapeEntry && (
|
||||
<button
|
||||
onClick={() => handleLockToggle('website_scrape', scrapeEntry.is_locked || false)}
|
||||
@@ -506,7 +779,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 (Safe from auto-overwrite)" : "Unlocked (Auto-overwrite enabled)"}
|
||||
title={scrapeEntry.is_locked ? "Data Locked" : "Unlocked"}
|
||||
>
|
||||
{scrapeEntry.is_locked ? <Lock className="h-3.5 w-3.5" /> : <Unlock className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
@@ -580,9 +853,6 @@ 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>
|
||||
@@ -651,7 +921,6 @@ 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">
|
||||
@@ -679,60 +948,34 @@ 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}
|
||||
@@ -742,24 +985,14 @@ 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}
|
||||
@@ -795,7 +1028,6 @@ 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" />
|
||||
@@ -883,7 +1115,6 @@ 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
|
||||
@@ -891,7 +1122,6 @@ 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">
|
||||
@@ -907,7 +1137,6 @@ 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">
|
||||
@@ -924,11 +1153,9 @@ 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>
|
||||
@@ -946,7 +1173,6 @@ 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>
|
||||
@@ -974,27 +1200,18 @@ 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 } from 'lucide-react'
|
||||
import { X, Bot, Tag, Target, Users, Plus, Trash2, Save, Flag, Check, Ban, ExternalLink } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
interface RoboticsSettingsProps {
|
||||
@@ -9,27 +9,46 @@ 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'>(
|
||||
localStorage.getItem('roboticsSettingsActiveTab') as 'robotics' | 'industries' | 'roles' || 'robotics'
|
||||
const [activeTab, setActiveTab] = useState<'robotics' | 'industries' | 'roles' | 'mistakes'>(
|
||||
localStorage.getItem('roboticsSettingsActiveTab') as 'robotics' | 'industries' | 'roles' | 'mistakes' || '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] = await Promise.all([
|
||||
const [resRobotics, resIndustries, resJobRoles, resMistakes] = 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.");
|
||||
@@ -62,6 +81,19 @@ 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 {
|
||||
@@ -109,6 +141,7 @@ 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}
|
||||
@@ -146,9 +179,20 @@ export function RoboticsSettings({ isOpen, onClose, apiBase }: RoboticsSettingsP
|
||||
)}
|
||||
<div className="flex gap-4 items-start pr-12">
|
||||
<div className="flex-1">
|
||||
<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 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>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
@@ -158,12 +202,47 @@ export function RoboticsSettings({ isOpen, onClose, apiBase }: RoboticsSettingsP
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-slate-600 dark:text-slate-300 italic whitespace-pre-wrap">{ind.description || "No definition"}</p>
|
||||
|
||||
<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>
|
||||
<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></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>
|
||||
{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>}
|
||||
@@ -190,6 +269,86 @@ 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>
|
||||
|
||||
22
company-explorer/frontend/tsconfig.json
Normal file
22
company-explorer/frontend/tsconfig.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"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" }]
|
||||
}
|
||||
10
company-explorer/frontend/tsconfig.node.json
Normal file
10
company-explorer/frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
11
company-explorer/init_schema_update.py
Normal file
11
company-explorer/init_schema_update.py
Normal file
@@ -0,0 +1,11 @@
|
||||
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.")
|
||||
39
company-explorer/upgrade_schema.py
Normal file
39
company-explorer/upgrade_schema.py
Normal file
@@ -0,0 +1,39 @@
|
||||
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()
|
||||
154
company_explorer_connector.py
Normal file
154
company_explorer_connector.py
Normal file
@@ -0,0 +1,154 @@
|
||||
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,3 +18,21 @@ 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"
|
||||
```
|
||||
|
||||
42
connector-superoffice/Dockerfile
Normal file
42
connector-superoffice/Dockerfile
Normal file
@@ -0,0 +1,42 @@
|
||||
# --- 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"]
|
||||
90
connector-superoffice/README.md
Normal file
90
connector-superoffice/README.md
Normal file
@@ -0,0 +1,90 @@
|
||||
# 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
connector-superoffice/__init__.py
Normal file
1
connector-superoffice/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# This file makes the directory a Python package
|
||||
66
connector-superoffice/auth_handler.py
Normal file
66
connector-superoffice/auth_handler.py
Normal file
@@ -0,0 +1,66 @@
|
||||
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
|
||||
152
connector-superoffice/build_matrix.py
Normal file
152
connector-superoffice/build_matrix.py
Normal file
@@ -0,0 +1,152 @@
|
||||
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()
|
||||
45
connector-superoffice/config.py
Normal file
45
connector-superoffice/config.py
Normal file
@@ -0,0 +1,45 @@
|
||||
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))
|
||||
}
|
||||
44
connector-superoffice/debug_auth_manual.py
Normal file
44
connector-superoffice/debug_auth_manual.py
Normal file
@@ -0,0 +1,44 @@
|
||||
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}")
|
||||
89
connector-superoffice/discover_fields.py
Normal file
89
connector-superoffice/discover_fields.py
Normal file
@@ -0,0 +1,89 @@
|
||||
# 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")
|
||||
49
connector-superoffice/final_env_test.py
Normal file
49
connector-superoffice/final_env_test.py
Normal file
@@ -0,0 +1,49 @@
|
||||
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)
|
||||
36
connector-superoffice/generate_auth_url.py
Normal file
36
connector-superoffice/generate_auth_url.py
Normal file
@@ -0,0 +1,36 @@
|
||||
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()
|
||||
|
||||
34
connector-superoffice/generate_keys.py
Normal file
34
connector-superoffice/generate_keys.py
Normal file
@@ -0,0 +1,34 @@
|
||||
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')}")
|
||||
126
connector-superoffice/generate_marketing_copy.py
Normal file
126
connector-superoffice/generate_marketing_copy.py
Normal file
@@ -0,0 +1,126 @@
|
||||
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.")
|
||||
71
connector-superoffice/get_refresh_token.py
Normal file
71
connector-superoffice/get_refresh_token.py
Normal file
@@ -0,0 +1,71 @@
|
||||
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}")
|
||||
129
connector-superoffice/health_check_so.py
Normal file
129
connector-superoffice/health_check_so.py
Normal file
@@ -0,0 +1,129 @@
|
||||
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.")
|
||||
108
connector-superoffice/inject_demo_data.py
Normal file
108
connector-superoffice/inject_demo_data.py
Normal file
@@ -0,0 +1,108 @@
|
||||
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 ---")
|
||||
125
connector-superoffice/inject_demo_data_dynamic.py
Normal file
125
connector-superoffice/inject_demo_data_dynamic.py
Normal file
@@ -0,0 +1,125 @@
|
||||
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 ---")
|
||||
25
connector-superoffice/inspect_contact.py
Normal file
25
connector-superoffice/inspect_contact.py
Normal file
@@ -0,0 +1,25 @@
|
||||
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()
|
||||
8
connector-superoffice/inspect_matrix.py
Normal file
8
connector-superoffice/inspect_matrix.py
Normal file
@@ -0,0 +1,8 @@
|
||||
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))
|
||||
23
connector-superoffice/inspect_person.py
Normal file
23
connector-superoffice/inspect_person.py
Normal file
@@ -0,0 +1,23 @@
|
||||
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)
|
||||
13
connector-superoffice/inspect_simple.py
Normal file
13
connector-superoffice/inspect_simple.py
Normal file
@@ -0,0 +1,13 @@
|
||||
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()
|
||||
42
connector-superoffice/logging_config.py
Normal file
42
connector-superoffice/logging_config.py
Normal file
@@ -0,0 +1,42 @@
|
||||
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
|
||||
167
connector-superoffice/main.py
Normal file
167
connector-superoffice/main.py
Normal file
@@ -0,0 +1,167 @@
|
||||
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()
|
||||
82
connector-superoffice/normalize_persona.py
Normal file
82
connector-superoffice/normalize_persona.py
Normal file
@@ -0,0 +1,82 @@
|
||||
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)}")
|
||||
26
connector-superoffice/parse_ce_openapi.py
Normal file
26
connector-superoffice/parse_ce_openapi.py
Normal file
@@ -0,0 +1,26 @@
|
||||
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()
|
||||
157
connector-superoffice/polling_daemon_final.py
Normal file
157
connector-superoffice/polling_daemon_final.py
Normal file
@@ -0,0 +1,157 @@
|
||||
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.
|
||||
147
connector-superoffice/polling_daemon_sketch.py
Normal file
147
connector-superoffice/polling_daemon_sketch.py
Normal file
@@ -0,0 +1,147 @@
|
||||
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.")
|
||||
28
connector-superoffice/private_key.pem
Normal file
28
connector-superoffice/private_key.pem
Normal file
@@ -0,0 +1,28 @@
|
||||
-----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-----
|
||||
9
connector-superoffice/public_key.pem
Normal file
9
connector-superoffice/public_key.pem
Normal file
@@ -0,0 +1,9 @@
|
||||
-----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-----
|
||||
106
connector-superoffice/queue_manager.py
Normal file
106
connector-superoffice/queue_manager.py
Normal file
@@ -0,0 +1,106 @@
|
||||
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())
|
||||
60
connector-superoffice/register_webhook.py
Normal file
60
connector-superoffice/register_webhook.py
Normal file
@@ -0,0 +1,60 @@
|
||||
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()
|
||||
10
connector-superoffice/requirements.txt
Normal file
10
connector-superoffice/requirements.txt
Normal file
@@ -0,0 +1,10 @@
|
||||
requests
|
||||
python-dotenv
|
||||
cryptography
|
||||
pyjwt
|
||||
xmltodict
|
||||
holidays
|
||||
fastapi
|
||||
uvicorn
|
||||
schedule
|
||||
sqlalchemy
|
||||
6
connector-superoffice/start.sh
Normal file
6
connector-superoffice/start.sh
Normal file
@@ -0,0 +1,6 @@
|
||||
#!/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
|
||||
263
connector-superoffice/superoffice_client.py
Normal file
263
connector-superoffice/superoffice_client.py
Normal file
@@ -0,0 +1,263 @@
|
||||
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)
|
||||
|
||||
28
connector-superoffice/test_client.py
Normal file
28
connector-superoffice/test_client.py
Normal file
@@ -0,0 +1,28 @@
|
||||
# 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 ---")
|
||||
78
connector-superoffice/utils.py
Normal file
78
connector-superoffice/utils.py
Normal file
@@ -0,0 +1,78 @@
|
||||
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}")
|
||||
56
connector-superoffice/webhook_app.py
Normal file
56
connector-superoffice/webhook_app.py
Normal file
@@ -0,0 +1,56 @@
|
||||
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)
|
||||
128
connector-superoffice/webhook_server.py
Normal file
128
connector-superoffice/webhook_server.py
Normal file
@@ -0,0 +1,128 @@
|
||||
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)
|
||||
280
connector-superoffice/worker.py
Normal file
280
connector-superoffice/worker.py
Normal file
@@ -0,0 +1,280 @@
|
||||
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()
|
||||
47
connector-superoffice/xml_to_pem.py
Normal file
47
connector-superoffice/xml_to_pem.py
Normal file
@@ -0,0 +1,47 @@
|
||||
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,6 +183,17 @@
|
||||
</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>
|
||||
@@ -193,6 +204,15 @@
|
||||
<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>
|
||||
|
||||
71
debug_meeting.py
Normal file
71
debug_meeting.py
Normal file
@@ -0,0 +1,71 @@
|
||||
|
||||
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)
|
||||
58
debug_notion_properties.py
Normal file
58
debug_notion_properties.py
Normal file
@@ -0,0 +1,58 @@
|
||||
import requests
|
||||
import json
|
||||
from getpass import getpass
|
||||
|
||||
def inspect_database_properties(db_id: str):
|
||||
"""Liest die Eigenschaften (Spalten) einer Notion-Datenbank aus."""
|
||||
print(f"--- Untersuche Eigenschaften von Notion DB: {db_id} ---")
|
||||
token = getpass("Bitte gib deinen Notion API Key ein (Eingabe wird nicht angezeigt): ")
|
||||
|
||||
if not token:
|
||||
print("\nFehler: Kein Token eingegeben. Abbruch.")
|
||||
return
|
||||
|
||||
print(f"\n... Lese Struktur von Datenbank {db_id}...")
|
||||
|
||||
url = f"https://api.notion.com/v1/databases/{db_id}"
|
||||
headers = {
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Notion-Version": "2022-06-28"
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.get(url, headers=headers)
|
||||
response.raise_for_status()
|
||||
|
||||
database_info = response.json()
|
||||
properties = database_info.get("properties", {})
|
||||
|
||||
print("\n✅ Erfolgreich! Folgende Spalten (Properties) wurden gefunden:")
|
||||
print("--------------------------------------------------")
|
||||
for name, details in properties.items():
|
||||
prop_type = details.get("type")
|
||||
print(f"Spaltenname: '{name}' (Typ: {prop_type})")
|
||||
if prop_type == "relation":
|
||||
relation_details = details.get("relation", {})
|
||||
print(f" -> Verknüpft mit Datenbank-ID: {relation_details.get('database_id')}")
|
||||
# Gib die verfügbaren Optionen für Status- und Select-Felder aus
|
||||
elif prop_type in ["status", "select", "multi_select"]:
|
||||
options = details.get(prop_type, {}).get("options", [])
|
||||
if options:
|
||||
print(f" -> Verfügbare Optionen:")
|
||||
for option in options:
|
||||
print(f" - '{option.get('name')}'")
|
||||
print("--------------------------------------------------")
|
||||
print("Bitte finde den korrekten Namen der Spalte, die zu den Projekten verknüpft ist, und den exakten Namen für den 'In Bearbeitung'-Status.")
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
print(f"\n❌ FEHLER! Konnte die Datenbankstruktur nicht lesen: {e}")
|
||||
if hasattr(e, 'response') and e.response is not None:
|
||||
print(f"HTTP Status Code: {e.response.status_code}")
|
||||
try:
|
||||
print(f"Antwort des Servers: {json.dumps(e.response.json(), indent=2)}")
|
||||
except:
|
||||
print(f"Antwort des Servers: {e.response.text}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
tasks_db_id = "2e888f42-8544-8153-beac-e604719029cf" # Die ID für "Tasks [UT]"
|
||||
inspect_database_properties(tasks_db_id)
|
||||
63
debug_notion_search.py
Normal file
63
debug_notion_search.py
Normal file
@@ -0,0 +1,63 @@
|
||||
import requests
|
||||
import json
|
||||
from getpass import getpass
|
||||
|
||||
def debug_search_databases():
|
||||
print("--- Notion Datenbank Such-Debugger ---")
|
||||
token = getpass("Bitte gib deinen Notion API Key ein (Eingabe wird nicht angezeigt): ")
|
||||
|
||||
if not token:
|
||||
print("\nFehler: Kein Token eingegeben. Abbruch.")
|
||||
return
|
||||
|
||||
print("\n... Sende Suchanfrage an Notion für alle Datenbanken...")
|
||||
|
||||
url = "https://api.notion.com/v1/search"
|
||||
headers = {
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Content-Type": "application/json",
|
||||
"Notion-Version": "2022-06-28"
|
||||
}
|
||||
payload = {
|
||||
"filter": {
|
||||
"value": "database",
|
||||
"property": "object"
|
||||
},
|
||||
"sort": {
|
||||
"direction": "ascending",
|
||||
"timestamp": "last_edited_time"
|
||||
}
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.post(url, headers=headers, json=payload)
|
||||
response.raise_for_status() # Hebt HTTPError für 4xx/5xx Statuscodes hervor
|
||||
|
||||
results = response.json().get("results", [])
|
||||
|
||||
if not results:
|
||||
print("\nKeine Datenbanken gefunden, auf die die Integration Zugriff hat.")
|
||||
print("Bitte stelle sicher, dass die Integration auf Top-Level-Seiten geteilt ist.")
|
||||
return
|
||||
|
||||
print(f"\nGefundene Datenbanken ({len(results)} insgesamt):")
|
||||
print("--------------------------------------------------")
|
||||
for db in results:
|
||||
db_id = db["id"]
|
||||
db_title_parts = db.get("title", [])
|
||||
db_title = db_title_parts[0].get("plain_text", "(Unbenannt)") if db_title_parts else "(Unbenannt)"
|
||||
print(f"Titel: '{db_title}'\n ID: {db_id}\n")
|
||||
print("--------------------------------------------------")
|
||||
print("Bitte überprüfe die genauen Titel und IDs für 'Projects' und 'All Tasks'.")
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
print(f"\n❌ FEHLER! Fehler bei der Suche nach Datenbanken: {e}")
|
||||
if hasattr(e, 'response') and e.response is not None:
|
||||
print(f"HTTP Status Code: {e.response.status_code}")
|
||||
try:
|
||||
print(f"Antwort des Servers: {json.dumps(e.response.json(), indent=2)}")
|
||||
except:
|
||||
print(f"Antwort des Servers: {e.response.text}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
debug_search_databases()
|
||||
70
debug_transcription_raw.py
Normal file
70
debug_transcription_raw.py
Normal file
@@ -0,0 +1,70 @@
|
||||
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()
|
||||
583
dev_session.py
583
dev_session.py
@@ -5,6 +5,8 @@ import re
|
||||
from typing import List, Dict, Optional, Tuple
|
||||
from getpass import getpass
|
||||
from dotenv import load_dotenv
|
||||
import argparse
|
||||
import shutil
|
||||
|
||||
load_dotenv()
|
||||
|
||||
@@ -43,10 +45,11 @@ 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}")
|
||||
try:
|
||||
print(f"Antwort des Servers: {json.dumps(e.response.json(), indent=2)}")
|
||||
except:
|
||||
print(f"Antwort des Servers: {e.response.text}")
|
||||
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}")
|
||||
return None
|
||||
|
||||
def query_notion_database(token: str, database_id: str, filter_payload: Dict = None) -> List[Dict]:
|
||||
@@ -68,10 +71,11 @@ 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}")
|
||||
try:
|
||||
print(f"Antwort des Servers: {json.dumps(e.response.json(), indent=2)}")
|
||||
except:
|
||||
print(f"Antwort des Servers: {e.response.text}")
|
||||
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}")
|
||||
return []
|
||||
|
||||
def get_page_title(page: Dict) -> str:
|
||||
@@ -97,6 +101,101 @@ def get_page_property(page: Dict, prop_name: str, prop_type: str = "rich_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."""
|
||||
prop = page.get("properties", {}).get(prop_name)
|
||||
if not prop or prop.get("type") != "number":
|
||||
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}"
|
||||
|
||||
|
||||
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."""
|
||||
url = f"https://api.notion.com/v1/blocks/{page_id}/children"
|
||||
headers = {
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Content-Type": "application/json",
|
||||
"Notion-Version": "2022-06-28"
|
||||
}
|
||||
full_text = []
|
||||
next_cursor = None
|
||||
has_more = True
|
||||
|
||||
try:
|
||||
while has_more:
|
||||
params = {"page_size": 100} # Max page size
|
||||
if next_cursor:
|
||||
params["start_cursor"] = next_cursor
|
||||
|
||||
response = requests.get(url, headers=headers, params=params)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
blocks = data.get("results", [])
|
||||
|
||||
for block in blocks:
|
||||
block_type = block["type"]
|
||||
text_content = ""
|
||||
|
||||
if block_type in ["paragraph", "heading_1", "heading_2", "heading_3",
|
||||
"bulleted_list_item", "numbered_list_item", "to_do", "callout"]:
|
||||
rich_text_array = block[block_type].get("rich_text", [])
|
||||
for rich_text in rich_text_array:
|
||||
text_content += rich_text.get("plain_text", "")
|
||||
elif block_type == "code":
|
||||
rich_text_array = block["code"].get("rich_text", [])
|
||||
for rich_text in rich_text_array:
|
||||
text_content += rich_text.get("plain_text", "")
|
||||
text_content = f"```\n{text_content}\n```" # Markdown für Codeblöcke
|
||||
elif block_type == "unsupported":
|
||||
text_content = "[Unsupported Block Type]"
|
||||
|
||||
if text_content:
|
||||
# Füge grundlegende Formatierung für bessere Lesbarkeit hinzu
|
||||
if block_type == "heading_1":
|
||||
full_text.append(f"# {text_content}")
|
||||
elif block_type == "heading_2":
|
||||
full_text.append(f"## {text_content}")
|
||||
elif block_type == "heading_3":
|
||||
full_text.append(f"### {text_content}")
|
||||
elif block_type == "bulleted_list_item":
|
||||
full_text.append(f"- {text_content}")
|
||||
elif block_type == "numbered_list_item":
|
||||
full_text.append(f"1. {text_content}") # Einfache Nummerierung
|
||||
elif block_type == "to_do":
|
||||
checked = "[x]" if block["to_do"].get("checked") else "[ ]"
|
||||
full_text.append(f"{checked} {text_content}")
|
||||
elif block_type == "callout":
|
||||
# Extrahiere Icon und Text
|
||||
icon = block["callout"].get("icon", {}).get("emoji", "")
|
||||
full_text.append(f"> {icon} {text_content}")
|
||||
else: # paragraph und andere Standardtexte
|
||||
full_text.append(text_content)
|
||||
|
||||
next_cursor = data.get("next_cursor")
|
||||
has_more = data.get("has_more", False) and next_cursor
|
||||
|
||||
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}")
|
||||
return ""
|
||||
|
||||
|
||||
|
||||
def get_database_status_options(token: str, db_id: str) -> List[str]:
|
||||
"""Ruft die verfügbaren Status-Optionen für eine Datenbank-Eigenschaft ab."""
|
||||
url = f"https://api.notion.com/v1/databases/{db_id}"
|
||||
@@ -115,36 +214,30 @@ 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_status(token: str, task_id: str, status_value: str = "Doing") -> bool:
|
||||
"""Aktualisiert den Status eines Notion-Tasks."""
|
||||
print(f"\n--- Aktualisiere Status von Task '{task_id}' auf '{status_value}'... ---")
|
||||
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}'... ---")
|
||||
url = f"https://api.notion.com/v1/pages/{task_id}"
|
||||
headers = {
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Content-Type": "application/json",
|
||||
"Notion-Version": "2022-06-28"
|
||||
}
|
||||
payload = {
|
||||
"properties": {
|
||||
"Status": {
|
||||
"status": {
|
||||
"name": status_value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
update_payload = {"properties": payload}
|
||||
|
||||
try:
|
||||
response = requests.patch(url, headers=headers, json=payload)
|
||||
response = requests.patch(url, headers=headers, json=update_payload)
|
||||
response.raise_for_status()
|
||||
print(f"✅ Task-Status erfolgreich auf '{status_value}' aktualisiert.")
|
||||
print(f"✅ Task-Eigenschaft erfolgreich aktualisiert.")
|
||||
return True
|
||||
except requests.exceptions.RequestException as e:
|
||||
print(f"❌ FEHLER beim Aktualisieren des Task-Status: {e}")
|
||||
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: {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}")
|
||||
return False
|
||||
|
||||
def create_new_notion_task(token: str, project_id: str, tasks_db_id: str) -> Optional[Dict]:
|
||||
@@ -191,10 +284,11 @@ 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}")
|
||||
try:
|
||||
print(f"Antwort des Servers: {json.dumps(e.response.json(), indent=2)}")
|
||||
except:
|
||||
print(f"Antwort des Servers: {e.response.text}")
|
||||
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}")
|
||||
return None
|
||||
|
||||
def add_comment_to_notion_task(token: str, task_id: str, comment: str) -> bool:
|
||||
@@ -216,10 +310,38 @@ def add_comment_to_notion_task(token: str, task_id: str, comment: str) -> bool:
|
||||
try:
|
||||
response = requests.post(url, headers=headers, json=payload)
|
||||
response.raise_for_status()
|
||||
# Kein print, da dies vom Git-Hook im Hintergrund aufgerufen wird
|
||||
print(f"✅ Kommentar erfolgreich zum Notion-Task hinzugefügt.")
|
||||
return True
|
||||
except requests.exceptions.RequestException:
|
||||
# Fehler unterdrücken, um den Commit-Prozess nicht zu blockieren
|
||||
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}")
|
||||
return False
|
||||
|
||||
def append_blocks_to_notion_page(token: str, page_id: str, blocks: List[Dict]) -> bool:
|
||||
"""Hängt Inhaltsblöcke an eine Notion-Seite an."""
|
||||
url = f"https://api.notion.com/v1/blocks/{page_id}/children"
|
||||
headers = {
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Content-Type": "application/json",
|
||||
"Notion-Version": "2022-06-28"
|
||||
}
|
||||
payload = {"children": blocks}
|
||||
try:
|
||||
response = requests.patch(url, headers=headers, json=payload)
|
||||
response.raise_for_status()
|
||||
print(f"✅ Statusbericht erfolgreich an die Notion-Task-Seite angehängt.")
|
||||
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}")
|
||||
return False
|
||||
|
||||
# --- Session Management ---
|
||||
@@ -227,16 +349,6 @@ def add_comment_to_notion_task(token: str, task_id: str, comment: str) -> bool:
|
||||
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")
|
||||
@@ -330,23 +442,316 @@ def select_task(token: str, project_id: str, tasks_db_id: str) -> Optional[Dict]
|
||||
print("Ungültige Eingabe. Bitte eine Zahl eingeben.")
|
||||
|
||||
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 ---
|
||||
|
||||
def generate_git_summary() -> Tuple[str, str]:
|
||||
"""Generiert eine Zusammenfassung der Git-Änderungen und Commit-Nachrichten seit dem letzten Push zum Main-Branch."""
|
||||
try:
|
||||
# Finde den aktuellen Main-Branch Namen (master oder main)
|
||||
try:
|
||||
main_branch = subprocess.check_output(["git", "rev-parse", "--abbrev-ref", "HEAD"]).decode("utf-8").strip()
|
||||
if main_branch not in ["main", "master"]:
|
||||
# Versuche, den Remote-Tracking-Branch für main/master zu finden
|
||||
result = subprocess.run(["git", "branch", "-r"], capture_output=True, text=True)
|
||||
if "origin/main" in result.stdout:
|
||||
main_branch = "origin/main"
|
||||
elif "origin/master" in result.stdout:
|
||||
main_branch = "origin/master"
|
||||
else:
|
||||
print("Warnung: Konnte keinen 'main' oder 'master' Branch finden. Git-Zusammenfassung wird möglicherweise unvollständig sein.")
|
||||
main_branch = "HEAD~1" # Fallback zum letzten Commit, falls kein Main-Branch gefunden wird
|
||||
except subprocess.CalledProcessError:
|
||||
main_branch = "HEAD~1" # Fallback, falls gar kein Branch gefunden wird
|
||||
|
||||
# Git diff --stat
|
||||
diff_stat_cmd = ["git", "diff", "--stat", f"{main_branch}...HEAD"]
|
||||
diff_stat = subprocess.check_output(diff_stat_cmd).decode("utf-8").strip()
|
||||
|
||||
# Git log --pretty
|
||||
commit_log_cmd = ["git", "log", "--pretty=format:- %s", f"{main_branch}...HEAD"]
|
||||
commit_messages = subprocess.check_output(commit_log_cmd).decode("utf-8").strip()
|
||||
|
||||
return diff_stat, commit_messages
|
||||
except subprocess.CalledProcessError as e:
|
||||
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(
|
||||
status_override: Optional[str],
|
||||
todos_override: Optional[str],
|
||||
git_changes_override: Optional[str],
|
||||
commit_messages_override: Optional[str],
|
||||
summary_override: Optional[str]
|
||||
) -> None:
|
||||
"""
|
||||
Erstellt einen Statusbericht für den Notion-Task, entweder interaktiv oder mit überschriebenen Werten.
|
||||
"""
|
||||
if not os.path.exists(SESSION_FILE_PATH):
|
||||
print("❌ FEHLER: Keine aktive Session gefunden. Kann keinen Statusbericht erstellen.")
|
||||
return
|
||||
|
||||
try:
|
||||
with open(SESSION_FILE_PATH, "r") as f:
|
||||
session_data = json.load(f)
|
||||
task_id = session_data.get("task_id")
|
||||
token = session_data.get("token")
|
||||
|
||||
if not (task_id and token):
|
||||
print("❌ FEHLER: Session-Daten unvollständig. Kann keinen Statusbericht erstellen.")
|
||||
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} ---")
|
||||
|
||||
# Git-Zusammenfassung generieren (immer, wenn nicht explizit überschrieben)
|
||||
actual_git_changes = git_changes_override
|
||||
actual_commit_messages = commit_messages_override
|
||||
if not git_changes_override or not commit_messages_override:
|
||||
print("Generiere Git-Zusammenfassung...")
|
||||
diff_stat, commit_log = generate_git_summary()
|
||||
if not git_changes_override:
|
||||
actual_git_changes = diff_stat
|
||||
if not commit_messages_override:
|
||||
actual_commit_messages = commit_log
|
||||
|
||||
# Status abfragen oder übernehmen
|
||||
actual_status = status_override
|
||||
if not actual_status:
|
||||
tasks_db_id = find_database_by_title(token, "Tasks [UT]")
|
||||
if tasks_db_id:
|
||||
status_options = get_database_status_options(token, tasks_db_id)
|
||||
if status_options:
|
||||
print("\nBitte wähle den neuen Status des Tasks:")
|
||||
for i, option in enumerate(status_options):
|
||||
print(f"[{i+1}] {option}")
|
||||
while True:
|
||||
try:
|
||||
choice = int(input("Wähle eine Nummer: "))
|
||||
if 1 <= choice <= len(status_options):
|
||||
actual_status = status_options[choice - 1]
|
||||
break
|
||||
else:
|
||||
print("Ungültige Auswahl.")
|
||||
except ValueError:
|
||||
print("Ungültige Eingabe. Bitte eine Zahl eingeben.")
|
||||
else:
|
||||
print("❌ FEHLER: Konnte Status-Optionen nicht abrufen. Abbruch des Berichts.")
|
||||
return
|
||||
|
||||
if not actual_status:
|
||||
print("❌ FEHLER: Kein Status festgelegt. Abbruch des Berichts.")
|
||||
return
|
||||
|
||||
# Detaillierte Zusammenfassung abfragen oder übernehmen
|
||||
actual_summary = summary_override
|
||||
if not actual_summary:
|
||||
print("\nBitte gib eine Zusammenfassung der Arbeit ein (was wurde getan, Ergebnisse, Probleme etc.).")
|
||||
user_summary_lines = []
|
||||
while True:
|
||||
line = input()
|
||||
if not line:
|
||||
break
|
||||
user_summary_lines.append(line)
|
||||
actual_summary = "\n".join(user_summary_lines)
|
||||
|
||||
# To-Dos abfragen oder übernehmen
|
||||
actual_todos = todos_override
|
||||
if not actual_todos:
|
||||
user_todos = input("\nGibt es offene To-Dos oder nächste Schritte? (Leer lassen zum Überspringen): ")
|
||||
actual_todos = user_todos.strip()
|
||||
|
||||
# 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:
|
||||
report_lines.append("\nArbeitszusammenfassung:")
|
||||
report_lines.append(actual_summary)
|
||||
|
||||
if actual_git_changes or actual_commit_messages:
|
||||
report_lines.append("\nTechnische Änderungen (Git):")
|
||||
if actual_git_changes:
|
||||
report_lines.append(f"```{actual_git_changes}```")
|
||||
if actual_commit_messages:
|
||||
report_lines.append("\nCommit Nachrichten:")
|
||||
report_lines.append(f"```{actual_commit_messages}```")
|
||||
|
||||
if actual_todos:
|
||||
report_lines.append("\nOffene To-Dos / Nächste Schritte:")
|
||||
for todo_item in actual_todos.split('\n'):
|
||||
report_lines.append(f"- {todo_item.strip()}")
|
||||
|
||||
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')
|
||||
notion_blocks = [
|
||||
{
|
||||
"object": "block",
|
||||
"type": "heading_2",
|
||||
"heading_2": {
|
||||
"rich_text": [{"type": "text", "text": {"content": f"🤖 Status-Update ({timestamp} Berlin Time)"}}]
|
||||
}
|
||||
},
|
||||
{
|
||||
"object": "block",
|
||||
"type": "code",
|
||||
"code": {
|
||||
"rich_text": [{"type": "text", "text": {"content": report_content}}],
|
||||
"language": "yaml"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
# 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}")
|
||||
|
||||
except (FileNotFoundError, json.JSONDecodeError) as e:
|
||||
print(f"❌ FEHLER beim Lesen der Session-Informationen für Statusbericht: {e}")
|
||||
except Exception as e:
|
||||
print(f"❌ Unerwarteter Fehler beim Erstellen des Statusberichts: {e}")
|
||||
|
||||
|
||||
# --- Context Generation ---
|
||||
|
||||
def generate_cli_context(project_title: str, task_title: str, task_id: str, readme_path: Optional[str]) -> str:
|
||||
|
||||
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:
|
||||
"""Erstellt den reinen Kontext-String für die Gemini CLI."""
|
||||
|
||||
# Fallback, falls kein Pfad in Notion gesetzt ist
|
||||
if not readme_path:
|
||||
readme_path = "readme.md"
|
||||
|
||||
description_part = ""
|
||||
if task_description:
|
||||
description_part = (
|
||||
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\n"
|
||||
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"
|
||||
f"- Der Haupt-Code befindet sich wahrscheinlich in: @dev_session.py\n\n"
|
||||
f"Mein Ziel ist es, den Task '{task_title}' umzusetzen. Alle Commits für diesen Task sollen die Kennung `[{task_id.split('-')[0]}]` enthalten."
|
||||
f"- Die spezifische Dokumentation für dieses Modul: @{readme_path}\n\n"
|
||||
f"Mein Ziel ist es, den Task '{task_title}' umzusetzen. Alle Commits für diesen Task sollen die Kennung `[{task_id.split('-')[0]}]` enthalten.\n\n"
|
||||
"**WICHTIGER BEFEHL:** Bevor du mit der Implementierung oder einer Code-Änderung beginnst, fasse die Aufgabe in deinen eigenen Worten zusammen, erstelle einen detaillierten, schrittweisen Plan zur Lösung und **warte auf meine explizite Bestätigung**, bevor du den ersten Schritt ausführst."
|
||||
)
|
||||
return context
|
||||
|
||||
@@ -354,46 +759,20 @@ 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.
|
||||
|
||||
import shutil
|
||||
import argparse
|
||||
|
||||
# --- Session Management ---
|
||||
|
||||
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."""
|
||||
"""Speichert die Task-ID, den Token und den Startzeitpunkt für den Git-Hook."""
|
||||
os.makedirs(SESSION_DIR, exist_ok=True)
|
||||
session_data = {
|
||||
"task_id": task_id,
|
||||
"token": token
|
||||
"token": token,
|
||||
"session_start_time": datetime.now().isoformat()
|
||||
}
|
||||
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")
|
||||
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}")
|
||||
pass
|
||||
|
||||
def cleanup_session():
|
||||
"""Bereinigt die Session-Datei und den Git-Hook."""
|
||||
@@ -426,7 +805,8 @@ def complete_session():
|
||||
status_options = get_database_status_options(token, tasks_db_id)
|
||||
if status_options:
|
||||
done_status = status_options[-1]
|
||||
update_notion_task_status(token, task_id, done_status)
|
||||
status_payload = {"Status": {"status": {"name": done_status}}}
|
||||
update_notion_task_property(token, task_id, status_payload)
|
||||
|
||||
except (FileNotFoundError, json.JSONDecodeError):
|
||||
print("Fehler beim Lesen der Session-Informationen.")
|
||||
@@ -480,6 +860,12 @@ 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
|
||||
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)
|
||||
|
||||
@@ -492,7 +878,8 @@ def start_interactive_session():
|
||||
|
||||
suggested_branch_name = f"feature/task-{task_id.split('-')[0]}-{title_slug}"
|
||||
|
||||
status_updated = update_notion_task_status(token, task_id, "Doing")
|
||||
status_payload = {"Status": {"status": {"name": "Doing"}}}
|
||||
status_updated = update_notion_task_property(token, task_id, status_payload)
|
||||
if not status_updated:
|
||||
print("Warnung: Notion-Task-Status konnte nicht aktualisiert werden.")
|
||||
|
||||
@@ -505,7 +892,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)
|
||||
cli_context = generate_cli_context(project_title, task_title, task_id, readme_path, task_description, total_duration_decimal)
|
||||
print("\n---GEMINI_CLI_CONTEXT_START---")
|
||||
print(cli_context)
|
||||
print("---GEMINI_CLI_CONTEXT_END---")
|
||||
@@ -517,13 +904,45 @@ 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.")
|
||||
parser.add_argument("--report-status", action="store_true", help="Erstellt einen Statusbericht für den Notion-Task.")
|
||||
parser.add_argument("--status", type=str, help="Status, der im Notion-Task gesetzt werden soll (z.B. 'In Bearbeitung', 'Bereit für Review').")
|
||||
parser.add_argument("--todos", type=str, help="Eine durch '\n' getrennte Liste offener To-Dos.")
|
||||
parser.add_argument("--git-changes", type=str, help="Zusammenfassung der Git-Änderungen (git diff --stat).")
|
||||
parser.add_argument("--commit-messages", type=str, help="Eine durch '\n' getrennte Liste der Commit-Nachrichten.")
|
||||
parser.add_argument("--summary", type=str, help="Eine detaillierte textuelle Zusammenfassung der erledigten Arbeit.")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.done:
|
||||
complete_session()
|
||||
elif args.add_comment:
|
||||
if not os.path.exists(SESSION_FILE_PATH):
|
||||
print("❌ FEHLER: Keine aktive Session gefunden. Kann keinen Kommentar hinzufügen.")
|
||||
return
|
||||
try:
|
||||
with open(SESSION_FILE_PATH, "r") as f:
|
||||
session_data = json.load(f)
|
||||
task_id = session_data.get("task_id")
|
||||
token = session_data.get("token")
|
||||
|
||||
if task_id and token:
|
||||
add_comment_to_notion_task(token, task_id, args.add_comment)
|
||||
else:
|
||||
print("❌ FEHLER: Session-Daten unvollständig. Kann keinen Kommentar hinzufügen.")
|
||||
except (FileNotFoundError, json.JSONDecodeError):
|
||||
print("❌ FEHLER: Fehler beim Lesen der Session-Informationen. Kann keinen Kommentar hinzufügen.")
|
||||
elif args.report_status:
|
||||
report_status_to_notion(
|
||||
status_override=args.status,
|
||||
todos_override=args.todos,
|
||||
git_changes_override=args.git_changes,
|
||||
commit_messages_override=args.commit_messages,
|
||||
summary_override=args.summary
|
||||
)
|
||||
else:
|
||||
start_interactive_session()
|
||||
|
||||
|
||||
@@ -1,228 +1,285 @@
|
||||
# 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:
|
||||
# --- CENTRAL GATEWAY (Reverse Proxy with Auth) ---
|
||||
proxy:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.proxy
|
||||
container_name: gemini-gateway
|
||||
# --- GATEKEEPER (NGINX) ---
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
container_name: gateway_proxy
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8090:80"
|
||||
- "8090:80" # Synology Reverse Proxy should point to THIS port (8090)
|
||||
volumes:
|
||||
- ./nginx-proxy.conf:/etc/nginx/nginx.conf
|
||||
# Use clean config to avoid caching issues
|
||||
- ./nginx-proxy-clean.conf:/etc/nginx/nginx.conf:ro
|
||||
- ./.htpasswd:/etc/nginx/.htpasswd:ro
|
||||
depends_on:
|
||||
- dashboard
|
||||
- b2b-app
|
||||
- market-frontend
|
||||
- company-explorer
|
||||
- competitor-analysis
|
||||
- content-app
|
||||
dashboard:
|
||||
condition: service_started
|
||||
company-explorer:
|
||||
condition: service_healthy
|
||||
connector-superoffice:
|
||||
condition: service_healthy
|
||||
|
||||
# ... [existing services] ...
|
||||
|
||||
content-app:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: content-engine/Dockerfile
|
||||
container_name: content-app
|
||||
# --- DASHBOARD ---
|
||||
dashboard:
|
||||
image: nginx:alpine
|
||||
container_name: dashboard
|
||||
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
|
||||
- ./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
|
||||
- ./dashboard:/usr/share/nginx/html:ro
|
||||
|
||||
|
||||
# --- DASHBOARD (Landing Page) ---
|
||||
dashboard:
|
||||
build:
|
||||
context: ./dashboard
|
||||
dockerfile: Dockerfile.dashboard
|
||||
container_name: gemini-dashboard
|
||||
restart: unless-stopped
|
||||
|
||||
# --- COMPANY EXPLORER (Robotics Edition) ---
|
||||
# --- APPS ---
|
||||
company-explorer:
|
||||
build:
|
||||
context: ./company-explorer
|
||||
dockerfile: Dockerfile
|
||||
container_name: company-explorer
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
# Sideloading: Source Code (Hot Reload)
|
||||
- ./company-explorer:/app
|
||||
# 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
|
||||
environment:
|
||||
- PYTHONUNBUFFERED=1
|
||||
# Port 8000 is internal only
|
||||
|
||||
# --- 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
|
||||
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
|
||||
- "8000:8000"
|
||||
environment:
|
||||
- PYTHONUNBUFFERED=1
|
||||
- DB_PATH=/app/b2b_projects.db
|
||||
# Port 3002 is internal only
|
||||
|
||||
# --- MARKET INTELLIGENCE BACKEND ---
|
||||
market-backend:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.market
|
||||
container_name: market-backend
|
||||
restart: unless-stopped
|
||||
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: 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
|
||||
- ./company-explorer:/app
|
||||
# Mount named volume to a DIRECTORY, not a file
|
||||
- explorer_db_data:/data
|
||||
- ./Log_from_docker:/app/logs_debug
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8000/docs"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
|
||||
# --- MARKET INTELLIGENCE FRONTEND ---
|
||||
market-frontend:
|
||||
connector-superoffice:
|
||||
build:
|
||||
context: ./general-market-intelligence
|
||||
context: ./connector-superoffice
|
||||
dockerfile: Dockerfile
|
||||
container_name: market-frontend
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- market-backend
|
||||
# Port 80 is internal only
|
||||
|
||||
gtm-app:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: gtm-architect/Dockerfile
|
||||
container_name: gtm-app
|
||||
container_name: connector-superoffice
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8003:8000" # Expose internal 8000 to host 8003
|
||||
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
|
||||
- ./connector-superoffice:/app
|
||||
# Mount named volume to a DIRECTORY matching the Python code's expectation
|
||||
- connector_db_data:/data
|
||||
environment:
|
||||
- PYTHONUNBUFFERED=1
|
||||
- DB_PATH=/app/gtm_projects.db
|
||||
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
|
||||
|
||||
# --- 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
|
||||
# --- DISABLED SERVICES (Commented out but preserved) ---
|
||||
|
||||
# heatmap-backend:
|
||||
# build: ./heatmap-tool/backend
|
||||
# container_name: heatmap-backend
|
||||
# restart: unless-stopped
|
||||
# volumes:
|
||||
# - ./heatmap-tool/backend:/app
|
||||
|
||||
# --- 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
|
||||
# heatmap-frontend:
|
||||
# build: ./heatmap-tool/frontend
|
||||
# container_name: heatmap-frontend
|
||||
# restart: unless-stopped
|
||||
# volumes:
|
||||
# - ./heatmap-tool/frontend:/app
|
||||
# depends_on:
|
||||
# - heatmap-backend
|
||||
|
||||
# --- 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
|
||||
# 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"
|
||||
|
||||
# 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}"
|
||||
|
||||
# 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}"
|
||||
|
||||
# market-frontend:
|
||||
# build:
|
||||
# context: ./general-market-intelligence
|
||||
# dockerfile: Dockerfile
|
||||
# container_name: market-frontend
|
||||
# restart: unless-stopped
|
||||
# depends_on:
|
||||
# - market-backend
|
||||
|
||||
# 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: {}
|
||||
@@ -11,6 +11,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Python-Bibliotheken für dev_session.py installieren
|
||||
RUN pip3 install requests python-dotenv
|
||||
|
||||
# Installieren der von Ihnen gefundenen, korrekten Gemini CLI global
|
||||
RUN npm install -g @google/gemini-cli
|
||||
|
||||
|
||||
BIN
docs/KAB PLZ Online Kontakte.xlsx
Normal file
BIN
docs/KAB PLZ Online Kontakte.xlsx
Normal file
Binary file not shown.
BIN
docs/KAB PLZ Tradingtwins Kontakte.xlsx
Normal file
BIN
docs/KAB PLZ Tradingtwins Kontakte.xlsx
Normal file
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user