191 Commits

Author SHA1 Message Date
35c30bc39a [30388f42] Recovery & Stabilization: Restored productive core stack, implemented Docker Volumes for DB persistence, and fixed frontend build issues. 2026-03-07 08:06:50 +00:00
4224206b24 fix: Docker-Compose FINAL FINAL FIX: YAML-Reparatur und Konsolidierung -- Manuelle Bereinigung nach Desaster 2026-03-06 22:15:59 +01:00
a39efeb5b1 [2ff88f42] Full End-to-End integration: Webhooks, Auto-Enrichment, Notion-Sync, UI updates and new Connector Architecture 2026-02-19 16:13:16 +00:00
Moltbot-Jarvis
fcedd25372 docs: Add scale-up plan for SuperOffice batch processing 2026-02-19 08:14:44 +00:00
Moltbot-Jarvis
b25d09a65b fix: Emergency restoration of complete MIGRATION_PLAN 2026-02-18 19:50:39 +00:00
Moltbot-Jarvis
18f74f8903 docs: finalized Task 2 (Excel Importer) with detailed logic and PLZ safety 2026-02-18 19:47:56 +00:00
62aed99503 [30b88f42] Läuft wieder
Läuft wieder
2026-02-18 14:35:21 +00:00
Moltbot-Jarvis
bd072c133a docs: RESTORED complete MIGRATION_PLAN with full logic and tasks 2026-02-18 13:07:32 +00:00
Moltbot-Jarvis
ceb7b4ba32 docs: highly detailed logic for database fields and CLI tasks 2026-02-18 13:04:17 +00:00
Moltbot-Jarvis
c6d0f9b324 docs: force sync of migration plan in root and company-explorer 2026-02-18 12:35:12 +00:00
Moltbot-Jarvis
84283c706d docs: finalized MIGRATION_PLAN with DB schema and CLI tasks 2026-02-18 12:32:20 +00:00
Moltbot-Jarvis
42a9e571c8 refactor: Remove root TASKS.md, moving tasks to project-specific docs 2026-02-18 12:00:44 +00:00
Moltbot-Jarvis
62768b962c docs: Define first task for CLI execution (Excel Import) 2026-02-18 11:55:55 +00:00
Moltbot-Jarvis
067e672bff fix: Restore cron script update_projects_cache 2026-02-18 11:45:44 +00:00
Moltbot-Jarvis
c1b9308c05 refactor(frontend): Relocate Pains/Gains to Settings, add CRM view to Inspector 2026-02-18 11:33:39 +00:00
Moltbot-Jarvis
cde3de4a88 feat: Add Notion cache script and move to scripts folder 2026-02-18 11:21:46 +00:00
Moltbot-Jarvis
1374ade35b fix(frontend): Remove unused Save icon import in Inspector 2026-02-18 11:06:49 +00:00
Moltbot-Jarvis
50dbe10249 fix: Repair Inspector TypeScript errors (props and unused vars) 2026-02-18 10:37:47 +00:00
Moltbot-Jarvis
317473b326 fix: Restore Frontend Inspector code (full version) 2026-02-18 10:26:07 +00:00
Moltbot-Jarvis
75deb72525 feat: Frontend Inspector Upgrade (CRM & Strategy View) 2026-02-18 10:22:20 +00:00
Moltbot-Jarvis
fe8e1aad14 fix: Correct DB path in migration script 2026-02-18 10:02:20 +00:00
Moltbot-Jarvis
5ec9c05151 fix: Restore missing scripts from local backup 2026-02-18 09:42:34 +00:00
Moltbot-Jarvis
ec9b56d45b feat: Restore CE backend changes and scripts 2026-02-18 09:36:43 +00:00
Moltbot-Jarvis
5081e092a2 Merge remote changes (resolved audio conflict) 2026-02-18 09:27:30 +00:00
Moltbot-Jarvis
bdbfaa470e feat: Documentation and Tool Config Update 2026-02-18 09:12:04 +00:00
Moltbot-Jarvis
f3cf366a6a feat: Enhanced CE schema and Notion sync (Pains/Gains) 2026-02-17 19:44:42 +00:00
ce5e845a2e [30a88f42] [30a88f42] feat: Centralize API key handling and fix product enrichment\n\n- Centralized GEMINI_API_KEY loading from project root .env file.\n- Corrected product enrichment: added /enrich-product endpoint in Node.js, implemented mode in Python backend, and updated frontend to use new API for product analysis.\n- Fixed to pass product data correctly for enrichment.\n- Updated documentation ().
[30a88f42] feat: Centralize API key handling and fix product enrichment\n\n- Centralized GEMINI_API_KEY loading from project root .env file.\n- Corrected product enrichment: added /enrich-product endpoint in Node.js, implemented  mode in Python backend, and updated frontend to use new API for product analysis.\n- Fixed  to pass product data correctly for enrichment.\n- Updated documentation ().
2026-02-17 07:16:13 +00:00
d38d099393 [30a88f42] feat: Centralize API key handling and fix product enrichment\n\n- Centralized GEMINI_API_KEY loading from project root .env file.\n- Corrected product enrichment: added /enrich-product endpoint in Node.js, implemented mode in Python backend, and updated frontend to use new API for product analysis.\n- Fixed to pass product data correctly for enrichment.\n- Updated documentation (). 2026-02-17 07:14:51 +00:00
e0de9b3270 [30988f42] Ergebnis: M4A & Transcription Upgrade [30988f42]
Ergebnis: M4A & Transcription Upgrade [30988f42]
2026-02-16 14:37:29 +00:00
2265073c4c [30988f42] feat(transcription-tool): stabilize transcription with plain text parsing and add retry feature 2026-02-16 14:35:33 +00:00
Moltbot-Jarvis
ca32715185 feat(so-sync): final round-trip tools and infrastructure fixes 2026-02-16 13:58:37 +00:00
Moltbot-Jarvis
073d3460fe feat(so-sync): bidirectional round-trip for company data established [lessons-learned] 2026-02-16 13:58:37 +00:00
337b804967 [30988f42] I have successfully implemented M4A audio support for the Meeting Assistant (Transcription Tool).
I have successfully implemented M4A audio support for the Meeting Assistant (Transcription Tool).
2026-02-16 11:58:22 +00:00
e43fc93703 [30988f42] chore(dev-session): Update session info and add frontend .gitignore 2026-02-16 11:58:02 +00:00
59d11fdf26 [30988f42] feat(transcription-tool): Add M4A audio support by re-encoding to MP3 2026-02-16 11:57:28 +00:00
f0b7b1ef34 Merge remote-tracking branch 'origin/main' 2026-02-16 10:44:59 +00:00
7a53904952 [2ff88f42] Zusammenfassung der erfolgreichen Ausführung:
Zusammenfassung der erfolgreichen Ausführung:
2026-02-16 10:34:55 +00:00
f861ca030c feat(superoffice): Restore full main.py functionality and add health check [2ff88f42]
This commit restores the full functionality of the  script within the  module. Several  instances were resolved by implementing missing methods in  (e.g., , , , , , , ) and correcting argument passing.

