mirror of
https://github.com/x1xhlol/system-prompts-and-models-of-ai-tools.git
synced 2026-06-18 23:39:34 +00:00
793 lines
34 KiB
Python
793 lines
34 KiB
Python
"""
|
||
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": "منصة تجارة إلكترونية سعودية راسخة ضمن الحجم المطلوب.",
|
||
},
|
||
],
|
||
}
|