3 Commits

7 changed files with 370 additions and 99 deletions

View File

@@ -1 +1 @@
{"task_id": "2ff88f42-8544-8050-8245-c3bb852058f4", "token": "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", "session_start_time": "2026-02-23T07:38:02.815686"}
{"task_id": "2ff88f42-8544-8050-8245-c3bb852058f4", "token": "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", "session_start_time": "2026-02-23T09:56:54.647321"}

View File

@@ -2,7 +2,7 @@ import sys
import os
import json
import argparse
from typing import List
import re
import google.generativeai as genai
# Setup Environment
@@ -14,30 +14,76 @@ from backend.config import settings
# --- Configuration ---
MODEL_NAME = "gemini-2.0-flash" # High quality copy
def extract_segment(text: str, marker: str) -> str:
"""
Extracts a text block starting with [marker].
Example: [Primary Product: Cleaning] ... [Secondary Product: Service]
"""
if not text: return ""
# Split by square brackets that look like headers [Text: ...]
# We look for the marker inside the header
# Simplified Regex: Capture everything inside brackets as ONE group
pattern = r'\[(.*?)\]'
segments = re.split(pattern, text)
# segments[0] is text before first bracket
# segments[1] is content of first bracket (header)
# segments[2] is content after first bracket (body)
# ...
best_match = ""
for i in range(1, len(segments), 2):
header = segments[i]
content = segments[i+1]
# print(f"DEBUG: Checking Header: '{header}' for Marker: '{marker}'") # Uncomment for deep debug
if marker.lower() in header.lower():
return content.strip()
# Fallback: If no markers found, return full text (legacy support)
if "Primary Product" not in text and "Secondary Product" not in text:
return text
return ""
def generate_prompt(industry: Industry, persona: Persona) -> str:
"""
Builds the prompt for the AI to generate the marketing texts.
Combines Industry context with Persona specific pains/gains and Product Category.
"""
# 1. Determine Product Context
# We focus on the primary category for the general matrix,
# but we inform the AI about the secondary option if applicable.
primary_cat = industry.primary_category
product_context = f"{primary_cat.name}: {primary_cat.description}" if primary_cat else "Intelligente Robotik-Lösungen"
# 1. Determine Product Focus Strategy
# Default: Primary
target_scope = "Primary Product"
target_category = industry.primary_category
# 2. Extract specific segments from industry pains/gains
def extract_segment(text, marker):
if not text: return ""
import re
segments = re.split(r'\[(.*?)\]', text)
for i in range(1, len(segments), 2):
if marker.lower() in segments[i].lower():
return segments[i+1].strip()
return text
# Special Rule: "Operativer Entscheider" gets Secondary Product IF ops_focus_secondary is True
# Logic: A Nursing Director (Ops) doesn't care about floor cleaning (Facility),
# but cares about Service Robots (Secondary).
if persona.name == "Operativer Entscheider" and industry.ops_focus_secondary:
target_scope = "Secondary Product"
target_category = industry.secondary_category
print(f" -> STRATEGY SWITCH: Using {target_scope} for {persona.name}")
industry_pains = extract_segment(industry.pains, "Primary Product")
industry_gains = extract_segment(industry.gains, "Primary Product")
# Fallback if secondary was requested but not defined
if not target_category:
target_category = industry.primary_category
target_scope = "Primary Product" # Fallback to primary if secondary category object is missing
product_context = f"{target_category.name}: {target_category.description}" if target_category else "Intelligente Robotik-Lösungen"
# 2. Extract specific segments from industry pains/gains based on scope
industry_pains = extract_segment(industry.pains, target_scope)
industry_gains = extract_segment(industry.gains, target_scope)
# Fallback: If specific scope is empty (e.g. no Secondary Pains defined), try Primary
if not industry_pains and target_scope == "Secondary Product":
print(f" -> WARNING: No specific Pains found for {target_scope}. Fallback to Primary.")
industry_pains = extract_segment(industry.pains, "Primary Product")
industry_gains = extract_segment(industry.gains, "Primary Product")
# 3. Handle Persona Data
try:
@@ -48,35 +94,41 @@ def generate_prompt(industry: Industry, persona: Persona) -> str:
persona_gains = [persona.gains] if persona.gains else []
prompt = f"""
Du bist ein scharfsinniger B2B-Strategieberater und exzellenter Copywriter.
Deine Aufgabe: Erstelle hochpräzise, "scharfe" Marketing-Textbausteine für einen Outreach an Entscheider.
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 an einen Entscheider.
--- STRATEGISCHER RAHMEN ---
ZIELUNTERNEHMEN (Branche): {industry.name}
BRANCHEN-KONTEXT: {industry.description or 'Keine spezifische Beschreibung'}
BRANCEHN-HERAUSFORDERUNGEN: {industry_pains}
ANGESTREBTE MEHRWERTE: {industry_gains}
--- KONTEXT ---
ZIELBRANCHE: {industry.name}
BRANCHEN-HERAUSFORDERUNGEN (PAIN POINTS):
{industry_pains}
ZIELPERSON (Rolle): {persona.name}
PERSÖNLICHER DRUCK (Pains der Rolle):
{chr(10).join(['- ' + p for p in persona_pains])}
GEWÜNSCHTE ERFOLGE (Gains der Rolle):
{chr(10).join(['- ' + g for g in persona_gains])}
ANGEBOTENE LÖSUNG (Produkt-Fokus):
FOKUS-PRODUKT (LÖSUNG):
{product_context}
--- DEIN AUFTRAG ---
Erstelle ein JSON-Objekt mit 3 Textbausteinen, die den persönlichen Druck des Empfängers mit den strategischen Notwendigkeiten seiner Branche und der technologischen Lösung verknüpfen.
Tonalität: Wertschätzend, auf Augenhöhe, scharfsinnig, absolut NICHT marktschreierisch.
ANSPRECHPARTNER (ROLLE): {persona.name}
PERSÖNLICHE HERAUSFORDERUNGEN DES ANSPRECHPARTNERS (PAIN POINTS):
{chr(10).join(['- ' + str(p) for p in persona_pains])}
1. "subject": Eine Betreffzeile (Max 6 Wörter), die den Finger direkt in eine Wunde (Pain) legt oder ein hohes Ziel (Gain) verspricht.
2. "intro": Einleitung (2-3 Sätze). Verbinde die spezifische Branchen-Herausforderung mit der persönlichen Verantwortung des Empfängers. Er muss sich sofort verstanden fühlen.
3. "social_proof": Ein Beweissatz, der zeigt, dass diese Lösung in der Branche {industry.name} bereits reale Probleme (z.B. Personalmangel, Dokumentationsdruck) gelöst hat. Nenne keine konkreten Firmennamen, aber quantifizierbare Effekte.
--- DEINE AUFGABE ---
1. **Subject:** Formuliere eine kurze Betreffzeile (max. 6 Wörter). Richte sie **direkt an einem der persönlichen Pain Points** des Ansprechpartners oder dem zentralen Branchen-Pain. Sei scharfsinnig, nicht werblich.
2. **Introduction_Textonly:** Formuliere einen Einleitungstext (2-3 Sätze).
- **Satz 1 (Die Brücke):** Knüpfe an die (uns unbekannte) operative Herausforderung an. Beschreibe subtil den Nutzen einer Lösung, ohne das Produkt (Roboter) plump zu nennen.
- **Satz 2 (Die Relevanz):** Schaffe die Relevanz für die Zielperson, indem du das Thema mit einem ihrer persönlichen Pain Points verknüpfst (z.B. "Für Sie als {persona.name} ist dabei entscheidend...").
3. **Industry_References_Textonly:** Formuliere einen **strategischen Referenz-Block (ca. 2-3 Sätze)** nach folgendem Muster:
- **Satz 1 (Social Proof):** Beginne direkt mit dem Nutzen, den vergleichbare Unternehmen in der Branche {industry.name} bereits erzielen. (Erfinde keine Firmennamen, sprich von "Führenden Einrichtungen" oder "Vergleichbaren Häusern").
- **Satz 2 (Rollen-Relevanz):** Schaffe den direkten Nutzen für die Zielperson. Formuliere z.B. 'Dieser Wissensvorsprung hilft uns, Ihre [persönlicher Pain Point der Rolle] besonders effizient zu lösen.'
--- BEISPIEL FÜR EINEN PERFEKTEN OUTPUT ---
{{
"Subject": "Kostenkontrolle im Service",
"Introduction_Textonly": "Genau bei der Optimierung dieser Serviceprozesse können erhebliche Effizienzgewinne erzielt werden. Für Sie als Finanzleiter ist dabei die Sicherstellung der Profitabilität bei gleichzeitiger Kostentransparenz von zentraler Bedeutung.",
"Industry_References_Textonly": "Vergleichbare Unternehmen profitieren bereits massiv von automatisierten Prozessen. Unsere Erfahrung zeigt, dass die grundlegenden Herausforderungen in der Einsatzplanung oft branchenübergreifend ähnlich sind. Dieser Wissensvorsprung hilft uns, Ihre Ziele bei der Kostenkontrolle und Profitabilitätssteigerung besonders effizient zu unterstützen."
}}
--- FORMAT ---
Antworte NUR mit einem validen JSON-Objekt.
Antworte NUR mit einem validen JSON-Objekt. Keine Markdown-Blöcke (```json), kein erklärender Text.
Format:
{{
"subject": "...",
@@ -88,7 +140,7 @@ Format:
def mock_call(prompt: str):
"""Simulates an API call for dry runs."""
print(f"\n--- [MOCK] GENERATING PROMPT ---\n{prompt[:300]}...\n--------------------------------")
print(f"\n--- [MOCK] GENERATING PROMPT ---\n{prompt[:800]}...\n--------------------------------")
return {
"subject": "[MOCK] Effizienzsteigerung in der Produktion",
"intro": "[MOCK] Als Produktionsleiter wissen Sie, wie teuer Stillstand ist. Unsere Roboter helfen.",
@@ -149,10 +201,14 @@ def run_matrix_generation(dry_run: bool = True, force: bool = False, specific_in
print(f"Found {len(industries)} Industries and {len(personas)} Personas.")
print(f"Mode: {'DRY RUN (No API calls, no DB writes)' if dry_run else 'LIVE - GEMINI GENERATION'}")
# Pre-load categories to avoid lazy load issues if detached
# (SQLAlchemy session is open, so should be fine, but good practice)
total_combinations = len(industries) * len(personas)
processed = 0
for ind in industries:
print(f"\n>>> Processing Industry: {ind.name} (Ops Secondary: {ind.ops_focus_secondary})")
for pers in personas:
processed += 1
print(f"[{processed}/{total_combinations}] Check: {ind.name} x {pers.name}")
@@ -175,9 +231,22 @@ def run_matrix_generation(dry_run: bool = True, force: bool = False, specific_in
else:
try:
result = real_gemini_call(prompt)
# Basic Validation
if not result.get("subject") or not result.get("intro"):
print(" -> Invalid result structure. Skipping.")
# Normalize Keys (Case-Insensitive)
normalized_result = {}
for k, v in result.items():
normalized_result[k.lower()] = v
# Map known variations to standardized keys
if "introduction_textonly" in normalized_result:
normalized_result["intro"] = normalized_result["introduction_textonly"]
if "industry_references_textonly" in normalized_result:
normalized_result["social_proof"] = normalized_result["industry_references_textonly"]
# Validation using normalized keys
if not normalized_result.get("subject") or not normalized_result.get("intro"):
print(f" -> Invalid result structure. Keys found: {list(result.keys())}")
print(f" -> Raw Result: {json.dumps(result, indent=2)}")
continue
except Exception as e:
@@ -190,16 +259,16 @@ def run_matrix_generation(dry_run: bool = True, force: bool = False, specific_in
new_entry = MarketingMatrix(
industry_id=ind.id,
persona_id=pers.id,
subject=result.get("subject"),
intro=result.get("intro"),
social_proof=result.get("social_proof")
subject=normalized_result.get("subject"),
intro=normalized_result.get("intro"),
social_proof=normalized_result.get("social_proof")
)
db.add(new_entry)
print(f" -> Created new entry.")
else:
existing.subject = result.get("subject")
existing.intro = result.get("intro")
existing.social_proof = result.get("social_proof")
existing.subject = normalized_result.get("subject")
existing.intro = normalized_result.get("intro")
existing.social_proof = normalized_result.get("social_proof")
print(f" -> Updated entry.")
db.commit()

View File

@@ -0,0 +1,162 @@
import os
import requests
import json
from dotenv import load_dotenv
# Load environment variables
load_dotenv()
NOTION_API_KEY = os.getenv("NOTION_API_KEY")
NOTION_DB_VERTICALS = "2ec88f4285448014ab38ea664b4c2b81"
if not NOTION_API_KEY:
print("Error: NOTION_API_KEY not found.")
exit(1)
headers = {
"Authorization": f"Bearer {NOTION_API_KEY}",
"Notion-Version": "2022-06-28",
"Content-Type": "application/json"
}
# The approved changes from ANALYSIS_AND_PROPOSAL.md for Phase 2
UPDATES = {
"Energy - Solar/Wind": {
"Pains": """[Primary Product: Security]
- Kupfer-Diebstahl: Professionelle Banden plündern abgelegene Parks in Minuten; der Schaden durch Betriebsunterbrechung übersteigt den Materialwert oft weit.
- Interventionszeit: Bis der Wachdienst eintrifft ("Blaulicht-Fahrt"), sind die Täter längst verschwunden.
- Kostenfalle Falschalarm: Wildtiere oder wetterbedingte Störungen lösen teure, unnötige Polizeieinsätze aus.""",
"Gains": """[Primary Product: Security]
- Sofort-Verifikation: KI-gestützte Erkennung unterscheidet zuverlässig zwischen Tier und Mensch und liefert Live-Bilder in Sekunden.
- Präventive Abschreckung: Autonome Patrouillen signalisieren "Hier wird bewacht" und verhindern den Versuch.
- Lückenlose Beweissicherung: Gerichtsfeste Dokumentation von Vorfällen für Versicherung und Strafverfolgung."""
},
"Infrastructure - Public": {
"Pains": """[Primary Product: Cleaning Indoor]
- Zeitdruck (Turnaround): Zwischen Messe-Ende und Öffnung am nächsten Tag liegen nur wenige Stunden für eine Komplettreinigung.
- Kostenspirale: Nacht- und Wochenendzuschläge für manuelles Personal belasten das Budget massiv.
- Personalverfügbarkeit: Für Spitzenlasten (Messezeiten) ist kurzfristig kaum ausreichendes Personal zu finden.
[Secondary Product: Cleaning Outdoor]
- Erster Eindruck: Vermüllte Vorplätze und Zufahrten schaden dem Image der Veranstaltung schon bei Ankunft.""",
"Gains": """[Primary Product: Cleaning Indoor]
- Planbare Kapazität: Roboter reinigen autonom die Kilometer langen Gänge ("Gang-Reinigung"), Personal fokussiert sich auf Stände und Details.
- Kosteneffizienz: Fixe Kosten statt variabler Zuschläge für Nachtarbeit.
[Secondary Product: Cleaning Outdoor]
- Repräsentative Außenwirkung: Sauberer Empfangsbereich ohne permanenten Personaleinsatz."""
},
"Infrastructure - Transport": {
"Pains": """[Primary Product: Cleaning Indoor]
- Sicherheits-Checks: Jede externe Reinigungskraft im Sicherheitsbereich erfordert aufwändige Überprüfungen (ZÜP) und Begleitung.
- Passagier-Störung: Laute, manuelle Reinigungsmaschinen behindern Laufwege und Durchsagen im 24/7-Betrieb.
- Hochfrequenz-Verschmutzung: Kaffee-Flecken und Nässe (Winter) müssen sofort beseitigt werden, um Rutschunfälle zu vermeiden.
[Secondary Product: Cleaning Outdoor]
- Müll-Aufkommen: Raucherbereiche und Taxi-Spuren verkommen schnell durch Zigarettenstummel und Kleinmüll.""",
"Gains": """[Primary Product: Cleaning Indoor]
- "Approved Staff": Roboter verbleibt im Sicherheitsbereich kein täglicher Check-in/Check-out nötig.
- Silent Cleaning: Leise, autonome Navigation zwischen Passagieren stört den Betriebsablauf nicht.
[Secondary Product: Cleaning Outdoor]
- Sauberer Transfer: Gepflegte Außenanlagen als Visitenkarte der Mobilitätsdrehscheibe."""
},
"Retail - Shopping Center": {
"Pains": """[Primary Product: Cleaning Indoor]
- Food-Court-Chaos: Zu Stoßzeiten kommen Reinigungskräfte mit dem Wischen von verschütteten Getränken und Essensresten kaum nach.
- Rutschfallen: Nasse Eingänge (Regen) und verschmutzte Zonen sind Haftungsrisiken für den Betreiber.
- Image-Faktor: Ein "grauer" oder fleckiger Boden senkt die Aufenthaltsqualität und damit die Verweildauer der Kunden.
[Secondary Product: Cleaning Outdoor]
- Parkplatz-Pflege: Müll auf Parkplätzen und in Parkhäusern ist der erste negative Touchpoint für Besucher.""",
"Gains": """[Primary Product: Cleaning Indoor]
- Reaktionsschnelligkeit: Roboter sind permanent präsent und beseitigen Malheure sofort, bevor sie antrocknen.
- Hochglanz-Optik: Konstante Pflege poliert den Steinboden und sorgt für ein hochwertiges Ambiente.
[Secondary Product: Cleaning Outdoor]
- Willkommens-Kultur: Sauberer Außenbereich lädt zum Betreten ein."""
},
"Leisure - Wet & Spa": {
"Pains": """[Primary Product: Cleaning Indoor]
- Rutsch-Unfälle: Staunässe auf Fliesen ist die Unfallursache Nummer 1 in Bädern hohes Haftungsrisiko.
- Hygiene-Sensibilität: Im Barfußbereich (Umkleiden/Gänge) erwarten Gäste klinische Sauberkeit; Haare und Fussel sind "Ekel-Faktor".
- Personal-Konflikt: Fachangestellte für Bäderbetriebe sollen die Beckenaufsicht führen (Sicherheit), nicht wischen.""",
"Gains": """[Primary Product: Cleaning Indoor]
- Permanente Sicherheit: Roboter trocknen Laufwege kontinuierlich und minimieren das Rutschrisiko aktiv.
- Entlastung der Aufsicht: Bademeister können sich zu 100% auf die Sicherheit der Badegäste konzentrieren.
- Hygiene-Standard: Dokumentierte Desinfektion und Reinigung sichert Top-Bewertungen."""
},
"Corporate - Campus": {
"Pains": """[Primary Product: Cleaning Indoor]
- Repräsentativität: Empfangshallen und Atrien sind das Aushängeschild sichtbarer Staub oder Schlieren wirken unprofessionell.
- Kostendruck Facility: Enorme Flächen (Flure/Verbindungsgänge) erzeugen hohe laufende Reinigungskosten.
[Secondary Product: Cleaning Outdoor]
- Campus-Pflege: Weitläufige Außenanlagen manuell sauber zu halten, bindet unverhältnismäßig viele Ressourcen.""",
"Gains": """[Primary Product: Cleaning Indoor]
- Innovations-Statement: Einsatz von Robotik unterstreicht den technologischen Führungsanspruch des Unternehmens gegenüber Besuchern und Bewerbern.
- Konstante Qualität: Einheitliches Sauberkeitsniveau in allen Gebäudeteilen, unabhängig von Tagesform oder Krankenstand.
[Secondary Product: Cleaning Outdoor]
- Gepflegtes Erscheinungsbild: Automatisierte Kehrleistung sorgt für repräsentative Wege und Plätze."""
},
"Reinigungsdienstleister": {
"Pains": """[Primary Product: Cleaning Indoor]
- Personal-Mangel & Fluktuation: Hohe "No-Show"-Quoten und ständige Neurekrutierung binden Objektleiter massiv und gefährden die Vertragserfüllung.
- Margen-Verfall: Steigende Tariflöhne bei gleichzeitigem Preisdruck der Auftraggeber lassen kaum noch Gewinn zu.
- Qualitäts-Schwankungen: Wechselndes, ungelernte Personal liefert oft unzureichende Ergebnisse, was zu Reklamationen und Kürzungen führt.""",
"Gains": """[Primary Product: Cleaning Indoor]
- Kalkulations-Sicherheit: Roboter bieten fixe Kosten statt unkalkulierbarer Krankheits- und Ausfallrisiken.
- Wettbewerbsvorteil: Mit Robotik-Konzepten punkten Dienstleister bei Ausschreibungen als Innovationsführer.
- Entlastung Objektleitung: Weniger Personal-Management bedeutet mehr Zeit für Kundenpflege und Qualitätskontrolle."""
}
}
def get_page_id(vertical_name):
# Try to find the page with a filter on "Vertical" property
url = f"https://api.notion.com/v1/databases/{NOTION_DB_VERTICALS}/query"
payload = {
"filter": {
"property": "Vertical",
"title": {
"equals": vertical_name
}
}
}
response = requests.post(url, headers=headers, json=payload)
if response.status_code == 200:
results = response.json().get("results", [])
if results:
return results[0]["id"]
return None
def update_page(page_id, pains, gains):
url = f"https://api.notion.com/v1/pages/{page_id}"
payload = {
"properties": {
"Pains": {
"rich_text": [{"text": {"content": pains}}]
},
"Gains": {
"rich_text": [{"text": {"content": gains}}]
}
}
}
response = requests.patch(url, headers=headers, json=payload)
if response.status_code == 200:
print(f"✅ Updated {page_id}")
else:
print(f"❌ Failed to update {page_id}: {response.text}")
def main():
print("Starting update Phase 2...")
for vertical, content in UPDATES.items():
print(f"Processing '{vertical}'...")
page_id = get_page_id(vertical)
if page_id:
update_page(page_id, content["Pains"], content["Gains"])
else:
print(f"⚠️ Vertical '{vertical}' not found in Notion.")
if __name__ == "__main__":
main()

View File

@@ -67,6 +67,7 @@ def extract_select(prop):
return prop.get("select", {}).get("name", "") if prop.get("select") else ""
def extract_number(prop):
if not prop: return None
return prop.get("number")
def sync_categories(token, session):
@@ -135,6 +136,11 @@ def sync_industries(token, session):
industry.name = name
industry.description = extract_rich_text(props.get("Definition"))
# New: Map Pains & Gains explicitly
industry.pains = extract_rich_text(props.get("Pains"))
industry.gains = extract_rich_text(props.get("Gains"))
industry.notes = extract_rich_text(props.get("Notes"))
status = extract_select(props.get("Status"))
industry.status_notion = status
industry.is_focus = (status == "P1 Focus Industry")
@@ -147,6 +153,9 @@ def sync_industries(token, session):
industry.scraper_search_term = extract_select(props.get("Scraper Search Term")) # <-- FIXED HERE
industry.scraper_keywords = extract_rich_text(props.get("Scraper Keywords"))
industry.standardization_logic = extract_rich_text(props.get("Standardization Logic"))
# New Field: Ops Focus Secondary (Checkbox)
industry.ops_focus_secondary = props.get("Ops Focus: Secondary", {}).get("checkbox", False)
# Relation: Primary Product Category
relation = props.get("Primary Product Category", {}).get("relation", [])
@@ -157,6 +166,16 @@ def sync_industries(token, session):
industry.primary_category_id = cat.id
else:
logger.warning(f"Related category {related_id} not found for industry {name}")
# Relation: Secondary Product Category
relation_sec = props.get("Secondary Product", {}).get("relation", [])
if relation_sec:
related_id = relation_sec[0]["id"]
cat = session.query(RoboticsCategory).filter(RoboticsCategory.notion_id == related_id).first()
if cat:
industry.secondary_category_id = cat.id
else:
logger.warning(f"Related Secondary category {related_id} not found for industry {name}")
count += 1

View File

@@ -66,43 +66,52 @@ class SuperOfficeClient:
logger.error(f"❌ Connection Error during token refresh: {e}")
return None
def _get(self, endpoint):
"""Generic GET request."""
if not self.access_token: return None
def _request_with_retry(self, method, endpoint, payload=None, retry=True):
"""Helper to handle 401 Unauthorized with auto-refresh."""
if not self.access_token:
if not self._refresh_access_token():
return None
url = f"{self.base_url}/{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}")
logger.debug(f"Response Headers: {e.response.headers}")
return None
if method == "GET":
resp = requests.get(url, headers=self.headers)
elif method == "POST":
resp = requests.post(url, headers=self.headers, json=payload)
elif method == "PUT":
resp = requests.put(url, headers=self.headers, json=payload)
# 401 Handling
if resp.status_code == 401 and retry:
logger.warning(f"⚠️ 401 Unauthorized for {endpoint}. Attempting Token Refresh...")
new_token = self._refresh_access_token()
if new_token:
self.access_token = new_token
self.headers["Authorization"] = f"Bearer {self.access_token}"
return self._request_with_retry(method, endpoint, payload, retry=False)
else:
logger.error("❌ Token Refresh failed during retry.")
return None
def _put(self, endpoint, payload):
"""Generic PUT request."""
if not self.access_token: return None
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:
logger.error(f"❌ API PUT Error for {endpoint}: {e.response.text}")
return None
def _post(self, endpoint, payload):
"""Generic POST request."""
if not self.access_token: return None
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:
logger.error(f"❌ API POST Error for {endpoint} (Status: {e.response.status_code}): {e.response.text}")
logger.error(f"❌ API {method} Error for {endpoint} (Status: {e.response.status_code}): {e.response.text}")
return None
except Exception as e:
logger.error(f"❌ Connection Error during POST for {endpoint}: {e}")
logger.error(f"❌ Connection Error during {method} for {endpoint}: {e}")
return None
def _get(self, endpoint):
return self._request_with_retry("GET", endpoint)
def _put(self, endpoint, payload):
return self._request_with_retry("PUT", endpoint, payload)
def _post(self, endpoint, payload):
return self._request_with_retry("POST", endpoint, payload)
# --- Convenience Wrappers ---
def get_person(self, person_id):

View File

@@ -115,32 +115,37 @@ def process_job(job, so_client: SuperOfficeClient):
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")
if not contact_details:
raise ValueError(f"Contact {contact_id} not found (API returned None)")
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")
if settings.UDF_VERTICAL:
udfs = contact_details.get("UserDefinedFields", {})
so_vertical_val = udfs.get(settings.UDF_VERTICAL)
if settings.UDF_VERTICAL:
udfs = contact_details.get("UserDefinedFields", {})
so_vertical_val = udfs.get(settings.UDF_VERTICAL)
if so_vertical_val:
val_str = str(so_vertical_val)
if val_str.startswith("[I:"):
val_str = val_str.split(":")[1].strip("]")
if so_vertical_val:
val_str = str(so_vertical_val)
if val_str.startswith("[I:"):
val_str = val_str.split(":")[1].strip("]")
try:
vertical_map = json.loads(settings.VERTICAL_MAP_JSON)
vertical_map_rev = {str(v): k for k, v in vertical_map.items()}
if val_str in vertical_map_rev:
crm_industry_name = vertical_map_rev[val_str]
logger.info(f"Detected CRM Vertical Override: {so_vertical_val} -> {crm_industry_name}")
except Exception as ex:
logger.error(f"Error mapping vertical ID {val_str}: {ex}")
try:
vertical_map = json.loads(settings.VERTICAL_MAP_JSON)
vertical_map_rev = {str(v): k for k, v in vertical_map.items()}
if val_str in vertical_map_rev:
crm_industry_name = vertical_map_rev[val_str]
logger.info(f"Detected CRM Vertical Override: {so_vertical_val} -> {crm_industry_name}")
except Exception as ex:
logger.error(f"Error mapping vertical ID {val_str}: {ex}")
except Exception as e:
logger.warning(f"Failed to fetch contact details for {contact_id}: {e}")
logger.error(f"Failed to fetch contact details for {contact_id}: {e}")
# Critical failure: Without contact details, we cannot provision correctly.
# Raising exception triggers a retry.
raise Exception(f"SuperOffice API Failure: {e}")
# --- 3. Company Explorer Provisioning ---
ce_url = f"{settings.COMPANY_EXPLORER_URL}/api/provision/superoffice-contact"

View File

@@ -182,9 +182,16 @@ http {
# Trailing Slash STRIPS the /connector/ prefix!
# So /connector/dashboard -> /dashboard
proxy_pass http://connector-superoffice:8000/;
# Standard Proxy Headers
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Websocket Support (just in case)
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}
}