The data extraction logic in  was adjusted to correctly parse the structure returned by the SuperOffice API (e.g.,  and ).

A dedicated SuperOffice API health check script () was introduced to quickly verify basic API connectivity (reading contacts and persons). This script confirmed that read operations for  and  entities are functional, while the  endpoint continues to return a , which is now handled gracefully as a warning, allowing other tests to proceed.

The  now successfully executes all SuperOffice-specific POC steps, including creating contacts, persons, sales, projects, and updating UDFs.
2026-02-16 10:33:19 +00:00
8289cca372 Dateien nach "docs" hochladen 2026-02-15 20:07:10 +00:00
a2f5df33c0 Direktive_Zusammenarbeit.md hinzugefügt 2026-02-15 20:05:22 +00:00
5d90832769 MOLTBOT_SYNOLOGY_GUIDE.md aktualisiert
Ergänzung zur neuen installation
2026-02-14 12:07:23 +00:00
Jarvis
3d45e03739 docs: Add ID mappings for Roles and Verticals (DEV) 2026-02-12 14:24:57 +00:00
Jarvis
5040b0b52e docs: Add DEV/PROD mapping for UDF ProgIds 2026-02-12 14:23:45 +00:00
Jarvis
5a19a9c85f feat: Build complete POC for Butler model (client, matrix, daemon) 2026-02-12 14:18:57 +00:00
Jarvis
5b1939c881 docs: Create comprehensive documentation for the Butler model architecture 2026-02-12 14:18:57 +00:00
15db0cfe7f [2ff88f42] IDs für Felder herausfinden.
IDs für Felder herausfinden.
2026-02-12 11:08:17 +00:00
07ca427fc8 [2fd88f42] 1. Kartendarstellung (Neutralisierung):
1. Kartendarstellung (Neutralisierung):
       * Die TileLayer-URL in heatmap-tool/frontend/src/components/MapDisplay.tsx wurde auf eine neutrale CARTO light_all-Kachelansicht umgestellt und die Quellenangabe entsprechend angepasst.
2026-02-11 15:36:06 +00:00
96875247dd [2ff88f42] Wir haben versucht, auf eine neu erstellte Zusatz-Tabelle (ExtraTableId 1, auch y_marketing_copy oder Marketing Ansprache genannt) in SuperOffice zuzugreifen, um dort Marketing-Texte zu speichern. Dabei sind wir auf eine
Wir haben versucht, auf eine neu erstellte Zusatz-Tabelle (ExtraTableId 1, auch y_marketing_copy oder Marketing Ansprache genannt) in SuperOffice zuzugreifen, um dort Marketing-Texte zu speichern. Dabei sind wir auf eine
  hartnäckige Blockade gestoßen:
2026-02-11 13:36:57 +00:00
88f9b8efb5 [2ff88f42] Update README.md with custom table access lessons learned and revert unrequested files. 2026-02-11 13:34:35 +00:00
581ec01775 [2fd88f42] ✦ Das Heatmap-Tool zeigte einen Fehler beim Laden der Frontend-Anwendung, da die Datei /src/main.tsx nicht gefunden werden konnte.
✦ Das Heatmap-Tool zeigte einen Fehler beim Laden der Frontend-Anwendung, da die Datei /src/main.tsx nicht gefunden werden konnte.
2026-02-11 11:32:16 +00:00
d484ab08a0 [30388f42] Einrichtung der Entwicklungsumgebung auf der neuen VM docker1 (Ubuntu 24.04). Fehlerbehebung bei Docker-DNS-Problemen (systemd-resolved). Installation und Konfiguration einer frischen Gitea-Instanz via Docker Compose (manuelle Web-Installation für Konsistenz). Bereitstellung der Gemini CLI-Umgebung (Docker-basiert mit startgemini.sh Workflow). Dokumentation der Schritte in umzug.md erstellt.
Einrichtung der Entwicklungsumgebung auf der neuen VM docker1 (Ubuntu 24.04). Fehlerbehebung bei Docker-DNS-Problemen (systemd-resolved). Installation und Konfiguration einer frischen Gitea-Instanz via Docker Compose (manuelle Web-Installation für Konsistenz). Bereitstellung der Gemini CLI-Umgebung (Docker-basiert mit startgemini.sh Workflow). Dokumentation der Schritte in umzug.md erstellt.
2026-02-10 15:38:02 +00:00
05c377b40e [30388f42] Doc: Migration steps for docker1 setup (Gitea & Gemini CLI) 2026-02-10 15:36:59 +00:00
68e0d31ed4 [2ff88f42] 1. Professionalisierung & Code-Härtung ("Enterprise Ready")
1. Professionalisierung & Code-Härtung ("Enterprise Ready")
   * Zentrales Logging: Wir haben eine logging_config.py implementiert. Der Connector schreibt jetzt professionelle, rotierende Log-Dateien (mit Zeitstempel und Modulnamen), statt nur
     print-Ausgaben in die Konsole zu werfen. Das ist essenziell für den stabilen Betrieb auf der VM.
   * Decoupling (Entkopplung): Alle festkodierten Werte (wie die mühsam ermittelten MA-Status-IDs 11–18 oder die UDF-ProgIds) wurden aus dem Logik-Code entfernt und in eine zentrale
     config.py ausgelagert. Diese kann nun einfach über Umgebungsvariablen für die Produktion (Prod) angepasst werden.
   * Refactoring: Sämtliche Module (SuperOfficeClient, AuthHandler, ExplorerClient) wurden bereinigt, redundante Kommentare entfernt und auf die neue Konfigurationsstruktur umgestellt.
