[2ff88f42] Full End-to-End integration: Webhooks, Auto-Enrichment, Notion-Sync, UI updates and new Connector Architecture

This commit is contained in:
2026-02-19 16:05:52 +00:00
parent 262add256f
commit 17346b3fcb
21 changed files with 1107 additions and 203 deletions

View File

@@ -49,6 +49,14 @@ Um externen Diensten (wie der `lead-engine`) eine einfache und robuste Anbindung
| **`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").
@@ -105,6 +113,15 @@ Wir kapseln das neue Projekt vollständig ab ("Fork & Clean").
* `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)**

View File

@@ -32,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, ReportedMistake
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
@@ -42,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(
@@ -65,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]
@@ -84,6 +84,20 @@ class ReportMistakeRequest(BaseModel):
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():
@@ -100,6 +114,141 @@ def on_startup():
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,
@@ -234,6 +383,7 @@ def create_company(company: CompanyCreate, db: Session = Depends(get_db), userna
city=company.city,
country=company.country,
website=company.website,
crm_id=company.crm_id,
status="NEW"
)
db.add(new_company)
@@ -665,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

@@ -93,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
@@ -248,6 +252,30 @@ class ReportedMistake(Base):
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

@@ -72,6 +72,10 @@ 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 ---")
@@ -131,6 +135,16 @@ def sync():
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"))

View File

@@ -194,9 +194,6 @@ export function RoboticsSettings({ isOpen, onClose, apiBase }: RoboticsSettingsP
</span>
)}
</div>
<div className="flex flex-wrap gap-2 mt-1">
{ind.status_notion && <span className="text-[10px] border border-slate-300 dark:border-slate-700 px-1.5 rounded text-slate-500">{ind.status_notion}</span>}
</div>
</div>
<div className="text-right">
<div className="flex items-center gap-1.5 justify-end">
@@ -236,7 +233,16 @@ export function RoboticsSettings({ isOpen, onClose, apiBase }: RoboticsSettingsP
<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>}

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