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 os
import json import json
import argparse import argparse
from typing import List import re
import google.generativeai as genai import google.generativeai as genai
# Setup Environment # Setup Environment
@@ -14,28 +14,74 @@ from backend.config import settings
# --- Configuration --- # --- Configuration ---
MODEL_NAME = "gemini-2.0-flash" # High quality copy 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: def generate_prompt(industry: Industry, persona: Persona) -> str:
""" """
Builds the prompt for the AI to generate the marketing texts. Builds the prompt for the AI to generate the marketing texts.
Combines Industry context with Persona specific pains/gains and Product Category. Combines Industry context with Persona specific pains/gains and Product Category.
""" """
# 1. Determine Product Context # 1. Determine Product Focus Strategy
# We focus on the primary category for the general matrix, # Default: Primary
# but we inform the AI about the secondary option if applicable. target_scope = "Primary Product"
primary_cat = industry.primary_category target_category = industry.primary_category
product_context = f"{primary_cat.name}: {primary_cat.description}" if primary_cat else "Intelligente Robotik-Lösungen"
# 2. Extract specific segments from industry pains/gains # Special Rule: "Operativer Entscheider" gets Secondary Product IF ops_focus_secondary is True
def extract_segment(text, marker): # Logic: A Nursing Director (Ops) doesn't care about floor cleaning (Facility),
if not text: return "" # but cares about Service Robots (Secondary).
import re if persona.name == "Operativer Entscheider" and industry.ops_focus_secondary:
segments = re.split(r'\[(.*?)\]', text) target_scope = "Secondary Product"
for i in range(1, len(segments), 2): target_category = industry.secondary_category
if marker.lower() in segments[i].lower(): print(f" -> STRATEGY SWITCH: Using {target_scope} for {persona.name}")
return segments[i+1].strip()
return text
# 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_pains = extract_segment(industry.pains, "Primary Product")
industry_gains = extract_segment(industry.gains, "Primary Product") industry_gains = extract_segment(industry.gains, "Primary Product")
@@ -48,35 +94,41 @@ def generate_prompt(industry: Industry, persona: Persona) -> str:
persona_gains = [persona.gains] if persona.gains else [] persona_gains = [persona.gains] if persona.gains else []
prompt = f""" prompt = f"""
Du bist ein scharfsinniger B2B-Strategieberater und exzellenter Copywriter. Du bist ein kompetenter Lösungsberater und brillanter Texter.
Deine Aufgabe: Erstelle hochpräzise, "scharfe" Marketing-Textbausteine für einen Outreach an Entscheider. AUFGABE: Erstelle 3 Textblöcke (Subject, Introduction_Textonly, Industry_References_Textonly) für eine E-Mail an einen Entscheider.
--- STRATEGISCHER RAHMEN --- --- KONTEXT ---
ZIELUNTERNEHMEN (Branche): {industry.name} ZIELBRANCHE: {industry.name}
BRANCHEN-KONTEXT: {industry.description or 'Keine spezifische Beschreibung'} BRANCHEN-HERAUSFORDERUNGEN (PAIN POINTS):
BRANCEHN-HERAUSFORDERUNGEN: {industry_pains} {industry_pains}
ANGESTREBTE MEHRWERTE: {industry_gains}
ZIELPERSON (Rolle): {persona.name} FOKUS-PRODUKT (LÖSUNG):
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):
{product_context} {product_context}
--- DEIN AUFTRAG --- ANSPRECHPARTNER (ROLLE): {persona.name}
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. PERSÖNLICHE HERAUSFORDERUNGEN DES ANSPRECHPARTNERS (PAIN POINTS):
Tonalität: Wertschätzend, auf Augenhöhe, scharfsinnig, absolut NICHT marktschreierisch. {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. --- DEINE AUFGABE ---
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. 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.
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.
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 --- --- 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: Format:
{{ {{
"subject": "...", "subject": "...",
@@ -88,7 +140,7 @@ Format:
def mock_call(prompt: str): def mock_call(prompt: str):
"""Simulates an API call for dry runs.""" """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 { return {
"subject": "[MOCK] Effizienzsteigerung in der Produktion", "subject": "[MOCK] Effizienzsteigerung in der Produktion",
"intro": "[MOCK] Als Produktionsleiter wissen Sie, wie teuer Stillstand ist. Unsere Roboter helfen.", "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"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'}") 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) total_combinations = len(industries) * len(personas)
processed = 0 processed = 0
for ind in industries: for ind in industries:
print(f"\n>>> Processing Industry: {ind.name} (Ops Secondary: {ind.ops_focus_secondary})")
for pers in personas: for pers in personas:
processed += 1 processed += 1
print(f"[{processed}/{total_combinations}] Check: {ind.name} x {pers.name}") 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: else:
try: try:
result = real_gemini_call(prompt) result = real_gemini_call(prompt)
# Basic Validation
if not result.get("subject") or not result.get("intro"): # Normalize Keys (Case-Insensitive)
print(" -> Invalid result structure. Skipping.") 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 continue
except Exception as e: except Exception as e:
@@ -190,16 +259,16 @@ def run_matrix_generation(dry_run: bool = True, force: bool = False, specific_in
new_entry = MarketingMatrix( new_entry = MarketingMatrix(
industry_id=ind.id, industry_id=ind.id,
persona_id=pers.id, persona_id=pers.id,
subject=result.get("subject"), subject=normalized_result.get("subject"),
intro=result.get("intro"), intro=normalized_result.get("intro"),
social_proof=result.get("social_proof") social_proof=normalized_result.get("social_proof")
) )
db.add(new_entry) db.add(new_entry)
print(f" -> Created new entry.") print(f" -> Created new entry.")
else: else:
existing.subject = result.get("subject") existing.subject = normalized_result.get("subject")
existing.intro = result.get("intro") existing.intro = normalized_result.get("intro")
existing.social_proof = result.get("social_proof") existing.social_proof = normalized_result.get("social_proof")
print(f" -> Updated entry.") print(f" -> Updated entry.")
db.commit() 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 "" return prop.get("select", {}).get("name", "") if prop.get("select") else ""
def extract_number(prop): def extract_number(prop):
if not prop: return None
return prop.get("number") return prop.get("number")
def sync_categories(token, session): def sync_categories(token, session):
@@ -135,6 +136,11 @@ def sync_industries(token, session):
industry.name = name industry.name = name
industry.description = extract_rich_text(props.get("Definition")) 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")) status = extract_select(props.get("Status"))
industry.status_notion = status industry.status_notion = status
industry.is_focus = (status == "P1 Focus Industry") industry.is_focus = (status == "P1 Focus Industry")
@@ -148,6 +154,9 @@ def sync_industries(token, session):
industry.scraper_keywords = extract_rich_text(props.get("Scraper Keywords")) industry.scraper_keywords = extract_rich_text(props.get("Scraper Keywords"))
industry.standardization_logic = extract_rich_text(props.get("Standardization Logic")) 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: Primary Product Category
relation = props.get("Primary Product Category", {}).get("relation", []) relation = props.get("Primary Product Category", {}).get("relation", [])
if relation: if relation:
@@ -158,6 +167,16 @@ def sync_industries(token, session):
else: else:
logger.warning(f"Related category {related_id} not found for industry {name}") 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 count += 1
session.commit() session.commit()

View File

@@ -66,43 +66,52 @@ class SuperOfficeClient:
logger.error(f"❌ Connection Error during token refresh: {e}") logger.error(f"❌ Connection Error during token refresh: {e}")
return None return None
def _get(self, endpoint): def _request_with_retry(self, method, endpoint, payload=None, retry=True):
"""Generic GET request.""" """Helper to handle 401 Unauthorized with auto-refresh."""
if not self.access_token: return None if not self.access_token:
try: if not self._refresh_access_token():
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 return None
def _put(self, endpoint, payload): url = f"{self.base_url}/{endpoint}"
"""Generic PUT request."""
if not self.access_token: return None
try: try:
resp = requests.put(f"{self.base_url}/{endpoint}", headers=self.headers, json=payload) if method == "GET":
resp.raise_for_status() resp = requests.get(url, headers=self.headers)
return resp.json() elif method == "POST":
except requests.exceptions.HTTPError as e: resp = requests.post(url, headers=self.headers, json=payload)
logger.error(f"❌ API PUT Error for {endpoint}: {e.response.text}") 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 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() resp.raise_for_status()
return resp.json() return resp.json()
except requests.exceptions.HTTPError as e: 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 return None
except Exception as e: 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 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 --- # --- Convenience Wrappers ---
def get_person(self, person_id): def get_person(self, person_id):

View File

@@ -115,7 +115,9 @@ def process_job(job, so_client: SuperOfficeClient):
try: try:
contact_details = so_client.get_contact(contact_id) contact_details = so_client.get_contact(contact_id)
if contact_details: if not contact_details:
raise ValueError(f"Contact {contact_id} not found (API returned None)")
crm_name = contact_details.get("Name") crm_name = contact_details.get("Name")
crm_website = contact_details.get("UrlAddress") crm_website = contact_details.get("UrlAddress")
if not crm_website and "Urls" in contact_details and contact_details["Urls"]: if not crm_website and "Urls" in contact_details and contact_details["Urls"]:
@@ -140,7 +142,10 @@ def process_job(job, so_client: SuperOfficeClient):
logger.error(f"Error mapping vertical ID {val_str}: {ex}") logger.error(f"Error mapping vertical ID {val_str}: {ex}")
except Exception as e: 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 --- # --- 3. Company Explorer Provisioning ---
ce_url = f"{settings.COMPANY_EXPLORER_URL}/api/provision/superoffice-contact" ce_url = f"{settings.COMPANY_EXPLORER_URL}/api/provision/superoffice-contact"

View File

@@ -182,9 +182,16 @@ http {
# Trailing Slash STRIPS the /connector/ prefix! # Trailing Slash STRIPS the /connector/ prefix!
# So /connector/dashboard -> /dashboard # So /connector/dashboard -> /dashboard
proxy_pass http://connector-superoffice:8000/; proxy_pass http://connector-superoffice:8000/;
# Standard Proxy Headers
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 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";
} }
} }
} }