2026-02-10 12:45:43 +00:00
92fcfd747c [2ff88f42] refactor(connector-superoffice): finalize production readiness cleanup
- Integrated centralized logging system in all modules.
- Extracted all IDs and ProgIds into a separate .
- Refactored  and  for cleaner dependency management.
- Included updated discovery and inspection utilities.
- Verified end-to-end workflow stability.
2026-02-10 12:43:26 +00:00
f2ddd7ac09 [2ff88f42] 1. Umfassende Entitäten-Erstellung: Wir haben erfolgreich Methoden implementiert, um die Kern-SuperOffice-Entitäten per API zu erstellen:
1. Umfassende Entitäten-Erstellung: Wir haben erfolgreich Methoden implementiert, um die Kern-SuperOffice-Entitäten per API zu erstellen:
       * Firmen (`Contact`)
       * Personen (`Person`)
       * Verkäufe (`Sale`) (entspricht D365 Opportunity)
       * Projekte (`Project`) (entspricht D365 Campaign), inklusive der Verknüpfung von Personen als Projektmitglieder.
   2. Robuste UDF-Aktualisierung: Wir haben eine generische und fehlertolerante Methode (update_entity_udfs) implementiert, die benutzerdefinierte Felder (UDFs) für sowohl Contact- als
      auch Person-Entitäten aktualisieren kann. Diese Methode ruft zuerst das bestehende Objekt ab, um die Konsistenz zu gewährleisten.
   3. UDF-ID-Discovery: Durch eine iterative Inspektionsmethode haben wir erfolgreich alle internen SuperOffice-IDs für die Listenwerte deines MA Status-Feldes (Ready_to_Send, Sent_Week1,
      Sent_Week2, Bounced, Soft_Denied, Interested, Out_of_Office, Unsubscribed) ermittelt und im Connector hinterlegt.
   4. Vollständiger End-to-End Test-Workflow: Unser main.py-Skript demonstriert nun einen kompletten Ablauf, der alle diese Schritte von der Erstellung bis zur UDF-Aktualisierung umfasst.
   5. Architekturplan für Marketing Automation: Wir haben einen detaillierten "Butler-Service"-Architekturplan für die Marketing-Automatisierung entworfen, der den Connector für die
      Textgenerierung und SuperOffice für den Versand und das Status-Management nutzt.
   6. Identifikation des E-Mail-Blockers: Wir haben festgestellt, dass das Erstellen von E-Mail-Aktivitäten per API in deiner aktuellen SuperOffice-Entwicklungsumgebung aufgrund fehlender
      Lizenzierung/Konfiguration des E-Mail-Moduls blockiert ist (500 Internal Server Error).
   7. Feiertagslogik ergänzt
2026-02-10 11:58:49 +00:00
2f17a3bec1 [2ff88f42] feat(connector-superoffice): Implement Company Explorer sync and Holiday logic
- **Company Explorer Sync**: Added `explorer_client.py` and integrated Step 9 in `main.py` for automated data transfer to the intelligence engine.
- **Holiday Logic**: Implemented `BusinessCalendar` in `utils.py` using the `holidays` library to automatically detect weekends and Bavarian holidays, ensuring professional timing for automated outreaches.
- **API Discovery**: Created `parse_ce_openapi.py` to facilitate technical field mapping through live OpenAPI analysis.
- **Project Stability**: Refined error handling and logging for a smooth end-to-end workflow.
2026-02-10 11:56:44 +00:00
312ce8aa1d [2ff88f42] 1. Umfassende Entitäten-Erstellung: Wir haben erfolgreich Methoden implementiert, um die Kern-SuperOffice-Entitäten per API zu erstellen:
1. Umfassende Entitäten-Erstellung: Wir haben erfolgreich Methoden implementiert, um die Kern-SuperOffice-Entitäten per API zu erstellen:
       * Firmen (`Contact`)
       * Personen (`Person`)
       * Verkäufe (`Sale`) (entspricht D365 Opportunity)
       * Projekte (`Project`) (entspricht D365 Campaign), inklusive der Verknüpfung von Personen als Projektmitglieder.
   2. Robuste UDF-Aktualisierung: Wir haben eine generische und fehlertolerante Methode (update_entity_udfs) implementiert, die benutzerdefinierte Felder (UDFs) für sowohl Contact- als
      auch Person-Entitäten aktualisieren kann. Diese Methode ruft zuerst das bestehende Objekt ab, um die Konsistenz zu gewährleisten.
   3. UDF-ID-Discovery: Durch eine iterative Inspektionsmethode haben wir erfolgreich alle internen SuperOffice-IDs für die Listenwerte deines MA Status-Feldes (Ready_to_Send, Sent_Week1,
      Sent_Week2, Bounced, Soft_Denied, Interested, Out_of_Office, Unsubscribed) ermittelt und im Connector hinterlegt.
   4. Vollständiger End-to-End Test-Workflow: Unser main.py-Skript demonstriert nun einen kompletten Ablauf, der alle diese Schritte von der Erstellung bis zur UDF-Aktualisierung umfasst.
   5. Architekturplan für Marketing Automation: Wir haben einen detaillierten "Butler-Service"-Architekturplan für die Marketing-Automatisierung entworfen, der den Connector für die
      Textgenerierung und SuperOffice für den Versand und das Status-Management nutzt.
   6. Identifikation des E-Mail-Blockers: Wir haben festgestellt, dass das Erstellen von E-Mail-Aktivitäten per API in deiner aktuellen SuperOffice-Entwicklungsumgebung aufgrund fehlender
      Lizenzierung/Konfiguration des E-Mail-Moduls blockiert ist (500 Internal Server Error).
