[2ff88f42] Full End-to-End integration: Webhooks, Auto-Enrichment, Notion-Sync, UI updates and new Connector Architecture
This commit is contained in:
@@ -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)**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
# ==============================================================================
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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 ></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>}
|
||||
|
||||
11
company-explorer/init_schema_update.py
Normal file
11
company-explorer/init_schema_update.py
Normal file
@@ -0,0 +1,11 @@
|
||||
import sys
|
||||
import os
|
||||
|
||||
# Add backend to path so imports work
|
||||
sys.path.append(os.path.join(os.getcwd(), "company-explorer"))
|
||||
|
||||
from backend.database import init_db
|
||||
|
||||
print("Initializing Database Schema...")
|
||||
init_db()
|
||||
print("Done.")
|
||||
39
company-explorer/upgrade_schema.py
Normal file
39
company-explorer/upgrade_schema.py
Normal file
@@ -0,0 +1,39 @@
|
||||
import sqlite3
|
||||
import os
|
||||
|
||||
DB_PATH = "/app/companies_v3_fixed_2.db"
|
||||
|
||||
# If running outside container, adjust path
|
||||
if not os.path.exists(DB_PATH):
|
||||
DB_PATH = "companies_v3_fixed_2.db"
|
||||
|
||||
def upgrade():
|
||||
print(f"Upgrading database at {DB_PATH}...")
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
cursor = conn.cursor()
|
||||
|
||||
# 2. Add Columns to Contact
|
||||
try:
|
||||
cursor.execute("ALTER TABLE contacts ADD COLUMN so_contact_id INTEGER")
|
||||
print("✅ Added column: so_contact_id")
|
||||
except sqlite3.OperationalError as e:
|
||||
if "duplicate column" in str(e):
|
||||
print("ℹ️ Column so_contact_id already exists")
|
||||
else:
|
||||
print(f"❌ Error adding so_contact_id: {e}")
|
||||
|
||||
try:
|
||||
cursor.execute("ALTER TABLE contacts ADD COLUMN so_person_id INTEGER")
|
||||
print("✅ Added column: so_person_id")
|
||||
except sqlite3.OperationalError as e:
|
||||
if "duplicate column" in str(e):
|
||||
print("ℹ️ Column so_person_id already exists")
|
||||
else:
|
||||
print(f"❌ Error adding so_person_id: {e}")
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
print("Upgrade complete.")
|
||||
|
||||
if __name__ == "__main__":
|
||||
upgrade()
|
||||
Reference in New Issue
Block a user