system-prompts-and-models-o.../dealix/api/routers/prospect.py
2026-05-01 14:03:52 +03:00

793 lines
34 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
Prospect discovery endpoint — public, rate-limited.
POST /api/v1/prospect/discover
body: {"icp": str, "use_case": "sales|partnership|collaboration|investor|b2c_audience", "count": 10}
returns: ProspectResult JSON
POST /api/v1/prospect/demo
returns: a canned demo result (no LLM call) for instant landing UI preview
"""
from __future__ import annotations
import logging
from typing import Any
from fastapi import APIRouter, Body, HTTPException
from auto_client_acquisition.agents.prospector import (
MAX_COUNT,
USE_CASES,
ProspectorAgent,
)
from auto_client_acquisition.agents.rules_router import (
generate_messages as _rules_generate_messages,
route_account as _rules_route,
)
from auto_client_acquisition.connectors.google_search import google_search
from auto_client_acquisition.connectors.tech_detect import detect_stack, extract_contact_info
router = APIRouter(prefix="/api/v1/prospect", tags=["prospect"])
log = logging.getLogger(__name__)
_agent = ProspectorAgent()
@router.get("/use-cases")
async def list_use_cases() -> dict[str, Any]:
return {"use_cases": USE_CASES, "max_count": MAX_COUNT}
@router.post("/discover")
async def discover(body: dict[str, Any] = Body(...)) -> dict[str, Any]:
icp = str(body.get("icp") or "").strip()
use_case = str(body.get("use_case") or "sales").strip().lower()
count = int(body.get("count") or 10)
if len(icp) < 20:
raise HTTPException(
status_code=400,
detail="icp_too_short: provide at least 20 characters describing your ideal customer",
)
if len(icp) > 2000:
raise HTTPException(
status_code=400,
detail="icp_too_long: keep ICP under 2000 characters",
)
if use_case not in USE_CASES:
raise HTTPException(
status_code=400,
detail=f"unknown_use_case: {use_case}. Valid: {list(USE_CASES.keys())}",
)
if count < 1 or count > MAX_COUNT:
raise HTTPException(
status_code=400,
detail=f"count_out_of_range: 1..{MAX_COUNT}",
)
try:
result = await _agent.run(icp=icp, use_case=use_case, count=count)
except Exception as exc:
log.warning("prospector_llm_unavailable use_case=%s — serving degraded rules mode", use_case)
# Degraded mode: serve the canned demo with a status flag
demo_resp = await demo()
demo_resp["status"] = "degraded"
demo_resp["reason"] = "missing_llm_key"
demo_resp["hint"] = "Add GROQ_API_KEY (or ANTHROPIC_API_KEY) in Railway env 'Dealix' service 'web' to enable live discovery."
demo_resp["error_type"] = type(exc).__name__
return demo_resp
return result.to_dict()
@router.get("/search-diag")
async def search_diag() -> dict[str, Any]:
"""Diagnose env var presence without revealing values."""
import os
def _diag(value: str) -> dict[str, Any]:
return {"set": bool(value), "length": len(value)}
k = os.getenv("GOOGLE_SEARCH_API_KEY", "")
c = os.getenv("GOOGLE_SEARCH_CX", "")
gm = os.getenv("GOOGLE_MAPS_API_KEY", "")
tav = os.getenv("TAVILY_API_KEY", "")
fc = os.getenv("FIRECRAWL_API_KEY", "")
hu = os.getenv("HUNTER_API_KEY", "")
ab = os.getenv("ABSTRACT_API_KEY", "")
wp = os.getenv("WAPPALYZER_API_KEY", "")
serp = os.getenv("SERPAPI_API_KEY", "")
apify = os.getenv("APIFY_TOKEN", "")
grq = os.getenv("GROQ_API_KEY", "")
ant = os.getenv("ANTHROPIC_API_KEY", "")
oai = os.getenv("OPENAI_API_KEY", "")
sd = os.getenv("SENTRY_DSN", "")
db = os.getenv("DATABASE_URL", "")
sg = os.getenv("SENDGRID_API_KEY", "")
wa = os.getenv("WHATSAPP_ACCESS_TOKEN", "")
m = os.getenv("MOYASAR_SECRET_KEY", "")
w = os.getenv("MOYASAR_WEBHOOK_SECRET", "")
# All env vars whose names start with target prefixes — helps detect typos
related = sorted([
name for name in os.environ.keys()
if name.startswith((
"GOOGLE_", "MOYASAR_", "ANTHROPIC_", "OPENAI_", "GROQ_", "POSTHOG_",
"SENTRY_", "DATABASE_", "TAVILY_", "FIRECRAWL_", "HUNTER_", "ABSTRACT_",
"WAPPALYZER_", "SERPAPI_", "APIFY_", "SENDGRID_", "WHATSAPP_",
"APP_URL", "PORT", "RAILWAY_",
))
])
# Tier readiness summary
tier1_ready = bool(db) and bool(grq or ant or oai) and bool(k and c) and bool(sd)
tier2_ready = bool(gm) and (bool(tav) or bool(fc) or bool(hu))
return {
# ── Layer 1 — Required now ──
"DATABASE_URL": _diag(db),
"GOOGLE_SEARCH_API_KEY": {**_diag(k), "prefix": (k[:6] + "...") if k else ""},
"GOOGLE_SEARCH_CX": {**_diag(c), "prefix": (c[:6] + "...") if c else ""},
"GROQ_API_KEY": _diag(grq),
"ANTHROPIC_API_KEY": _diag(ant),
"OPENAI_API_KEY": _diag(oai),
"SENTRY_DSN": _diag(sd),
# ── Layer 2 — Lead discovery power ──
"GOOGLE_MAPS_API_KEY": {**_diag(gm), "prefix": (gm[:6] + "...") if gm else ""},
"TAVILY_API_KEY": _diag(tav),
"FIRECRAWL_API_KEY": _diag(fc),
"HUNTER_API_KEY": _diag(hu),
"ABSTRACT_API_KEY": _diag(ab),
"WAPPALYZER_API_KEY": _diag(wp),
"SERPAPI_API_KEY": _diag(serp),
"APIFY_TOKEN": _diag(apify),
# ── Layer 3 — Channels ──
"SENDGRID_API_KEY": _diag(sg),
"WHATSAPP_ACCESS_TOKEN": _diag(wa),
# ── Payments ──
"MOYASAR_SECRET_KEY": {**_diag(m), "prefix": (m[:6] + "...") if m else ""},
"MOYASAR_WEBHOOK_SECRET":_diag(w),
# ── Tier readiness summary ──
"tier1_ready": tier1_ready,
"tier2_ready": tier2_ready,
"all_visible_env_var_names_starting_with_known_prefixes": related,
"railway_environment_name": os.getenv("RAILWAY_ENVIRONMENT_NAME", "(not set)"),
"railway_service_name": os.getenv("RAILWAY_SERVICE_NAME", "(not set)"),
"railway_project_name": os.getenv("RAILWAY_PROJECT_NAME", "(not set)"),
"hint": (
"ready_to_launch" if tier1_ready and tier2_ready else
"tier1_only" if tier1_ready else
"set_DATABASE_URL_first" if not db else
"set_GOOGLE_SEARCH_API_KEY_and_CX" if not (k and c) else
"set_GROQ_or_ANTHROPIC_or_OPENAI" if not (grq or ant or oai) else
"almost_there"
),
}
@router.post("/search")
async def search(body: dict[str, Any] = Body(...)) -> dict[str, Any]:
"""
Run a Google Custom Search query using server-side keys.
Body: {"query": "...", "num": 10, "site": "linkedin.com" (optional), "lang": "ar"|"en"}
Returns: SearchResponse JSON.
"""
q = str(body.get("query") or "").strip()
if len(q) < 3 or len(q) > 500:
raise HTTPException(status_code=400, detail="query_length_out_of_range")
num = int(body.get("num") or 10)
if num < 1 or num > 10:
raise HTTPException(status_code=400, detail="num_out_of_range: 1..10")
site = body.get("site")
site = str(site).strip() if site else None
lang = body.get("lang")
lang = str(lang).strip().lower() if lang else None
if lang and lang not in {"ar", "en", "fr", "es"}:
raise HTTPException(status_code=400, detail="unsupported_lang")
try:
resp = await google_search(q, num=num, site=site, lang=lang, timeout=10.0)
except Exception as exc: # noqa: BLE001
log.exception("google_search_call_failed q=%r", q)
raise HTTPException(status_code=502, detail="search_error") from exc
if resp.status == "no_keys":
raise HTTPException(status_code=503, detail="search_not_configured")
return resp.to_dict()
@router.post("/enrich-tech")
async def enrich_tech(body: dict[str, Any] = Body(...)) -> dict[str, Any]:
"""
Detect tech stack for a domain using Dealix native detector (free, self-hosted).
Body: {"domain": "foodics.com", "extra_paths": ["/careers", "/contact"]}
"""
domain = str(body.get("domain") or "").strip()
extra = body.get("extra_paths") or []
if not isinstance(extra, list):
extra = []
extra = [str(p)[:80] for p in extra[:5]]
if not domain or "." not in domain or len(domain) > 200:
raise HTTPException(status_code=400, detail="invalid_domain")
try:
result = await detect_stack(domain, timeout=10.0, extra_paths=extra)
except Exception as exc: # noqa: BLE001
log.exception("tech_detect_failed domain=%s", domain)
raise HTTPException(status_code=502, detail="tech_detect_error") from exc
return result.to_dict()
@router.post("/enrich-domain")
async def enrich_domain(body: dict[str, Any] = Body(...)) -> dict[str, Any]:
"""
End-to-end enrichment: given a domain + opportunity hint, combine tech stack
detection + LLM analysis to return a full lead record per LEAD_OUTPUT_SCHEMA.
Body:
{
"domain": "foodics.com",
"opportunity_hint": "DIRECT_CUSTOMER|AGENCY_PARTNER|..." (optional),
"context_notes": "optional extra human context"
}
Returns: full lead object (opportunity_type, scores, signals, outreach opening, etc.)
"""
domain = str(body.get("domain") or "").strip()
opportunity_hint = str(body.get("opportunity_hint") or "").strip().upper()
context_notes = str(body.get("context_notes") or "").strip()[:1000]
if not domain or "." not in domain or len(domain) > 200:
raise HTTPException(status_code=400, detail="invalid_domain")
# Step 1 — tech detection (free, always available)
try:
tech = await detect_stack(domain, timeout=10.0, extra_paths=["/careers", "/about"])
except Exception:
log.exception("tech_detect_failed domain=%s", domain)
tech = None
tech_dict = tech.to_dict() if tech else {"tools": [], "signals": [], "status": "unavailable"}
# Step 2 — LLM analysis using ProspectorAgent-style prompt but domain-scoped
from auto_client_acquisition.agents.prospector import ProspectorAgent, USE_CASES
agent = ProspectorAgent()
icp_text = (
f"الشركة: {domain}\n"
f"الأدوات المكتشفة عبر tech detector: "
f"{', '.join(t['name'] for t in tech_dict.get('tools', []))}\n"
f"الإشارات المستخرجة: "
f"{', '.join(s['evidence'] for s in tech_dict.get('signals', []))}\n"
+ (f"سياق إضافي: {context_notes}\n" if context_notes else "")
+ (f"تلميح لنوع الفرصة: {opportunity_hint}\n" if opportunity_hint else "")
+ "\nحلّل هذه الشركة تحديداً: صنّف نوع الفرصة، احسب ال 4 scores، اقترح sequence من الخطوات، وأعد نفس شكل JSON كما هو محدد."
)
use_case = "sales" # default; the LLM will classify opportunity_type freely
try:
result = await agent.run(icp=icp_text, use_case=use_case, count=1)
leads = result.leads
lead_dict = leads[0].to_dict() if leads else None
search_notes = result.search_notes
status = "ok"
except Exception:
log.warning("enrich_domain_llm_unavailable domain=%s — serving tech-only + rules", domain)
# Degraded: run rules router over the tech signals to still produce actionable lead
signals_for_router = [
{"name": s.get("name", ""), "weight": s.get("weight", 0), "evidence": s.get("evidence", "")}
for s in tech_dict.get("signals", [])
]
res = _rules_route(
company=domain.split(".")[0].replace("-", " ").title(),
sector="",
country="SA",
domain=domain,
signals=signals_for_router,
tags="",
decision_maker=None,
)
# Also produce messages deterministically
msgs = _rules_generate_messages(
company=domain.split(".")[0].replace("-", " ").title(),
decision_maker=None,
opportunity_type=res.opportunity_type,
signals=signals_for_router,
)
lead_dict = {
**res.to_dict(),
"company_en": domain.split(".")[0].replace("-", " ").title(),
"company_ar": "",
"website": f"https://{domain}",
"outreach_opening": msgs["linkedin"][:280],
"signals": signals_for_router,
"confidence": 60,
}
search_notes = "degraded mode — rules router + tech detect only (no LLM key)"
status = "degraded"
return {
"domain": domain,
"tech": tech_dict,
"lead": lead_dict,
"search_notes": search_notes,
"fetched_at": tech_dict.get("fetched_at"),
"status": status,
}
@router.post("/route")
async def route_endpoint(body: dict[str, Any] = Body(...)) -> dict[str, Any]:
"""
Deterministic rule-based router — classify + score + route an account without LLM.
Body: {company, sector?, country?, domain?, signals?, tags?, decision_maker?, size_hint?, is_government?, desired_goal?}
"""
company = str(body.get("company") or "").strip()
if not company:
raise HTTPException(status_code=400, detail="company_required")
res = _rules_route(
company=company,
sector=str(body.get("sector") or ""),
country=str(body.get("country") or ""),
domain=str(body.get("domain") or ""),
signals=body.get("signals") or [],
tags=str(body.get("tags") or ""),
decision_maker=body.get("decision_maker"),
size_hint=str(body.get("size_hint") or ""),
is_government=bool(body.get("is_government") or False),
desired_goal=body.get("desired_goal"),
)
return {"mode": "rules", "result": res.to_dict()}
@router.post("/score")
async def score_endpoint(body: dict[str, Any] = Body(...)) -> dict[str, Any]:
"""
Score an account against the 100-pt ICP model. Same inputs as /route.
Returns only the score breakdown (no messages).
"""
company = str(body.get("company") or "").strip()
if not company:
raise HTTPException(status_code=400, detail="company_required")
res = _rules_route(
company=company,
sector=str(body.get("sector") or ""),
country=str(body.get("country") or ""),
domain=str(body.get("domain") or ""),
signals=body.get("signals") or [],
tags=str(body.get("tags") or ""),
decision_maker=body.get("decision_maker"),
size_hint=str(body.get("size_hint") or ""),
is_government=bool(body.get("is_government") or False),
)
r = res.to_dict()
return {
"company": company,
"fit_score": r["fit_score"],
"intent_score": r["intent_score"],
"access_score": r["access_score"],
"revenue_score": r["revenue_score"],
"priority_score": r["priority_score"],
"priority_tier": r["priority_tier"],
"risk_level": r["risk_level"],
"opportunity_type": r["opportunity_type"],
"reason": r["reason"],
}
@router.post("/message")
async def message_endpoint(body: dict[str, Any] = Body(...)) -> dict[str, Any]:
"""
Generate templated, signal-aware Arabic outreach for an account.
Body: {company, decision_maker?, opportunity_type?, signals?}
Returns: {linkedin, email, whatsapp_warm_only, follow_up_plus_2/5/10}
"""
company = str(body.get("company") or "").strip()
if not company:
raise HTTPException(status_code=400, detail="company_required")
opp = str(body.get("opportunity_type") or "").strip().upper()
if not opp:
# Fall back: classify via rules
res = _rules_route(
company=company,
sector=str(body.get("sector") or ""),
tags=str(body.get("tags") or ""),
signals=body.get("signals") or [],
)
opp = res.opportunity_type
msgs = _rules_generate_messages(
company=company,
decision_maker=body.get("decision_maker"),
opportunity_type=opp,
signals=body.get("signals") or [],
)
return {"mode": "rules", "opportunity_type": opp, "messages": msgs}
@router.post("/bulk-enrich")
async def bulk_enrich(body: dict[str, Any] = Body(...)) -> dict[str, Any]:
"""
Bulk tech-detect enrichment for a list of domains.
Body: {"domains": ["foodics.com", "salla.sa", ...], "concurrency": 5}
Returns: {"results": {domain: tech_result, ...}, "summary": {...}}
Hard limit: 25 domains per request (prevent abuse).
"""
domains_raw = body.get("domains") or []
if not isinstance(domains_raw, list):
raise HTTPException(status_code=400, detail="domains_must_be_list")
domains = [str(d).strip() for d in domains_raw if d and "." in str(d)]
domains = list(dict.fromkeys(domains))[:25] # dedupe, cap
if not domains:
raise HTTPException(status_code=400, detail="no_valid_domains")
concurrency = int(body.get("concurrency") or 5)
concurrency = max(1, min(10, concurrency))
import asyncio as _asyncio
sem = _asyncio.Semaphore(concurrency)
async def _one(d: str) -> tuple[str, dict]:
async with sem:
try:
r = await detect_stack(d, timeout=10.0)
return d, r.to_dict()
except Exception as exc: # noqa: BLE001
return d, {"status": "error", "error": str(exc), "domain": d}
pairs = await _asyncio.gather(*(_one(d) for d in domains))
results = dict(pairs)
total_tools = sum(len(r.get("tools", [])) for r in results.values())
total_signals = sum(len(r.get("signals", [])) for r in results.values())
ok_count = sum(1 for r in results.values() if r.get("status") == "ok")
return {
"summary": {
"domains_requested": len(domains),
"ok_count": ok_count,
"total_tools_detected": total_tools,
"total_signals_detected": total_signals,
},
"results": results,
}
@router.post("/contacts")
async def contacts(body: dict[str, Any] = Body(...)) -> dict[str, Any]:
"""
Extract publicly listed contact info (emails, phones, WhatsApp, social) from a company's public pages.
LEGAL: public pages only; business contact only; no PII from private / authenticated sources.
Body: {"domain": "foodics.com"}
"""
domain = str(body.get("domain") or "").strip()
if not domain or "." not in domain or len(domain) > 200:
raise HTTPException(status_code=400, detail="invalid_domain")
try:
return await extract_contact_info(domain, timeout=10.0)
except Exception as exc:
log.exception("contacts_failed domain=%s", domain)
raise HTTPException(status_code=502, detail="contact_extraction_error") from exc
@router.post("/inbound/handle")
async def inbound_handle(body: dict[str, Any] = Body(...)) -> dict[str, Any]:
"""
Autonomous inbound handler — given an incoming lead message, classify, decide,
generate Arabic response, pick next_action.
Body:
{
"channel": "whatsapp|email|web_chat|linkedin|sms",
"from": "+966501234567" | "ali@example.com",
"company": "optional — extracted from domain if known",
"message": "the actual customer inquiry"
}
Returns:
{
"classification": "interested|price|demo|later|objection|...",
"opportunity_type": "DIRECT_CUSTOMER|...",
"response_ar": "...",
"next_action": "BOOK_DEMO|PREPARE_DM|...",
"should_escalate_to_human": bool,
"tracker_update": {status, sent_at, next_followup}
}
"""
channel = str(body.get("channel") or "unknown").lower()
sender = str(body.get("from") or "").strip()
company = str(body.get("company") or "").strip()
message = str(body.get("message") or "").strip()
if len(message) < 2:
raise HTTPException(status_code=400, detail="message_required")
# Very simple offline classifier (same regex rules from scripts/dealix_reply_classifier.py)
import re
text = message.lower()
classification = "interested" # default
rules = [
("wants_demo", r"demo|ديمو|عرض|تجربة"),
("price", r"كم\s*(السعر|يكلف|المبلغ)|السعر|price|pricing|كم\s*ريال"),
("send_details", r"ارسل|أرسل|تفاصيل|details|deck|presentation"),
("later", r"بعدين|لاحق|later|not\s*now|رمضان"),
("opt_out", r"أوقف|إيقاف|stop|unsubscribe|لا\s*شكراً|انهاء"),
("arabic_concern", r"العربي|عربي\s*(مضبوط|طبيعي|سيء|سيئ|رديء)|arabic.*quality|خليجي|لهجة"),
("not_relevant", r"مو\s*مناسب|not\s*relevant|غير\s*مناسب|لا\s*نحتاج"),
("budget_objection", r"ميزانية|budget|غالي|مكلف"),
("already_has_crm", r"crm|salesforce|hubspot|zoho"),
("arabic_concern", r"لهجة|arabic.*quality|خليجي"),
("privacy_concern", r"خصوصية|pdpl|privacy|بيانات"),
("partnership_interest",r"شراكة|partner|وكالة|reseller"),
("referral_opportunity",r"أعرف|رشح|referral|intro"),
]
for cat, pat in rules:
if re.search(pat, text):
classification = cat
break
# If very short greeting, treat as interested
if len(text) < 10 and any(g in text for g in ("مرحب", "سلام", "هلا", "hi", "hello")):
classification = "interested"
# Decide opportunity type from company name keywords
opp_type = "DIRECT_CUSTOMER"
if any(k in (company or "").lower() for k in ["agency", "وكالة", "marketing"]):
opp_type = "AGENCY_PARTNER"
elif any(k in (company or "").lower() for k in ["vc", "capital", "ventures", "fund"]):
opp_type = "INVESTOR_OR_ADVISOR"
# Build response
CAL = "https://calendly.com/sami-assiri11/dealix-demo"
responses = {
"opt_out": "تمام، تم إيقاف الرسائل. شكراً لوقتك.",
"interested": f"هلا! شكراً على اهتمامك. خلني أحجز معك 20 دقيقة demo بدون أي التزام — تقدر تختار موعدك هنا: {CAL}",
"wants_demo": f"ممتاز، نسوي demo. 20 دقيقة، اختار موعد: {CAL}",
"price": f"Starter 999/شهر، Growth 2,999، Scale 7,999. في pilot بريال × 7 أيام بدون التزام. 20 دقيقة demo أفصّل الباقة المناسبة: {CAL}",
"send_details": f"تفاصيل سريعة: Dealix = AI sales rep بالعربي الخليجي، يرد على leads خلال 45 ثانية، يؤهّل، ويحجز demos. الأفضل نشوفه معاً في 20 دقيقة على سيناريو شركتكم: {CAL}\nأو تصفح: https://dealix.me",
"later": "تمام. متى الوقت المناسب يحتمل يكون؟ سأرجع في نفس اليوم بالظبط.",
"not_relevant": "أحترم ذلك. سؤال أخير: هل تعرف شخص/شركة سعودية قد تستفيد من AI sales rep بالعربي؟ 10% من MRR لـ 12 شهر لكل referral. شكراً على وقتك.",
"budget_objection": "أفهم. عرضنا pilot بريال واحد × 7 أيام — قابل للاسترداد 100% — هدفه يثبت ROI قبل أي التزام. مناسب؟",
"already_has_crm": "Dealix ما يستبدل CRM — يشتغل كطبقة أولى فوقه. يرد بالعربي، يؤهّل، ويسلّم الـ CRM قائمة leads جاهزة. تكامل مباشر HubSpot/Salesforce/Zoho/webhook. 20 دقيقة demo: " + CAL,
"arabic_concern": f"نقطة مهمة. Dealix خليجي حقيقي، ما يكتب 'حضرتك' و'تعطفكم'. 20 دقيقة demo تختبره بنفسك على سيناريو شركتكم: {CAL}",
"privacy_concern": f"مصمم PDPL-compliant: بياناتكم في سيرفرات السعودية، opt-out في كل email، audit log كامل. 20 دقيقة نناقش compliance + demo: {CAL}",
"partnership_interest": f"ممتاز. 3 tiers:\n- Referral: 10% MRR × 12 شهر\n- Agency: setup 3-15K + 20-30% MRR\n- White-label (Scale)\n20 دقيقة partner call: https://dealix.me/partners.html",
"referral_opportunity": "شكراً! 10% من MRR × 12 شهر لأي عميل يجي عبرك. ممكن تخبرني بمعلومات الشركة والشخص؟",
}
response_ar = responses.get(classification, responses["interested"])
# Decide next action
action_map = {
"opt_out": "STOP_CONTACT",
"interested": "BOOK_DEMO",
"wants_demo": "BOOK_DEMO",
"price": "BOOK_DEMO",
"send_details": "PREPARE_DEMO_FLOW",
"later": "FOLLOW_UP",
"not_relevant": "STOP_CONTACT",
"budget_objection": "ROUTE_TO_MANUAL_PAYMENT",
"already_has_crm": "BOOK_DEMO",
"arabic_concern": "PREPARE_DEMO_FLOW",
"privacy_concern": "PREPARE_DEMO_FLOW",
"partnership_interest": "PREPARE_PARTNER_PITCH",
"referral_opportunity": "FOLLOW_UP",
}
next_action = action_map.get(classification, "ASK_HUMAN_FINAL_SEND")
# Escalation rule
escalate = classification in ("partnership_interest",) or opp_type == "INVESTOR_OR_ADVISOR"
from datetime import datetime, timedelta
now = datetime.utcnow().isoformat() + "Z"
next_followup = (datetime.utcnow() + timedelta(days=2)).date().isoformat()
return {
"classification": classification,
"opportunity_type": opp_type,
"response_ar": response_ar,
"next_action": next_action,
"should_escalate_to_human": escalate,
"channel_recommended_reply": channel,
"tracker_update": {
"reply_received_at": now,
"classification": classification,
"next_followup": next_followup,
"status": "engaged",
},
"compliance_note": (
"Response auto-generated using rules-based classifier + templated Khaliji Arabic. "
"No LLM used (deterministic). No personal PII stored beyond the inbound message. "
"Human review recommended for partnership/investor classifications."
),
}
async def _run_inbound_handler(channel: str, sender: str, company: str, message: str) -> dict[str, Any]:
"""Shared internal handler used by all channel webhooks."""
return await inbound_handle({
"channel": channel,
"from": sender,
"company": company,
"message": message,
})
@router.post("/inbound/whatsapp")
async def inbound_whatsapp(body: dict[str, Any] = Body(...)) -> dict[str, Any]:
"""
WhatsApp Business API webhook handler.
Expected payload format (Meta WhatsApp Cloud API):
{"entry":[{"changes":[{"value":{"messages":[{"from":"+966...","text":{"body":"..."}}]}}]}]}
Or simplified: {"from":"+966...","message":"..."}
"""
msg = ""
sender = ""
# Try both simple and Meta formats
if "entry" in body:
try:
m = body["entry"][0]["changes"][0]["value"]["messages"][0]
sender = str(m.get("from") or "")
msg = str(m.get("text", {}).get("body") or m.get("body") or "")
except (KeyError, IndexError, TypeError):
pass
msg = msg or str(body.get("message") or "")
sender = sender or str(body.get("from") or "")
if not msg:
raise HTTPException(status_code=400, detail="no_message_body")
result = await _run_inbound_handler("whatsapp", sender, str(body.get("company", "")), msg)
result["send_reply_instruction"] = (
"POST the response_ar via WhatsApp Business Cloud API: "
"POST https://graph.facebook.com/v17.0/{PHONE_NUMBER_ID}/messages "
"with { messaging_product: 'whatsapp', to: sender, text: { body: response_ar } }"
)
return result
@router.post("/inbound/email")
async def inbound_email(body: dict[str, Any] = Body(...)) -> dict[str, Any]:
"""
Email inbound webhook (SendGrid Inbound Parse / Mailgun Routes format).
Expected: {"from":"ali@example.com","subject":"...","text":"..."} or SendGrid inbound format.
"""
sender = str(body.get("from") or body.get("sender") or "")
msg = str(body.get("text") or body.get("body-plain") or body.get("message") or "")
subject = str(body.get("subject") or "")
if subject and msg:
combined = f"[{subject}] {msg}"
else:
combined = msg or subject
if not combined:
raise HTTPException(status_code=400, detail="no_message_body")
result = await _run_inbound_handler("email", sender, str(body.get("company", "")), combined)
result["send_reply_instruction"] = (
"Reply via Gmail API / SendGrid / SES — include opt-out footer "
"'لإيقاف الرسائل: رد بـ لا شكراً'"
)
return result
@router.post("/inbound/form")
async def inbound_form(body: dict[str, Any] = Body(...)) -> dict[str, Any]:
"""
Generic web-form submission handler. Feeds directly into /inbound/handle.
Expected: {"name","email","company","message","source":"web_form"}
"""
name = str(body.get("name") or "")
email = str(body.get("email") or "")
company = str(body.get("company") or "")
message = str(body.get("message") or "")
if not message:
raise HTTPException(status_code=400, detail="message_required")
sender = email or name
result = await _run_inbound_handler("web_form", sender, company, message)
result["send_reply_instruction"] = (
"Display response_ar inline in form confirmation. Also auto-send email reply "
"with response_ar + Calendly link."
)
# Also create a lead record via pipeline if email + company known
if email and company:
result["also_created_lead"] = True
result["lead_hint"] = "POST /api/v1/leads with this payload to persist"
return result
@router.post("/inbound/sms")
async def inbound_sms(body: dict[str, Any] = Body(...)) -> dict[str, Any]:
"""
SMS inbound webhook (Twilio format).
Expected: {"From":"+966...","Body":"..."} or {"from","message"}
"""
sender = str(body.get("From") or body.get("from") or "")
msg = str(body.get("Body") or body.get("message") or "")
if not msg:
raise HTTPException(status_code=400, detail="no_message_body")
result = await _run_inbound_handler("sms", sender, str(body.get("company", "")), msg)
result["send_reply_instruction"] = (
"Reply via Twilio / Unifonic / STC. Keep SMS ≤ 160 chars; long messages via WhatsApp link."
)
# SMS replies should be SHORTER
if result.get("response_ar") and len(result["response_ar"]) > 160:
result["response_ar_short"] = result["response_ar"][:140] + "... رابط: https://dealix.me"
return result
@router.post("/inbound/linkedin")
async def inbound_linkedin(body: dict[str, Any] = Body(...)) -> dict[str, Any]:
"""
LinkedIn manual-capture webhook (Sami pastes reply content; Dealix classifies + suggests reply).
NOT auto-send (LinkedIn ToS). Human final-send required.
Expected: {"from":"...","profile_url":"...","message":"..."}
"""
sender = str(body.get("from") or body.get("profile_url") or "")
msg = str(body.get("message") or "")
if not msg:
raise HTTPException(status_code=400, detail="no_message_body")
result = await _run_inbound_handler("linkedin", sender, str(body.get("company", "")), msg)
result["send_reply_instruction"] = (
"⚠️ LinkedIn = HUMAN FINAL SEND ONLY (ToS compliance). "
"Show response_ar to Sami, Sami pastes manually into LinkedIn DM. NO automation."
)
result["should_escalate_to_human"] = True # always for LinkedIn
return result
@router.post("/demo")
async def demo() -> dict[str, Any]:
"""Canned demo response for landing UI preview. No LLM call."""
return {
"use_case": "sales",
"icp": "شركات SaaS سعودية B2B بحجم 20-100 موظف تبيع للمطاعم",
"count_requested": 3,
"count_returned": 3,
"search_notes": "نتائج توضيحية — جرب الواجهة الحقيقية للحصول على قائمة مخصصة لمواصفاتك.",
"leads": [
{
"company_ar": "فودكس",
"company_en": "Foodics",
"industry": "SaaS للمطاعم",
"est_size": "200-1000",
"website": "https://www.foodics.com",
"linkedin": "https://www.linkedin.com/company/foodics",
"decision_maker_hints": ["Ahmad Al-Zaini — CEO", "Mosab Alothmani — Co-founder"],
"signals": ["جولة Series C بـ $170M 2025", "توسع في الخليج وشمال أفريقيا"],
"outreach_opening": "أحمد، مبروك Series C — 170M = فرصة مضاعفة السرعة في onboarding العملاء الجدد.",
"fit_score": 92,
"confidence": 90,
"evidence": "شركة SaaS سعودية واضحة، تستهدف restaurant operators، بحجم يطابق الـ ICP.",
},
{
"company_ar": "رُكاز",
"company_en": "Rekaz",
"industry": "SaaS للـ SMB",
"est_size": "10-50",
"website": "https://rekaz.io",
"linkedin": None,
"decision_maker_hints": ["Abdullah Al-Shalan — Founder"],
"signals": ["منصة متخصصة في إدارة المستودعات للتجار"],
"outreach_opening": "عبدالله، رُكاز تبني الطبقة التشغيلية للتاجر السعودي — هذا تماماً مكان AI sales rep بالعربي.",
"fit_score": 85,
"confidence": 75,
"evidence": "SMB-focused SaaS سعودي ضمن الحجم المطلوب.",
},
{
"company_ar": "زد",
"company_en": "Zid",
"industry": "E-commerce Platform",
"est_size": "200-1000",
"website": "https://zid.sa",
"linkedin": "https://www.linkedin.com/company/zidsa",
"decision_maker_hints": ["Sultan Mofarreh — Co-founder"],
"signals": ["منافس لسلة مع 15K تاجر+", "ركّز على SMB merchants"],
"outreach_opening": "سلطان، 15K تاجر = فرصة توزيع هائلة لـ AI sales rep داخل zid marketplace.",
"fit_score": 88,
"confidence": 85,
"evidence": "منصة تجارة إلكترونية سعودية راسخة ضمن الحجم المطلوب.",
},
],
}