2026-02-10 11:06:32 +00:00
23559cff08 [2ff88f42] 1. Strategische Klärung & Dokumentation: Wir haben die Rolle der SuperOffice-Entitäten Sale (als D365 Opportunity) und Project (als D365 Campaign) in unserem Integrationsplan geklärt
1. Strategische Klärung & Dokumentation: Wir haben die Rolle der SuperOffice-Entitäten Sale (als D365 Opportunity) und Project (als D365 Campaign) in unserem Integrationsplan geklärt
      und dies in der SUPEROFFICE_INTEGRATION_PLAN.md dokumentiert.
   2. `Sale` (Verkauf/Opportunity) Implementierung:
       * Ich habe die Methode create_sale in superoffice_client.py implementiert, um Verkaufschancen anzulegen.
       * Wir haben diese Funktion erfolgreich getestet und dabei gelernt, dass das Titelfeld in SuperOffice Heading statt Title heißt. Die Implementierung und das Logging wurden
         entsprechend korrigiert.
   3. `Project` (Projekt/Kampagne) Implementierung:
       * Ich habe die Methode create_project in superoffice_client.py implementiert, um Marketing-Projekte zu erstellen.
       * Nach anfänglichen API-Herausforderungen (falsche HTTP-Methode und Endpunkt für Projektmitglieder) habe ich die create_project-Methode so angepasst, dass Projektmitglieder direkt
         beim Erstellen des Projekts übergeben werden.
       * Diese Funktionalität wurde ebenfalls erfolgreich getestet.
   4. End-to-End-Workflow Demonstration: Das main.py-Skript demonstriert nun erfolgreich den gesamten Workflow: Anlegen einer Firma (Contact), einer Person (Person), eines Verkaufs (Sale)
      und eines Projekts (Project), wobei die Person direkt dem Projekt zugeordnet wird.
   5. Detaillierter Plan für Marketing Automation: Wir haben einen sehr detaillierten Plan für die Marketing-Automatisierung über SuperOffice erarbeitet. Dieser "Butler-Service"-Ansatz
      sieht vor, dass der Connector E-Mail-Inhalte generiert und in SuperOffice-Feldern speichert, während der Versand manuell durch den User im SuperOffice-Client erfolgt.
   6. Dokumentation des Marketing-Automationsplans: Dieser detaillierte Plan, einschließlich der benötigten benutzerdefinierten Felder (UDFs) und des Workflows, wurde 1:1 in der
      connector-superoffice/README.md dokumentiert.
2026-02-10 07:58:28 +00:00
c420bb31c3 [2ff88f42] feat(connector-superoffice): Implement Sale and Project entities, refine workflow
This commit extends the SuperOffice connector to support the creation and linking of Sale \(Opportunity\) and Project \(Campaign\) entities, providing a comprehensive foundation for both sales and marketing automation workflows.

Key achievements:
- **`SUPEROFFICE_INTEGRATION_PLAN.md`**: Updated to include strategic mapping of D365 concepts \(Opportunity, Campaign\) to SuperOffice entities \(Sale, Project\).
- **`connector-superoffice/superoffice_client.py`**:
    - Implemented `create_sale` method to generate new opportunities, correctly mapping `Title` to SuperOffices
2026-02-10 07:57:11 +00:00
47cd047bc8 [2ff88f42] 1. Analyse der Änderungen:
1. Analyse der Änderungen:
      * superoffice_client.py: Implementierung der Methoden create_contact (Standardfelder) und create_person (inkl. Firmenverknüpfung).
      * auth_handler.py: Härtung der Authentifizierung durch Priorisierung von SO_CLIENT_ID und Unterstützung für load_dotenv(override=True).
      * main.py: Erweiterung des Test-Workflows für den vollständigen Lese- und Schreib-Durchstich (Erstellung von Demo-Firmen und Personen).
      * README.md: Aktualisierung des Status Quo und der verfügbaren Client-Methoden.
2026-02-09 19:00:18 +00:00
a479058df2 [2ff88f42] Durchstich geschafft
Durchstich geschafft
2026-02-09 16:05:04 +00:00
16fc826b5e feat(connector-superoffice): implement OAuth 2.0 flow and S2S architecture
Completed POC for SuperOffice integration with the following key achievements:
- Switched from RSA/SOAP to OAuth 2.0 (Refresh Token Flow) for better compatibility with SOD environment.
- Implemented robust token refreshing and caching mechanism in .
- Solved 'Wrong Subdomain' issue by enforcing  for tenant .
- Created  for REST API interaction (Search, Create, Update UDFs).
- Added helper scripts: , , .
- Documented usage and configuration in .
- Updated  configuration requirements.

[2ff88f42]
2026-02-09 16:04:16 +00:00
Jarvis
2940843457 docs: Update docs with DEV/PROD environment variable standards 2026-02-09 13:02:39 +00:00
bc018669cf Dateien nach "docs/Zierl" hochladen 2026-02-09 11:37:32 +00:00
b7afd5c221 Fragen an Manuel ergänzt 2026-02-09 09:13:36 +00:00
Jarvis
fe335cd5f3 fix: Restore deleted API docs and ensure full file integrity 2026-02-09 07:56:44 +00:00
Jarvis
15670a4be3 docs: Add step-by-step IT setup guide for GCP Dev/Prod projects 2026-02-09 07:51:17 +00:00
0bc8a301b9 Merge remote-tracking branch 'origin/main' 2026-02-06 14:01:33 +00:00
6ced7f13e6 [2ff88f42] Zusammenfassung des bisherigen Fortschritts
Zusammenfassung des bisherigen Fortschritts
2026-02-06 13:53:12 +00:00
a04d92253a feat(superoffice): POC API handshake & auth flow [2ff88f42]
Establishes the initial structure for the SuperOffice connector. Implements the complete, iterative authentication process, culminating in a successful refresh token exchange. Documents the process and the final blocker (API authorization) in the integration plan, awaiting IT action to change the application type to 'Server to server'.
2026-02-06 13:52:44 +00:00
ca726ee7a4 Dateien nach "docs/roboplanet/Produkte/InMotion Robotics/Puma_M20" hochladen 2026-02-05 13:26:12 +00:00
Jarvis
0241bc1265 docs: Fully align architecture with Cloud Identity Free strategy (replace Gemini App with Custom Chat) 2026-02-05 09:42:25 +00:00
Jarvis
ff3bbc6791 docs: Update strategy for Cloud Identity Free tier and GCP billing request 2026-02-05 09:39:47 +00:00
Jarvis
622689e2f5 docs: Update meeting cheat sheet with new IT constraints (blocked Gemini) 2026-02-05 07:53:12 +00:00
Jarvis
cdb8bacb32 docs: Restore missing meeting notes and Axels template 2026-02-05 07:51:12 +00:00
Jarvis
b71733d8c0 fix: mermaid syntax error in subgraph titles 2026-02-05 07:43:31 +00:00
Jarvis
1f162138e4 docs: Add detailed GCP vs Workspace privacy architecture 2026-02-05 07:41:24 +00:00
d48311ca3e Merge branch 'main' of https://floke-gitea.duckdns.org/Floke/Brancheneinstufung2 2026-02-04 20:44:05 +00:00
2172b9a1c2 [2f988f42] readme.md wiederhergestellt.
readme.md wiederhergestellt.
2026-02-04 20:43:00 +00:00
b332964ba5 docs([2f988f42]): Restore full readme.md content and add LLM warning 2026-02-04 20:42:38 +00:00
91b084d66d ARCHITEKTUR_GCP_SETUP.md aktualisiert 2026-02-04 19:49:30 +00:00
898ae98d5a [2fd88f42] Sehr guter erster Wurf, sollte sicher für 95% der Anforderungen genügen.
Sehr guter erster Wurf, sollte sicher für 95% der Anforderungen genügen.
2026-02-04 14:59:40 +00:00
2d2e05aa2a docs([2fd88f42]): finalize documentation and update tasks for heatmap tool 2026-02-04 14:59:40 +00:00
6bd21e7323 fix([2fd88f42]): restore missing MarkerClusterGroup import 2026-02-04 14:59:39 +00:00
d46b30a205 refactor([2fd88f42]): consolidate tooltip manager into filter panel and fix app structure 2026-02-04 14:59:39 +00:00
6c755f186c fix([2fd88f42]): correct type import for TooltipColumn in App.tsx 2026-02-04 14:59:39 +00:00
e187a4ea52 fix([2fd88f42]): correct type import for TooltipColumn 2026-02-04 14:59:39 +00:00
97a2bac4be feat([2fd88f42]): implement tooltip column manager 2026-02-04 14:59:39 +00:00
25307f3ed4 feat([2fd88f42]): implement smart PLZ column selection 2026-02-04 14:59:39 +00:00
6d65b782b8 refactor([2fd88f42]): remove legend component 2026-02-04 14:59:39 +00:00
9e947b6dfb revert([2fd88f42]): remove zoom-adaptive legend due to critical errors 2026-02-04 14:59:39 +00:00
1c5db811a8 fix([2fd88f42]): definitively resolve infinite loop in dynamic legend 2026-02-04 14:59:39 +00:00
959946319a fix([2fd88f42]): resolve infinite loop in dynamic legend handler 2026-02-04 14:59:39 +00:00
aab0ba9461 feat([2fd88f42]): implement zoom-adaptive color and legend scaling 2026-02-04 14:59:39 +00:00
c2c1e12236 fix([2fd88f42]): correct type import in MapBoundsManager 2026-02-04 14:59:39 +00:00
10206b2c3f feat([2fd88f42]): add auto zoom-to-fit and dynamic legend 2026-02-04 14:59:39 +00:00
56728f3f00 feat([2fd88f42]): add dynamic legend to points map view 2026-02-04 14:59:38 +00:00
9cd0e806db feat([2fd88f42]): add dynamic legend to points map view 2026-02-04 14:59:38 +00:00
052a1d4a98 fix([2fd88f42]): correct import statement for MarkerClusterGroup in MapDisplay.tsx 2026-02-04 14:59:38 +00:00
41cca0c98b feat([2fd88f42]): add marker clustering to points view 2026-02-04 14:59:38 +00:00
b5c0d10f17 fix([2fd88f42]): use legacy-peer-deps in docker build 2026-02-04 14:59:38 +00:00
de527b0cc7 feat([2fd88f42]): display attributes in point tooltip 2026-02-04 14:59:38 +00:00
931b589466 feat([2fd88f42]): add heatmap view with toggle switch 2026-02-04 14:59:38 +00:00
61f7f0586c feat([2fd88f42]): add adjustable marker radius and collapse filters by default 2026-02-04 14:59:38 +00:00
3aacac97f0 fix([2fd88f42]): normalize PLZ column name before final output 2026-02-04 14:59:37 +00:00
e006fcfa17 fix([2fd88f42]): handle malformed header in PLZ csv dataset 2026-02-04 14:59:37 +00:00
d9eef04732 fix([2fd88f42]): correct syntax error in docker-compose network config 2026-02-04 14:59:37 +00:00
e181cc3a61 feat([2fd88f42]): integrate real PLZ geocoordinate dataset 2026-02-04 14:59:37 +00:00
34f156e7e7 feat([2fd88f42]): redesign filter panel with modern checkbox UI 2026-02-04 14:59:37 +00:00
96505b68fc fix([2fd88f42]): remove incorrect rewrite rule from vite proxy 2026-02-04 14:59:37 +00:00
29c5ba8a87 fix([2fd8f42]): enforce dedicated docker network for service discovery 2026-02-04 14:59:37 +00:00
d63bc64d8a fix([2fd88f42]): implement vite proxy for robust API calls and add logging 2026-02-04 14:59:37 +00:00
d3e561faf0 fix([2fd88f42]): correct docker networking issue for frontend API calls 2026-02-04 14:59:37 +00:00
24dd9c3be2 fix([2fd88f42]): correct named import for HeatmapPoint type 2026-02-04 14:59:37 +00:00
504e20477f fix([2fd88f42]): add react error boundary to debug blank page 2026-02-04 14:59:37 +00:00
b4c5995131 fix([2fd88f42]): upgrade node version in frontend Dockerfile 2026-02-04 14:59:37 +00:00
101dd194b7 fix([2fd88f42]): resolve port conflict to 8002 2026-02-04 14:59:37 +00:00
75724ec142 fix([2fd88f42]): resolve port conflict for heatmap service 2026-02-04 14:59:36 +00:00
f5ba056b92 feat([2fd88f42]): initial setup of heatmap tool 2026-02-04 14:59:36 +00:00
5fa8acaea4 Dateien nach "docs" hochladen 2026-02-04 11:12:55 +00:00
6f6493da4a Dateien nach "docs" hochladen 2026-02-04 10:49:15 +00:00
9300923665 Merge branch 'main' of https://floke-gitea.duckdns.org/Floke/Brancheneinstufung2 2026-02-04 11:28:44 +01:00
674de3d4ff [2f688f42] Dockercompose wiederhergestellt
Dockercompose wiederhergestellt
2026-02-04 10:26:58 +00:00
Jarvis
11421f5871 docs: add executive summary explaining the business purpose and AI usage 2026-02-03 09:37:27 +00:00
Jarvis
0dc2e12150 docs: update gcp architecture with dev/prod environment separation strategy 2026-02-03 09:29:13 +00:00
Jarvis
ca0ef5d741 chore: scaffold superoffice connector service for development 2026-02-02 19:42:38 +00:00
Jarvis
dd0d2353d2 docs: add strategic integration plan for SuperOffice CRM 2026-02-02 19:40:31 +00:00
Jarvis
e3d2e0b945 docs: append company explorer API endpoint documentation 2026-02-02 14:46:19 +00:00
Jarvis
086a4a0842 docs: add technical architecture diagram for IT workshop 2026-02-02 14:30:45 +00:00
fd3e3a3414 [2f988f42] sollte jetzt ganz gut funktioniere. API Endpunkte wurden ergänzt und getestet.
sollte jetzt ganz gut funktioniere. API Endpunkte wurden ergänzt und getestet.
2026-02-01 20:02:07 +00:00
6ffa34381f feat(company-explorer-integration): Implement Company Explorer Connector and Lead Engine Sync [2f988f42]\n\nIntegrates the Company Explorer API into the Lead Engine workflow, allowing for robust company existence checks, creation, and asynchronous enrichment.\n\n- Introduced as a central client wrapper for the Company Explorer API, handling find-or-create logic, discovery, polling for website data, and analysis triggers.\n- Updated to utilize the new connector for syncing new leads with the Company Explorer.\n- Added for comprehensive unit testing of the connector logic.\n- Created as a demonstration script for the end-to-end integration.\n- Updated to document the new client integration architecture pattern.\n\nThis enhances the Lead Engine with a reliable mechanism for company data enrichment. 2026-02-01 19:55:12 +00:00
Jarvis
9d7f8b37e8 fix(auth): update htpasswd for admin user with fresh hash 2026-02-01 14:24:57 +00:00
Jarvis
2907121f78 fix(docker): comment out moltbot service to prevent port conflict with running agent 2026-02-01 14:17:14 +00:00
Jarvis
a3bbb98a50 fix(docker): add dashboard service definition to compose file 2026-02-01 14:06:46 +00:00
Jarvis
46f428fea9 feat(dashboard): add link to local TradingTwins Lead Engine and ensure nginx proxy service is configured 2026-02-01 14:04:09 +00:00
Jarvis
caf59e603e Fix notion script + update lead-engine debug tools 2026-01-31 17:37:32 +00:00
Jarvis
ebe13f13aa Add lead-engine source code to main repo 2026-01-31 17:25:19 +00:00
0ba21c1098 Merge branch 'main' of origin 2026-01-31 08:31:56 +00:00
a83db7ddee [2f988f42] Moltbot hat das Tool kaputt gemacht. Habe es jetzt wieder mit Gemini CLI gefixt. Ist aber noch immer nicht ganz sauber - Optik ist kaputt, viele ja ja ja in der Transkription.
Moltbot hat das Tool kaputt gemacht. Habe es jetzt wieder mit Gemini CLI gefixt. Ist aber noch immer nicht ganz sauber - Optik ist kaputt, viele ja ja ja in der Transkription.
2026-01-31 08:31:10 +00:00
Jarvis
e35152c486 Enhance finish_task: Update Total Duration prop and format status report like dev_session.py 2026-01-31 07:37:29 +00:00
Jarvis
7b70c80c71 Add Task Manager scripts (Moltbot port) 2026-01-31 07:28:44 +00:00
Jarvis
4e8ca12c05 Fix: Switch to v1 API endpoint for gemini-pro 2026-01-31 06:49:29 +00:00
Jarvis
01eb090268 Fix: Fallback to gemini-pro model for stability 2026-01-31 06:46:37 +00:00
Jarvis
01036eccf8 Fix: Update Gemini model to gemini-1.5-flash-latest 2026-01-31 06:38:01 +00:00
Jarvis
05190da910 Fix: Update Gemini API endpoint to v1beta for gemini-1.5-flash model 2026-01-31 06:33:35 +00:00
Jarvis
61a11e2de8 Fix: Rename call_gemini_flash to call_gemini_api and add temperature parameter 2026-01-31 06:30:02 +00:00
7e852ac7f7 [2f988f42] Webinterface wieder zum Laufen gebracht
Webinterface wieder zum Laufen gebracht
2026-01-31 05:49:27 +00:00
Jarvis
e4b01218b3 Fix another indentation issue in update_company_industry 2026-01-30 14:05:56 +00:00
Jarvis
a61865cdd8 Fix IndentationError in app.py 2026-01-30 14:04:40 +00:00
Jarvis
027e5fb3a7 Fix syntax error in app.py 2026-01-30 13:58:42 +00:00
30b3042e85 [2f888f42] Container neu bauen und testne
Container neu bauen und testne
2026-01-30 11:55:37 +00:00
3c3632b9fa [2ea88f42] schaut gut aus
schaut gut aus
2026-01-29 11:03:21 +00:00
b9d4e32f21 [2ea88f42] habe nur die Frage zur Ursprungsdatei für den Import geklärt
habe nur die Frage zur Ursprungsdatei für den Import geklärt
2026-01-29 11:03:21 +00:00
4f462147af Docs: Add documentation for competitor import script and JSON file [2ea88f42] 2026-01-29 11:03:21 +00:00
875c6ffe94 docs/Wachstumsmaschine.json hinzugefügt 2026-01-28 18:05:36 +00:00
6f95bce179 [2f688f42] Hat nach vielen Versuchen leider aufgrund einer Inkompatibilität der node js Version nicht geklappt.
Hat nach vielen Versuchen leider aufgrund einer Inkompatibilität der node js Version nicht geklappt.
2026-01-28 10:29:17 +00:00
4f78012df7 [2f688f42] docs: Update Moltbot Synology Guide with final diagnosis
Updated the  to reflect the definitive conclusion that Moltbot (requiring Node.js v22+) cannot be installed on Synology NAS systems (due to Docker/kernel incompatibility with modern Node.js images, and Moltbot's hard requirement).

- Added a prominent warning about the unresolvable "Catch-22" at the beginning of the guide.
- Documented the  and  that represented the final, most advanced attempt to bypass the issues, including using Node.js v20, named Docker volumes, and aggressive patching attempts.
- Updated the troubleshooting section to clearly explain the unresolvable conflict and its implications, offering alternative solutions outside of Synology.
2026-01-28 10:28:01 +00:00
314cfb2805 [2f688f42] Muss ich im readme-File prüfen
Muss ich im readme-File prüfen
2026-01-28 06:39:08 +00:00
4b9f302bff [2f688f42] docs: Add Moltbot Docker installation guide and compose file
This commit introduces the necessary files for installing Moltbot as a Docker container, specifically targeting a Synology NAS setup.

- Created  to define the Moltbot service, including image build from source, port mapping (18789), and persistent data volume ( to ).
- Added  which provides a comprehensive, step-by-step guide for deploying Moltbot on a Synology NAS using , covering initial setup and the interactive  command.
2026-01-28 06:38:07 +00:00
d88e82eaca [2f488f42] Die GEMINI.md wurde aktualisiert, um den neuen #fertig-Befehl und den damit verbundenen Workflow zu dokumentieren. Diese Konvention stellt sicher, dass das Abschließen von Arbeitspaketen zuverlässig erkannt wird.
Die GEMINI.md wurde aktualisiert, um den neuen #fertig-Befehl und den damit verbundenen Workflow zu dokumentieren. Diese Konvention stellt sicher, dass das Abschließen von Arbeitspaketen zuverlässig erkannt wird.
2026-01-27 11:56:43 +00:00
7166c5aeea [2f488f42] Abschließende Überprüfung des /fertig-Workflows. Es wurden keine neuen Code-Änderungen festgestellt. Der Status wird in Notion aktualisiert, um den Task formal abzuschließen.
Abschließende Überprüfung des /fertig-Workflows. Es wurden keine neuen Code-Änderungen festgestellt. Der Status wird in Notion aktualisiert, um den Task formal abzuschließen.
2026-01-27 11:52:11 +00:00
415ca2594a [2f488f42] Der 'fertig'-Workflow wurde erfolgreich repariert und getestet. Ein veralteter post-commit-Hook, der Fehler verursachte, wurde entfernt. Der gesamte Prozess von der Zeiterfassung über den automatisierten Commit bis zum interaktiven Push funktioniert nun wie erwartet. Ein Test-Kommentar wurde zur Validierung hinzugefügt.
Der 'fertig'-Workflow wurde erfolgreich repariert und getestet. Ein veralteter post-commit-Hook, der Fehler verursachte, wurde entfernt. Der gesamte Prozess von der Zeiterfassung über den automatisierten Commit bis zum interaktiven Push funktioniert nun wie erwartet. Ein Test-Kommentar wurde zur Validierung hinzugefügt.
2026-01-27 11:50:06 +00:00
be86775c78 [2f488f42] Diesen Text sollte ich nicht selbst schreiben müssen.
Diesen Text sollte ich nicht selbst schreiben müssen.
2026-01-27 11:48:42 +00:00
a4f9f522bf [2f388f42] Implementierung der UI-Anpassungen zur Anzeige von ausstehenden Fehlerberichten (rote Flagge in der Unternehmensliste, Anzeige im Inspector) und zur Ermöglichung weiterer Fehlerberichte. Backend-APIs wurden entsprechend erweitert.
Implementierung der UI-Anpassungen zur Anzeige von ausstehenden Fehlerberichten (rote Flagge in der Unternehmensliste, Anzeige im Inspector) und zur Ermöglichung weiterer Fehlerberichte. Backend-APIs wurden entsprechend erweitert.
2026-01-27 11:18:36 +00:00
81d6da1622 [2f488f42] Der 'fertig'-Workflow wurde weiter gehärtet. Eine Prüfung stellt nun sicher, dass ein On branch main
Der 'fertig'-Workflow wurde weiter gehärtet. Eine Prüfung stellt nun sicher, dass ein On branch main
Your branch is up to date with 'origin/main'.

nothing to commit, working tree clean nur dann versucht wird, wenn auch tatsächlich Änderungen im Arbeitsverzeichnis vorhanden sind. Dies verhindert Fehler bei der Ausführung, wenn der Task-Abschluss nur der Status-Aktualisierung dient.
2026-01-27 10:44:06 +00:00
226882affd fix(workflow): [2f488f42] Mache git commit bedingt nur bei Änderungen 2026-01-27 10:42:30 +00:00
acf64fcfe8 refactor(workflow): [2f488f42] Deaktiviere post-commit hook und mache git push interaktiv 2026-01-27 10:36:37 +00:00
6b4901f981 [2f488f42] Zeitzone angepasst, Timetracking angepasst
Zeitzone angepasst, Timetracking angepasst
2026-01-27 10:27:44 +00:00
fea8790474 fix(timetracking): [2f488f42] Passe Zeiterfassung an Berlin-Zeitzone an 2026-01-27 10:26:46 +00:00
856a583dbc fix(timetracking): [2f488f42] Behebe Platzhalter, UTC-Zeit und redundante Zeitmessung 2026-01-27 10:24:35 +00:00
661d6c08e2 [2f488f42] Scheint bereits einigermaßen zu funktionieren.
{actual_summary}
2026-01-27 10:19:15 +00:00
b03a33eede chore: Remove test file 2026-01-27 10:16:26 +00:00
4ef000d051 [2f488f42] Test: Automated workflow execution.
{actual_summary}
2026-01-27 10:16:03 +00:00
c9c462e035 [DEBUG] Triggering hook for import test 2026-01-27 10:14:23 +00:00
89ee96a5ca [2f488f42] Implement: Atomarer 'fertig' Befehl in dev_session.py mit automatischer Notion-Aktualisierung und Git-Operationen. 2026-01-27 10:12:33 +00:00
7172051803 Merge branch 'main' of https://floke-gitea.duckdns.org/Floke/Brancheneinstufung2 2026-01-27 09:36:27 +00:00
6ae0d5a963 Merge branch 'main' of https://floke-gitea.duckdns.org/Floke/Brancheneinstufung2 2026-01-27 09:34:58 +00:00
87ae3623f3 Merge branch 'main' of https://floke-gitea.duckdns.org/Floke/Brancheneinstufung2 2026-01-27 09:31:21 +00:00
3c06312ad9 Merge branch 'main' of https://floke-gitea.duckdns.org/Floke/Brancheneinstufung2
# Conflicts:
#	company-explorer/frontend/src/components/Inspector.tsx
#	company-explorer/frontend/src/components/RoboticsSettings.tsx
2026-01-27 09:30:23 +00:00
8e8fe5fb99 Merge branch 'main' of https://floke-gitea.duckdns.org/Floke/Brancheneinstufung2
# Conflicts:
#	company-explorer/frontend/src/components/Inspector.tsx
#	company-explorer/frontend/src/components/RoboticsSettings.tsx
2026-01-27 09:21:39 +00:00
45b5ec2d3f feat(reporting): Implement 'Report Mistake' feature with API and UI [2f388f42] 2026-01-27 09:12:50 +00:00
758118f7f0 feat(reporting): Implement 'Report Mistake' feature with API and UI [2f388f42] 2026-01-27 09:00:20 +00:00
7d384fafdd feat(timetracking): Complete and verify time tracking implementation [2f488f42]
Implemented a full time tracking feature. The system now displays the previously recorded time in hh:mm format when a session starts. When a work unit is completed, the invested time is automatically calculated, added to the total in Notion, and included in the status report. Various bugs were fixed during this process.
2026-01-27 08:24:44 +00:00
62fec8f078 feat(dev-session): display duration and fix hook [2f488f42] 2026-01-26 19:16:44 +00:00
4d26cca645 fix(transcription): Behebt Start- und API-Fehler in der App [2f488f42] 2026-01-26 14:15:23 +00:00
c410c9b3c0 feat(notion): Append status reports directly to page content
- Replaces the Notion update mechanism to append content blocks to the task page instead of posting comments.
- A new function, , is implemented to handle the Notion Block API.
- The  function now formats the report into a 'heading_2' block for the title and a 'code' block for the detailed content, preserving formatting.
- This provides a much cleaner and more readable changelog directly within the Notion task description.
2026-01-26 13:16:52 +00:00
a1a0d46b63 feat(transcription): add share button to detail view [2f488f42] 2026-01-26 13:07:45 +00:00
6e38c85de8 refactor(workflow): Enhance Notion reporting and context awareness
- Adds a '--summary' parameter to dev_session.py to allow for detailed, narrative descriptions in Notion status updates.
- The Notion comment format is updated to prominently display this summary.

- start-gemini.sh is refactored to be more robust and context-aware.
- It now injects the container name and a strict rule against nested docker commands into the Gemini CLI's initial prompt.
- This prevents operational errors and provides better context for the agent.
2026-01-26 12:51:53 +00:00
5d6bcbd0dd feat(transcription): add download transcript as txt button
[2f488f42]
2026-01-26 12:36:58 +00:00
fbf59bfb71 feat(dev_session): Add agent-driven Notion status reporting
Implements the  functionality in , allowing the Gemini agent to non-interactively update a Notion task with a detailed progress summary.

The agent can now be prompted to:
- Collect the new task status and any open to-dos.
- Generate a summary of Git changes () and commit messages.
- Post a formatted report as a comment to the Notion task.
- Update the task's status property.

The  has been updated to document this new agent-centric workflow, detailing how to start a session, work within it, and use the agent to report progress and push changes seamlessly.
2026-01-26 12:24:26 +00:00
9019a801ed fix(transcription): [2f388f42] finalize and fix AI insights feature
This commit resolves all outstanding issues with the AI Insights feature.

- Corrects the transcript formatting logic in  to properly handle the database JSON structure, ensuring the AI receives the correct context.
- Fixes the Gemini API client by using the correct model name ('gemini-2.0-flash') and the proper client initialization.
- Updates  to securely pass the API key as an environment variable to the container.
- Cleans up the codebase by removing temporary debugging endpoints.
- Adds  script for programmatic updates.
- Updates documentation with troubleshooting insights from the implementation process.
2026-01-26 08:53:13 +00:00
259 changed files with 83420 additions and 904 deletions

View 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
View 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
View File

@@ -0,0 +1 @@
GEMINI_API_KEY=AIzaSyBNg5yQ-dezfDs6j9DGn8qJ8SImNCGm9Ds

3
.gitignore vendored
View File

@@ -68,3 +68,6 @@ Log_from_docker/
# Node.js specific
!package.json
!package-lock.json
.gemini/.env
gemini_api_key.txt

View File

@@ -1 +1 @@
admin:$1$RzTlC0sX$L2VQ31MyQ0Wefz1vNG7Yf1
admin:$6$un97dUtWx4rc/Qcr$Xo7oatwiWn8F7lPiSCHI56K3OK7k0rHztVp2kfl78Kk6juw5KTwWlwU07PGgDGY5mQiGZzDy4O0UhIVvR5HsC.

277
ARCHITEKTUR_GCP_SETUP.md Normal file
View 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
View File

@@ -0,0 +1,467 @@
A) Überblick
* Kontext des Dialogs (25 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 24082410)
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 (35 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 19261933)
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 12551257)
* „Diskrepanz…“ (mittig, Zeilen 13311335)
* „git pull … Already up to date.“ (spät, Zeilen 21862188)
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 22122216)
* „…Repo … zu groß … Timeout… shallow clone…“ (spät, Zeilen 22372241)
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 677683)
* „…Volume… ./clawd:/home/node/clawd“ (mittig, Zeilen 730746)
* „…kopiere … nach … persistent_clawd…“ (spät, Zeilen 22542256)
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 24212424)
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 (35 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
View 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"]

View File

@@ -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
View 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.

View File

@@ -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", "").
- "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
View 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
View 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.

View File

@@ -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
View 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
View 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`.

View 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).

View 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.
---

View File

@@ -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.

View 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. |

View File

@@ -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}

View File

@@ -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`

View File

@@ -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 = () => {

View File

@@ -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));

View File

@@ -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: {

View File

@@ -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)

View 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
View 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
View 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
View 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
View 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
View 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()

View File

@@ -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"]

View 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`

View File

@@ -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

View File

@@ -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
# ==============================================================================

View 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()

View File

@@ -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.")

View 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()

View 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": "",
"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=""
)
# --- Assertions ---
self.assertIsNotNone(results)
self.assertEqual(results['calculated_metric_value'], 8000.0)
self.assertEqual(results['metric_source'], 'website')
if __name__ == '__main__':
unittest.main()

View 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()

View File

@@ -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"
}
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 &gt;</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>

View 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" }]
}

View File

@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

View 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.")

View 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()

View 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))

View File

@@ -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"
```

View 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"]

View 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.

View File

@@ -0,0 +1 @@
# This file makes the directory a Python package

View 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

View 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()

View 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))
}

View 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}")

View 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")

View 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)

View 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()

View 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')}")

View 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.")

View 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}")

View 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.")

View 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 ---")

View 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 ---")

View 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()

View 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))

View 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)

View 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()

View 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

View 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()

View 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)}")

View 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()

View 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.

View 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.")

View 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-----

View 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-----

View 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())

View 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()

View File

@@ -0,0 +1,10 @@
requests
python-dotenv
cryptography
pyjwt
xmltodict
holidays
fastapi
uvicorn
schedule
sqlalchemy

View 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

View 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)

View 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 ---")

View 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}")

View 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)

View 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)

View 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()

View 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'))

View File

@@ -183,6 +183,17 @@
</p>
<a href="/ca/" class="btn">Starten &rarr;</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 &rarr;</a>
</div>
<!-- Meeting Assistant (Transcription) -->
<div class="card">
<span class="card-icon">🎙️</span>
@@ -193,6 +204,15 @@
<a href="/tr/" class="btn">Starten &rarr;</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 &rarr;</a>
</div>
</div>
<footer>

71
debug_meeting.py Normal file
View 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)

View 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
View 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()

View 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()

View File

@@ -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()

View File

@@ -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: {}

View File

@@ -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

Binary file not shown.

Binary file not shown.

Some files were not shown because too many files have changed in this diff Show More