mirror of
https://github.com/x1xhlol/system-prompts-and-models-of-ai-tools.git
synced 2026-06-17 23:09:35 +00:00
Dealix OS Frontend implementation v1
This commit is contained in:
parent
1e3086d220
commit
1744cb7134
@ -1,42 +1,114 @@
|
||||
# Database
|
||||
# ═══════════════════════════════════════════════
|
||||
# Dealix — AI Revenue Operating System
|
||||
# Environment Configuration
|
||||
# ═══════════════════════════════════════════════
|
||||
|
||||
# ── App ───────────────────────────────────────
|
||||
APP_NAME=Dealix
|
||||
APP_NAME_AR=ديل اي اكس
|
||||
ENVIRONMENT=development
|
||||
DEFAULT_TIMEZONE=Asia/Riyadh
|
||||
DEFAULT_CURRENCY=SAR
|
||||
DEFAULT_LOCALE=ar
|
||||
|
||||
# ── Database ──────────────────────────────────
|
||||
DB_NAME=salesflow
|
||||
DB_USER=salesflow
|
||||
DB_PASSWORD=change_me_in_production
|
||||
DATABASE_URL=postgresql+asyncpg://salesflow:change_me_in_production@db:5432/salesflow
|
||||
DB_POOL_SIZE=20
|
||||
DB_MAX_OVERFLOW=10
|
||||
|
||||
# Redis
|
||||
# ── Redis ─────────────────────────────────────
|
||||
REDIS_URL=redis://redis:6379/0
|
||||
REDIS_CACHE_TTL=3600
|
||||
|
||||
# Security
|
||||
# ── Security ──────────────────────────────────
|
||||
SECRET_KEY=change-this-to-a-random-secret-key-in-production
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES=30
|
||||
REFRESH_TOKEN_EXPIRE_DAYS=7
|
||||
|
||||
# API
|
||||
# ── URLs ──────────────────────────────────────
|
||||
API_URL=http://localhost:8000
|
||||
FRONTEND_URL=http://localhost:3000
|
||||
WEBHOOK_BASE_URL=http://localhost:8000/api/v1/webhooks
|
||||
|
||||
# WhatsApp Business API
|
||||
# ── LLM Providers (AI Engine) ─────────────────
|
||||
# Primary: OpenAI
|
||||
OPENAI_API_KEY=sk-your-openai-key
|
||||
OPENAI_MODEL=gpt-4o
|
||||
OPENAI_EMBEDDING_MODEL=text-embedding-3-small
|
||||
OPENAI_MAX_TOKENS=4096
|
||||
OPENAI_TEMPERATURE=0.7
|
||||
|
||||
# Secondary: Groq (fast inference)
|
||||
GROQ_API_KEY=gsk_your-groq-key
|
||||
GROQ_MODEL=llama-3.3-70b-versatile
|
||||
GROQ_MAX_TOKENS=4096
|
||||
|
||||
# Fallback: Ollama (local)
|
||||
OLLAMA_BASE_URL=http://localhost:11434
|
||||
OLLAMA_MODEL=qwen2.5:7b
|
||||
|
||||
# LLM Strategy
|
||||
LLM_PRIMARY_PROVIDER=openai
|
||||
LLM_FALLBACK_PROVIDER=groq
|
||||
LLM_CACHE_ENABLED=true
|
||||
LLM_RATE_LIMIT_RPM=60
|
||||
|
||||
# ── WhatsApp Business API ─────────────────────
|
||||
WHATSAPP_API_TOKEN=
|
||||
WHATSAPP_PHONE_NUMBER_ID=
|
||||
WHATSAPP_BUSINESS_ACCOUNT_ID=
|
||||
WHATSAPP_VERIFY_TOKEN=
|
||||
WHATSAPP_API_VERSION=v21.0
|
||||
|
||||
# Email (SendGrid or SMTP)
|
||||
# ── Email ─────────────────────────────────────
|
||||
EMAIL_PROVIDER=smtp
|
||||
SMTP_HOST=smtp.gmail.com
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=
|
||||
SMTP_PASSWORD=
|
||||
SENDGRID_API_KEY=
|
||||
EMAIL_FROM_NAME=Dealix
|
||||
EMAIL_FROM_ADDRESS=noreply@dealix.sa
|
||||
|
||||
# SMS (Unifonic - Saudi)
|
||||
# ── SMS (Unifonic - Saudi) ────────────────────
|
||||
UNIFONIC_APP_SID=
|
||||
UNIFONIC_SENDER_ID=Dealix
|
||||
|
||||
# App Settings
|
||||
APP_NAME=Dealix
|
||||
APP_NAME_AR=ديل اي اكس
|
||||
DEFAULT_TIMEZONE=Asia/Riyadh
|
||||
DEFAULT_CURRENCY=SAR
|
||||
DEFAULT_LOCALE=ar
|
||||
# ── Voice AI ──────────────────────────────────
|
||||
VOICE_PROVIDER=elevenlabs
|
||||
ELEVENLABS_API_KEY=
|
||||
ELEVENLABS_VOICE_ID=
|
||||
AZURE_SPEECH_KEY=
|
||||
AZURE_SPEECH_REGION=uaenorth
|
||||
|
||||
# ── CRM Integrations ─────────────────────────
|
||||
# Salesforce
|
||||
SALESFORCE_CLIENT_ID=
|
||||
SALESFORCE_CLIENT_SECRET=
|
||||
SALESFORCE_REDIRECT_URI=
|
||||
|
||||
# HubSpot
|
||||
HUBSPOT_CLIENT_ID=
|
||||
HUBSPOT_CLIENT_SECRET=
|
||||
HUBSPOT_API_KEY=
|
||||
|
||||
# ── Calendar ──────────────────────────────────
|
||||
GOOGLE_CALENDAR_CREDENTIALS=
|
||||
MICROSOFT_CLIENT_ID=
|
||||
MICROSOFT_CLIENT_SECRET=
|
||||
|
||||
# ── Payment (Moyasar - Saudi) ─────────────────
|
||||
PAYMENT_PROVIDER=moyasar
|
||||
MOYASAR_API_KEY=
|
||||
MOYASAR_PUBLISHABLE_KEY=
|
||||
STRIPE_SECRET_KEY=
|
||||
STRIPE_PUBLISHABLE_KEY=
|
||||
|
||||
# ── Agent Configuration ───────────────────────
|
||||
AGENT_PROMPTS_DIR=ai-agents/prompts
|
||||
AGENT_MAX_CONCURRENT=10
|
||||
AGENT_DEFAULT_TIMEOUT=60
|
||||
AGENT_ESCALATION_ENABLED=true
|
||||
|
||||
18
salesflow-saas/backend/app/ai/__init__.py
Normal file
18
salesflow-saas/backend/app/ai/__init__.py
Normal file
@ -0,0 +1,18 @@
|
||||
"""
|
||||
Dealix AI Engine
|
||||
Core AI infrastructure: LLM providers, agent execution, orchestration.
|
||||
"""
|
||||
|
||||
from app.ai.llm_provider import LLMProvider
|
||||
from app.ai.agent_executor import AgentExecutor
|
||||
from app.ai.agent_router import AgentRouter
|
||||
from app.ai.orchestrator import Orchestrator
|
||||
from app.ai.saudi_dialect import SaudiDialectProcessor
|
||||
|
||||
__all__ = [
|
||||
"LLMProvider",
|
||||
"AgentExecutor",
|
||||
"AgentRouter",
|
||||
"Orchestrator",
|
||||
"SaudiDialectProcessor",
|
||||
]
|
||||
402
salesflow-saas/backend/app/ai/agent_executor.py
Normal file
402
salesflow-saas/backend/app/ai/agent_executor.py
Normal file
@ -0,0 +1,402 @@
|
||||
"""
|
||||
Agent Executor — Loads agent configs/prompts and executes them via LLM.
|
||||
Each of the 18 agents is defined in ai-agents/prompts/ with a .md prompt file.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.ai.llm_provider import LLMProvider
|
||||
from app.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
class AgentExecutor:
|
||||
"""
|
||||
Executes any of the 18 Dealix AI agents.
|
||||
|
||||
Each agent has:
|
||||
- A system prompt (from ai-agents/prompts/*.md)
|
||||
- An optional config (from ai-agents/*/config.yml or *.json)
|
||||
- Input/output schema validation
|
||||
- Escalation rules
|
||||
- Logging to ai_conversations table
|
||||
"""
|
||||
|
||||
AGENT_REGISTRY = {
|
||||
"lead_qualification": {
|
||||
"prompt_file": "lead-qualification-agent.md",
|
||||
"description": "Score and qualify inbound leads",
|
||||
"model_preference": "openai", # needs high quality
|
||||
},
|
||||
"affiliate_evaluator": {
|
||||
"prompt_file": "affiliate-recruitment-evaluator.md",
|
||||
"description": "Evaluate affiliate applications",
|
||||
"model_preference": "openai",
|
||||
},
|
||||
"onboarding_coach": {
|
||||
"prompt_file": "affiliate-onboarding-coach.md",
|
||||
"description": "Guide new affiliates through onboarding",
|
||||
"model_preference": "groq", # speed matters
|
||||
},
|
||||
"outreach_writer": {
|
||||
"prompt_file": "outreach-message-writer.md",
|
||||
"description": "Draft personalized outreach messages",
|
||||
"model_preference": "openai",
|
||||
},
|
||||
"arabic_whatsapp": {
|
||||
"prompt_file": "arabic-whatsapp-agent.md",
|
||||
"description": "Handle Arabic WhatsApp conversations",
|
||||
"model_preference": "openai",
|
||||
},
|
||||
"english_conversation": {
|
||||
"prompt_file": "english-conversation-agent.md",
|
||||
"description": "Handle English conversations",
|
||||
"model_preference": "groq",
|
||||
},
|
||||
"voice_call": {
|
||||
"prompt_file": "voice-call-flow-agent.md",
|
||||
"description": "Analyze voice call transcripts",
|
||||
"model_preference": "openai",
|
||||
},
|
||||
"meeting_booking": {
|
||||
"prompt_file": "meeting-booking-agent.md",
|
||||
"description": "Negotiate and book meetings",
|
||||
"model_preference": "openai",
|
||||
},
|
||||
"sector_strategist": {
|
||||
"prompt_file": "sector-sales-strategist.md",
|
||||
"description": "Generate sector-specific strategies",
|
||||
"model_preference": "openai",
|
||||
},
|
||||
"objection_handler": {
|
||||
"prompt_file": "objection-handling-agent.md",
|
||||
"description": "Handle customer objections",
|
||||
"model_preference": "openai",
|
||||
},
|
||||
"proposal_drafter": {
|
||||
"prompt_file": "proposal-drafting-agent.md",
|
||||
"description": "Generate proposals and pitch decks",
|
||||
"model_preference": "openai",
|
||||
},
|
||||
"qa_reviewer": {
|
||||
"prompt_file": "conversation-qa-reviewer.md",
|
||||
"description": "Review AI content quality",
|
||||
"model_preference": "groq",
|
||||
},
|
||||
"compliance_reviewer": {
|
||||
"prompt_file": "compliance-reviewer.md",
|
||||
"description": "Check regulatory compliance",
|
||||
"model_preference": "openai",
|
||||
},
|
||||
"knowledge_retrieval": {
|
||||
"prompt_file": "knowledge-retrieval-agent.md",
|
||||
"description": "Search knowledge base (RAG)",
|
||||
"model_preference": "groq",
|
||||
},
|
||||
"revenue_attribution": {
|
||||
"prompt_file": "revenue-attribution-agent.md",
|
||||
"description": "Attribute revenue to sources",
|
||||
"model_preference": "openai",
|
||||
},
|
||||
"fraud_reviewer": {
|
||||
"prompt_file": "fraud-reviewer.md",
|
||||
"description": "Detect fraudulent patterns",
|
||||
"model_preference": "openai",
|
||||
},
|
||||
"guarantee_reviewer": {
|
||||
"prompt_file": "guarantee-claim-reviewer.md",
|
||||
"description": "Evaluate guarantee claims",
|
||||
"model_preference": "openai",
|
||||
},
|
||||
"management_summary": {
|
||||
"prompt_file": "management-summary-agent.md",
|
||||
"description": "Generate executive summaries",
|
||||
"model_preference": "openai",
|
||||
},
|
||||
}
|
||||
|
||||
def __init__(self, db: AsyncSession = None, llm: LLMProvider = None):
|
||||
self.db = db
|
||||
self.llm = llm or LLMProvider()
|
||||
self._prompts_dir = Path(settings.AGENT_PROMPTS_DIR)
|
||||
|
||||
# ── Execute Agent ─────────────────────────────
|
||||
|
||||
async def execute(
|
||||
self,
|
||||
agent_type: str,
|
||||
input_data: dict,
|
||||
tenant_id: str = None,
|
||||
lead_id: str = None,
|
||||
contact_id: str = None,
|
||||
conversation_history: list = None,
|
||||
override_prompt: str = None,
|
||||
json_mode: bool = True,
|
||||
) -> dict:
|
||||
"""
|
||||
Execute an AI agent and return structured results.
|
||||
|
||||
Args:
|
||||
agent_type: One of the 18 registered agent types
|
||||
input_data: Context data for the agent
|
||||
tenant_id: Tenant scope
|
||||
lead_id: Optional lead association
|
||||
contact_id: Optional contact association
|
||||
conversation_history: Previous messages for context
|
||||
override_prompt: Override the default system prompt
|
||||
json_mode: Request JSON output from LLM
|
||||
|
||||
Returns:
|
||||
{
|
||||
"agent_type": "lead_qualification",
|
||||
"output": { ... structured response ... },
|
||||
"raw_content": "...",
|
||||
"tokens": { ... },
|
||||
"latency_ms": 1234,
|
||||
"escalation": { "needed": False },
|
||||
"conversation_id": "uuid"
|
||||
}
|
||||
"""
|
||||
if agent_type not in self.AGENT_REGISTRY:
|
||||
raise ValueError(f"Unknown agent type: {agent_type}. Available: {list(self.AGENT_REGISTRY.keys())}")
|
||||
|
||||
agent_config = self.AGENT_REGISTRY[agent_type]
|
||||
start = time.time()
|
||||
|
||||
# Load system prompt
|
||||
system_prompt = override_prompt or self._load_prompt(agent_config["prompt_file"])
|
||||
if not system_prompt:
|
||||
raise FileNotFoundError(f"Prompt file not found: {agent_config['prompt_file']}")
|
||||
|
||||
# Build user message from input data
|
||||
user_message = self._format_input(agent_type, input_data)
|
||||
|
||||
# Call LLM
|
||||
response = await self.llm.chat(
|
||||
system_prompt=system_prompt,
|
||||
user_message=user_message,
|
||||
provider=agent_config.get("model_preference"),
|
||||
json_mode=json_mode,
|
||||
history=conversation_history,
|
||||
)
|
||||
|
||||
# Parse output
|
||||
output = self._parse_output(response["content"], json_mode)
|
||||
|
||||
# Check escalation rules
|
||||
escalation = self._check_escalation(agent_type, output, input_data)
|
||||
|
||||
total_latency = int((time.time() - start) * 1000)
|
||||
|
||||
# Log to database
|
||||
conversation_id = None
|
||||
if self.db and tenant_id:
|
||||
conversation_id = await self._log_conversation(
|
||||
tenant_id=tenant_id,
|
||||
agent_type=agent_type,
|
||||
lead_id=lead_id,
|
||||
contact_id=contact_id,
|
||||
input_payload=input_data,
|
||||
output_payload=output,
|
||||
tokens=response.get("tokens", {}),
|
||||
latency=total_latency,
|
||||
status="escalated" if escalation.get("needed") else "success",
|
||||
)
|
||||
|
||||
return {
|
||||
"agent_type": agent_type,
|
||||
"output": output,
|
||||
"raw_content": response["content"],
|
||||
"provider": response.get("provider"),
|
||||
"model": response.get("model"),
|
||||
"tokens": response.get("tokens", {}),
|
||||
"latency_ms": total_latency,
|
||||
"escalation": escalation,
|
||||
"conversation_id": conversation_id,
|
||||
"cached": response.get("cached", False),
|
||||
}
|
||||
|
||||
# ── Prompt Loading ────────────────────────────
|
||||
|
||||
def _load_prompt(self, filename: str) -> Optional[str]:
|
||||
"""Load agent prompt from file system."""
|
||||
# Try multiple possible locations
|
||||
paths = [
|
||||
self._prompts_dir / filename,
|
||||
Path("ai-agents") / "prompts" / filename,
|
||||
Path("../ai-agents") / "prompts" / filename,
|
||||
]
|
||||
|
||||
for path in paths:
|
||||
if path.exists():
|
||||
return path.read_text(encoding="utf-8")
|
||||
|
||||
return None
|
||||
|
||||
def get_available_agents(self) -> list:
|
||||
"""List all available agents and their descriptions."""
|
||||
return [
|
||||
{
|
||||
"type": agent_type,
|
||||
"description": config["description"],
|
||||
"prompt_file": config["prompt_file"],
|
||||
"model_preference": config.get("model_preference", "openai"),
|
||||
}
|
||||
for agent_type, config in self.AGENT_REGISTRY.items()
|
||||
]
|
||||
|
||||
# ── Input Formatting ──────────────────────────
|
||||
|
||||
def _format_input(self, agent_type: str, data: dict) -> str:
|
||||
"""Format input data into a structured prompt for the agent."""
|
||||
parts = [f"## Agent Request: {agent_type}\n"]
|
||||
parts.append(f"**Timestamp:** {datetime.now(timezone.utc).isoformat()}\n")
|
||||
|
||||
if "lead" in data:
|
||||
lead = data["lead"]
|
||||
parts.append("### Lead Information")
|
||||
for k, v in lead.items():
|
||||
if v:
|
||||
parts.append(f"- **{k}:** {v}")
|
||||
|
||||
if "conversation" in data:
|
||||
parts.append("\n### Conversation History")
|
||||
for msg in data["conversation"]:
|
||||
role = msg.get("role", "unknown")
|
||||
content = msg.get("content", "")
|
||||
parts.append(f"- [{role}]: {content}")
|
||||
|
||||
if "context" in data:
|
||||
parts.append("\n### Additional Context")
|
||||
for k, v in data["context"].items():
|
||||
parts.append(f"- **{k}:** {v}")
|
||||
|
||||
# Add any remaining top-level data
|
||||
skip_keys = {"lead", "conversation", "context"}
|
||||
remaining = {k: v for k, v in data.items() if k not in skip_keys and v}
|
||||
if remaining:
|
||||
parts.append("\n### Request Data")
|
||||
parts.append(json.dumps(remaining, ensure_ascii=False, indent=2))
|
||||
|
||||
parts.append("\n---\nPlease respond with a structured JSON output.")
|
||||
|
||||
return "\n".join(parts)
|
||||
|
||||
# ── Output Parsing ────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def _parse_output(content: str, json_mode: bool) -> dict:
|
||||
"""Parse LLM response into structured data."""
|
||||
if json_mode:
|
||||
try:
|
||||
return json.loads(content)
|
||||
except json.JSONDecodeError:
|
||||
# Try to extract JSON from markdown code blocks
|
||||
if "```json" in content:
|
||||
json_str = content.split("```json")[1].split("```")[0].strip()
|
||||
try:
|
||||
return json.loads(json_str)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
elif "```" in content:
|
||||
json_str = content.split("```")[1].split("```")[0].strip()
|
||||
try:
|
||||
return json.loads(json_str)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
return {"raw_response": content}
|
||||
|
||||
# ── Escalation Rules ──────────────────────────
|
||||
|
||||
def _check_escalation(self, agent_type: str, output: dict, input_data: dict) -> dict:
|
||||
"""Check if the agent output triggers escalation rules."""
|
||||
escalation = {"needed": False, "reason": None, "target": None}
|
||||
|
||||
if agent_type == "lead_qualification":
|
||||
score = output.get("qualification_score", output.get("score", 50))
|
||||
if isinstance(score, (int, float)) and 40 <= score <= 60:
|
||||
escalation = {
|
||||
"needed": True,
|
||||
"reason": "Ambiguous qualification score (40-60 range)",
|
||||
"target": "human_review",
|
||||
}
|
||||
|
||||
elif agent_type == "arabic_whatsapp":
|
||||
sentiment = output.get("sentiment", "")
|
||||
if sentiment == "negative":
|
||||
escalation = {
|
||||
"needed": True,
|
||||
"reason": "Negative sentiment detected in conversation",
|
||||
"target": "human_agent",
|
||||
}
|
||||
|
||||
elif agent_type == "compliance_reviewer":
|
||||
status = output.get("compliance_status", "")
|
||||
if status == "non_compliant":
|
||||
escalation = {
|
||||
"needed": True,
|
||||
"reason": "Compliance violation detected",
|
||||
"target": "compliance_officer",
|
||||
}
|
||||
|
||||
elif agent_type == "fraud_reviewer":
|
||||
risk_score = output.get("risk_score", 0)
|
||||
if isinstance(risk_score, (int, float)) and risk_score > 80:
|
||||
escalation = {
|
||||
"needed": True,
|
||||
"reason": f"High fraud risk score: {risk_score}",
|
||||
"target": "admin",
|
||||
}
|
||||
|
||||
elif agent_type == "guarantee_reviewer":
|
||||
amount = output.get("amount_claimed", 0)
|
||||
if isinstance(amount, (int, float)) and amount > 50000:
|
||||
escalation = {
|
||||
"needed": True,
|
||||
"reason": f"High-value guarantee claim: {amount} SAR",
|
||||
"target": "director",
|
||||
}
|
||||
|
||||
return escalation
|
||||
|
||||
# ── Database Logging ──────────────────────────
|
||||
|
||||
async def _log_conversation(
|
||||
self,
|
||||
tenant_id: str,
|
||||
agent_type: str,
|
||||
lead_id: str = None,
|
||||
contact_id: str = None,
|
||||
input_payload: dict = None,
|
||||
output_payload: dict = None,
|
||||
tokens: dict = None,
|
||||
latency: int = 0,
|
||||
status: str = "success",
|
||||
) -> str:
|
||||
from app.models.ai_conversation import AIConversation
|
||||
|
||||
conv = AIConversation(
|
||||
id=uuid.uuid4(),
|
||||
tenant_id=uuid.UUID(tenant_id),
|
||||
agent_type=agent_type,
|
||||
lead_id=uuid.UUID(lead_id) if lead_id else None,
|
||||
contact_id=uuid.UUID(contact_id) if contact_id else None,
|
||||
input_payload=input_payload or {},
|
||||
output_payload=output_payload or {},
|
||||
tokens_used=tokens.get("total", 0) if tokens else 0,
|
||||
latency_ms=latency,
|
||||
status=status,
|
||||
)
|
||||
self.db.add(conv)
|
||||
await self.db.flush()
|
||||
return str(conv.id)
|
||||
192
salesflow-saas/backend/app/ai/agent_router.py
Normal file
192
salesflow-saas/backend/app/ai/agent_router.py
Normal file
@ -0,0 +1,192 @@
|
||||
"""
|
||||
Agent Router — Maps events to the correct agent(s) and handles multi-agent chaining.
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.ai.agent_executor import AgentExecutor
|
||||
from app.ai.llm_provider import LLMProvider
|
||||
|
||||
|
||||
# Event type → Agent(s) mapping
|
||||
EVENT_AGENT_MAP = {
|
||||
# Lead events
|
||||
"lead.created": ["lead_qualification"],
|
||||
"lead.qualified": ["outreach_writer"],
|
||||
"lead.contacted": ["arabic_whatsapp"],
|
||||
"lead.replied": ["arabic_whatsapp", "objection_handler"],
|
||||
"lead.meeting_ready": ["meeting_booking"],
|
||||
|
||||
# Message events
|
||||
"message.inbound.whatsapp.ar": ["arabic_whatsapp"],
|
||||
"message.inbound.whatsapp.en": ["english_conversation"],
|
||||
"message.inbound.email": ["english_conversation"],
|
||||
"message.objection_detected": ["objection_handler"],
|
||||
|
||||
# Call events
|
||||
"call.completed": ["voice_call"],
|
||||
"call.transcript_ready": ["voice_call"],
|
||||
|
||||
# Meeting events
|
||||
"meeting.requested": ["meeting_booking"],
|
||||
"meeting.confirmed": ["proposal_drafter"],
|
||||
"meeting.prep_needed": ["sector_strategist", "proposal_drafter"],
|
||||
|
||||
# Deal events
|
||||
"deal.created": ["sector_strategist"],
|
||||
"deal.proposal_needed": ["proposal_drafter"],
|
||||
"deal.closed_won": ["revenue_attribution", "management_summary"],
|
||||
"deal.closed_lost": ["management_summary"],
|
||||
|
||||
# Affiliate events
|
||||
"affiliate.applied": ["affiliate_evaluator"],
|
||||
"affiliate.approved": ["onboarding_coach"],
|
||||
"affiliate.fraud_suspected": ["fraud_reviewer"],
|
||||
|
||||
# Compliance events
|
||||
"content.review_needed": ["qa_reviewer"],
|
||||
"compliance.check_needed": ["compliance_reviewer"],
|
||||
|
||||
# Guarantee events
|
||||
"guarantee.claimed": ["guarantee_reviewer"],
|
||||
|
||||
# Knowledge events
|
||||
"knowledge.query": ["knowledge_retrieval"],
|
||||
|
||||
# Reporting events
|
||||
"report.daily": ["management_summary"],
|
||||
"report.weekly": ["management_summary"],
|
||||
}
|
||||
|
||||
|
||||
class AgentRouter:
|
||||
"""
|
||||
Routes incoming events to the appropriate AI agent(s).
|
||||
Supports single-agent, multi-agent, and chained execution.
|
||||
"""
|
||||
|
||||
def __init__(self, db: AsyncSession, llm: LLMProvider = None):
|
||||
self.db = db
|
||||
self.llm = llm or LLMProvider()
|
||||
self.executor = AgentExecutor(db=db, llm=self.llm)
|
||||
|
||||
async def route(
|
||||
self,
|
||||
event_type: str,
|
||||
event_data: dict,
|
||||
tenant_id: str,
|
||||
lead_id: str = None,
|
||||
contact_id: str = None,
|
||||
) -> dict:
|
||||
"""
|
||||
Route an event to the appropriate agent(s).
|
||||
|
||||
Returns:
|
||||
{
|
||||
"event": "lead.created",
|
||||
"agents_invoked": ["lead_qualification"],
|
||||
"results": [ { ...agent output... } ],
|
||||
"escalations": [ ... ],
|
||||
}
|
||||
"""
|
||||
agents = EVENT_AGENT_MAP.get(event_type, [])
|
||||
if not agents:
|
||||
return {
|
||||
"event": event_type,
|
||||
"agents_invoked": [],
|
||||
"results": [],
|
||||
"error": f"No agent mapped for event: {event_type}",
|
||||
}
|
||||
|
||||
results = []
|
||||
escalations = []
|
||||
|
||||
for agent_type in agents:
|
||||
try:
|
||||
result = await self.executor.execute(
|
||||
agent_type=agent_type,
|
||||
input_data=event_data,
|
||||
tenant_id=tenant_id,
|
||||
lead_id=lead_id,
|
||||
contact_id=contact_id,
|
||||
)
|
||||
results.append(result)
|
||||
|
||||
if result.get("escalation", {}).get("needed"):
|
||||
escalations.append({
|
||||
"agent": agent_type,
|
||||
"reason": result["escalation"]["reason"],
|
||||
"target": result["escalation"]["target"],
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
results.append({
|
||||
"agent_type": agent_type,
|
||||
"error": str(e),
|
||||
"status": "failed",
|
||||
})
|
||||
|
||||
return {
|
||||
"event": event_type,
|
||||
"agents_invoked": agents,
|
||||
"results": results,
|
||||
"escalations": escalations,
|
||||
}
|
||||
|
||||
async def chain(
|
||||
self,
|
||||
agent_sequence: list,
|
||||
initial_data: dict,
|
||||
tenant_id: str,
|
||||
lead_id: str = None,
|
||||
) -> dict:
|
||||
"""
|
||||
Execute agents in sequence, passing output of each to the next.
|
||||
|
||||
Example chain: ["lead_qualification", "outreach_writer", "meeting_booking"]
|
||||
"""
|
||||
chain_results = []
|
||||
current_data = initial_data.copy()
|
||||
|
||||
for agent_type in agent_sequence:
|
||||
try:
|
||||
result = await self.executor.execute(
|
||||
agent_type=agent_type,
|
||||
input_data=current_data,
|
||||
tenant_id=tenant_id,
|
||||
lead_id=lead_id,
|
||||
)
|
||||
chain_results.append(result)
|
||||
|
||||
# Pass output to next agent as context
|
||||
if result.get("output"):
|
||||
current_data["previous_agent"] = agent_type
|
||||
current_data["previous_output"] = result["output"]
|
||||
|
||||
# Stop chain if escalation is needed
|
||||
if result.get("escalation", {}).get("needed"):
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
chain_results.append({
|
||||
"agent_type": agent_type,
|
||||
"error": str(e),
|
||||
"status": "failed",
|
||||
})
|
||||
break
|
||||
|
||||
return {
|
||||
"chain": agent_sequence,
|
||||
"completed": len(chain_results),
|
||||
"results": chain_results,
|
||||
}
|
||||
|
||||
def get_event_types(self) -> list:
|
||||
"""List all supported event types."""
|
||||
return list(EVENT_AGENT_MAP.keys())
|
||||
|
||||
def get_agents_for_event(self, event_type: str) -> list:
|
||||
"""Get agents mapped to an event type."""
|
||||
return EVENT_AGENT_MAP.get(event_type, [])
|
||||
288
salesflow-saas/backend/app/ai/llm_provider.py
Normal file
288
salesflow-saas/backend/app/ai/llm_provider.py
Normal file
@ -0,0 +1,288 @@
|
||||
"""
|
||||
LLM Provider — Unified interface for OpenAI, Groq, and Ollama.
|
||||
Handles failover, caching, rate limiting, and token tracking.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
class LLMProvider:
|
||||
"""
|
||||
Unified LLM gateway supporting multiple providers with automatic failover.
|
||||
|
||||
Usage:
|
||||
llm = LLMProvider()
|
||||
response = await llm.chat("You are a sales agent.", "Hello, tell me about your services.")
|
||||
embedding = await llm.embed("Some text to vectorize")
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._openai = None
|
||||
self._groq = None
|
||||
self._cache = {}
|
||||
self._token_usage = {"prompt": 0, "completion": 0, "total": 0}
|
||||
self._request_count = 0
|
||||
self._last_request_time = 0
|
||||
|
||||
# ── Properties ────────────────────────────────
|
||||
|
||||
@property
|
||||
def openai_client(self) -> AsyncOpenAI:
|
||||
if not self._openai and settings.OPENAI_API_KEY:
|
||||
self._openai = AsyncOpenAI(api_key=settings.OPENAI_API_KEY)
|
||||
return self._openai
|
||||
|
||||
@property
|
||||
def groq_client(self) -> AsyncOpenAI:
|
||||
if not self._groq and settings.GROQ_API_KEY:
|
||||
self._groq = AsyncOpenAI(
|
||||
api_key=settings.GROQ_API_KEY,
|
||||
base_url="https://api.groq.com/openai/v1",
|
||||
)
|
||||
return self._groq
|
||||
|
||||
# ── Main Chat Interface ───────────────────────
|
||||
|
||||
async def chat(
|
||||
self,
|
||||
system_prompt: str,
|
||||
user_message: str,
|
||||
model: str = None,
|
||||
provider: str = None,
|
||||
temperature: float = None,
|
||||
max_tokens: int = None,
|
||||
json_mode: bool = False,
|
||||
history: list = None,
|
||||
) -> dict:
|
||||
"""
|
||||
Send a chat completion request with automatic failover.
|
||||
|
||||
Returns:
|
||||
{
|
||||
"content": "The AI response text",
|
||||
"provider": "openai",
|
||||
"model": "gpt-4o",
|
||||
"tokens": {"prompt": 100, "completion": 50, "total": 150},
|
||||
"latency_ms": 1234,
|
||||
"cached": False
|
||||
}
|
||||
"""
|
||||
# Check cache
|
||||
if settings.LLM_CACHE_ENABLED:
|
||||
cache_key = self._cache_key(system_prompt, user_message, model)
|
||||
cached = self._get_cached(cache_key)
|
||||
if cached:
|
||||
return {**cached, "cached": True}
|
||||
|
||||
# Rate limiting
|
||||
await self._rate_limit()
|
||||
|
||||
# Build messages
|
||||
messages = [{"role": "system", "content": system_prompt}]
|
||||
if history:
|
||||
messages.extend(history)
|
||||
messages.append({"role": "user", "content": user_message})
|
||||
|
||||
# Try primary provider, then fallback
|
||||
primary = provider or settings.LLM_PRIMARY_PROVIDER
|
||||
fallback = settings.LLM_FALLBACK_PROVIDER
|
||||
|
||||
for attempt_provider in [primary, fallback]:
|
||||
try:
|
||||
result = await self._call_provider(
|
||||
provider=attempt_provider,
|
||||
messages=messages,
|
||||
model=model,
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
json_mode=json_mode,
|
||||
)
|
||||
|
||||
# Cache result
|
||||
if settings.LLM_CACHE_ENABLED:
|
||||
self._set_cached(cache_key, result)
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
if attempt_provider == fallback:
|
||||
# Both failed, try Ollama as last resort
|
||||
try:
|
||||
return await self._call_ollama(messages, temperature, max_tokens)
|
||||
except Exception:
|
||||
raise RuntimeError(
|
||||
f"All LLM providers failed. Last error: {str(e)}"
|
||||
)
|
||||
|
||||
# ── Embedding ─────────────────────────────────
|
||||
|
||||
async def embed(self, text: str, model: str = None) -> list:
|
||||
"""Generate embeddings using OpenAI's embedding model."""
|
||||
if not self.openai_client:
|
||||
raise RuntimeError("OpenAI API key not configured for embeddings")
|
||||
|
||||
response = await self.openai_client.embeddings.create(
|
||||
model=model or settings.OPENAI_EMBEDDING_MODEL,
|
||||
input=text,
|
||||
)
|
||||
return response.data[0].embedding
|
||||
|
||||
async def embed_batch(self, texts: list, model: str = None) -> list:
|
||||
"""Generate embeddings for multiple texts."""
|
||||
if not self.openai_client:
|
||||
raise RuntimeError("OpenAI API key not configured for embeddings")
|
||||
|
||||
response = await self.openai_client.embeddings.create(
|
||||
model=model or settings.OPENAI_EMBEDDING_MODEL,
|
||||
input=texts,
|
||||
)
|
||||
return [item.embedding for item in response.data]
|
||||
|
||||
# ── Provider Implementations ──────────────────
|
||||
|
||||
async def _call_provider(
|
||||
self,
|
||||
provider: str,
|
||||
messages: list,
|
||||
model: str = None,
|
||||
temperature: float = None,
|
||||
max_tokens: int = None,
|
||||
json_mode: bool = False,
|
||||
) -> dict:
|
||||
start = time.time()
|
||||
|
||||
if provider == "openai":
|
||||
client = self.openai_client
|
||||
model = model or settings.OPENAI_MODEL
|
||||
temp = temperature if temperature is not None else settings.OPENAI_TEMPERATURE
|
||||
tokens = max_tokens or settings.OPENAI_MAX_TOKENS
|
||||
elif provider == "groq":
|
||||
client = self.groq_client
|
||||
model = model or settings.GROQ_MODEL
|
||||
temp = temperature if temperature is not None else 0.7
|
||||
tokens = max_tokens or settings.GROQ_MAX_TOKENS
|
||||
else:
|
||||
return await self._call_ollama(messages, temperature, max_tokens)
|
||||
|
||||
if not client:
|
||||
raise RuntimeError(f"Provider {provider} not configured")
|
||||
|
||||
kwargs = {
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
"temperature": temp,
|
||||
"max_tokens": tokens,
|
||||
}
|
||||
|
||||
if json_mode:
|
||||
kwargs["response_format"] = {"type": "json_object"}
|
||||
|
||||
response = await client.chat.completions.create(**kwargs)
|
||||
latency = int((time.time() - start) * 1000)
|
||||
|
||||
usage = response.usage
|
||||
self._token_usage["prompt"] += usage.prompt_tokens
|
||||
self._token_usage["completion"] += usage.completion_tokens
|
||||
self._token_usage["total"] += usage.total_tokens
|
||||
self._request_count += 1
|
||||
|
||||
return {
|
||||
"content": response.choices[0].message.content,
|
||||
"provider": provider,
|
||||
"model": model,
|
||||
"tokens": {
|
||||
"prompt": usage.prompt_tokens,
|
||||
"completion": usage.completion_tokens,
|
||||
"total": usage.total_tokens,
|
||||
},
|
||||
"latency_ms": latency,
|
||||
"cached": False,
|
||||
}
|
||||
|
||||
async def _call_ollama(
|
||||
self,
|
||||
messages: list,
|
||||
temperature: float = None,
|
||||
max_tokens: int = None,
|
||||
) -> dict:
|
||||
start = time.time()
|
||||
async with httpx.AsyncClient(timeout=120) as client:
|
||||
response = await client.post(
|
||||
f"{settings.OLLAMA_BASE_URL}/api/chat",
|
||||
json={
|
||||
"model": settings.OLLAMA_MODEL,
|
||||
"messages": messages,
|
||||
"stream": False,
|
||||
"options": {
|
||||
"temperature": temperature or 0.7,
|
||||
"num_predict": max_tokens or 2048,
|
||||
},
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
latency = int((time.time() - start) * 1000)
|
||||
return {
|
||||
"content": data.get("message", {}).get("content", ""),
|
||||
"provider": "ollama",
|
||||
"model": settings.OLLAMA_MODEL,
|
||||
"tokens": {
|
||||
"prompt": data.get("prompt_eval_count", 0),
|
||||
"completion": data.get("eval_count", 0),
|
||||
"total": data.get("prompt_eval_count", 0) + data.get("eval_count", 0),
|
||||
},
|
||||
"latency_ms": latency,
|
||||
"cached": False,
|
||||
}
|
||||
|
||||
# ── Rate Limiting ─────────────────────────────
|
||||
|
||||
async def _rate_limit(self):
|
||||
now = time.time()
|
||||
if now - self._last_request_time < 60 / settings.LLM_RATE_LIMIT_RPM:
|
||||
await asyncio.sleep(60 / settings.LLM_RATE_LIMIT_RPM)
|
||||
self._last_request_time = time.time()
|
||||
|
||||
# ── Caching ───────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def _cache_key(system: str, user: str, model: str = None) -> str:
|
||||
raw = f"{system}:{user}:{model or ''}"
|
||||
return hashlib.sha256(raw.encode()).hexdigest()
|
||||
|
||||
def _get_cached(self, key: str) -> Optional[dict]:
|
||||
if key in self._cache:
|
||||
entry = self._cache[key]
|
||||
if time.time() - entry["time"] < settings.LLM_CACHE_TTL:
|
||||
return entry["data"]
|
||||
del self._cache[key]
|
||||
return None
|
||||
|
||||
def _set_cached(self, key: str, data: dict):
|
||||
self._cache[key] = {"data": data, "time": time.time()}
|
||||
# Evict old entries
|
||||
if len(self._cache) > 1000:
|
||||
oldest = sorted(self._cache.items(), key=lambda x: x[1]["time"])
|
||||
for k, _ in oldest[:100]:
|
||||
del self._cache[k]
|
||||
|
||||
# ── Stats ─────────────────────────────────────
|
||||
|
||||
def get_usage_stats(self) -> dict:
|
||||
return {
|
||||
"token_usage": self._token_usage.copy(),
|
||||
"request_count": self._request_count,
|
||||
"cache_entries": len(self._cache),
|
||||
}
|
||||
344
salesflow-saas/backend/app/ai/orchestrator.py
Normal file
344
salesflow-saas/backend/app/ai/orchestrator.py
Normal file
@ -0,0 +1,344 @@
|
||||
"""
|
||||
Orchestrator — THE BRAIN of Dealix.
|
||||
Controls the full lead lifecycle: Lead → Qualify → Nurture → Book → Close.
|
||||
Decides when to use which agent, when to escalate to humans, and when to move stages.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.ai.agent_router import AgentRouter
|
||||
from app.ai.llm_provider import LLMProvider
|
||||
from app.services.lead_service import LeadService
|
||||
from app.services.deal_service import DealService
|
||||
from app.services.meeting_service import MeetingService
|
||||
from app.services.notification_service import NotificationService
|
||||
from app.services.trust_score_service import TrustScoreService
|
||||
|
||||
|
||||
# Lead lifecycle state machine
|
||||
LEAD_STATES = {
|
||||
"new": {
|
||||
"actions": ["qualify", "enrich"],
|
||||
"next_states": ["contacted", "lost"],
|
||||
"auto_agent": "lead_qualification",
|
||||
},
|
||||
"contacted": {
|
||||
"actions": ["nurture", "follow_up", "qualify"],
|
||||
"next_states": ["qualified", "lost"],
|
||||
"auto_agent": "outreach_writer",
|
||||
},
|
||||
"qualified": {
|
||||
"actions": ["book_meeting", "send_proposal"],
|
||||
"next_states": ["converted", "contacted", "lost"],
|
||||
"auto_agent": "meeting_booking",
|
||||
},
|
||||
"converted": {
|
||||
"actions": ["create_deal", "prepare_presentation"],
|
||||
"next_states": [],
|
||||
"auto_agent": None,
|
||||
},
|
||||
"lost": {
|
||||
"actions": ["re_engage"],
|
||||
"next_states": ["new"],
|
||||
"auto_agent": None,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class Orchestrator:
|
||||
"""
|
||||
Central orchestration engine that automates the Lead-to-Meeting pipeline.
|
||||
|
||||
The Orchestrator:
|
||||
1. Receives events (new lead, message, call, etc.)
|
||||
2. Determines the current state of the lead
|
||||
3. Decides which agent(s) to invoke
|
||||
4. Executes the appropriate action
|
||||
5. Moves the lead to the next state
|
||||
6. Notifies humans when needed
|
||||
"""
|
||||
|
||||
def __init__(self, db: AsyncSession, llm: LLMProvider = None):
|
||||
self.db = db
|
||||
self.llm = llm or LLMProvider()
|
||||
self.router = AgentRouter(db=db, llm=self.llm)
|
||||
self.leads = LeadService(db)
|
||||
self.deals = DealService(db)
|
||||
self.meetings = MeetingService(db)
|
||||
self.notifications = NotificationService(db)
|
||||
self.trust_scores = TrustScoreService(db)
|
||||
|
||||
# ── Process New Lead ──────────────────────────
|
||||
|
||||
async def process_new_lead(self, tenant_id: str, lead_id: str) -> dict:
|
||||
"""
|
||||
Full automated pipeline for a new lead:
|
||||
1. Calculate trust score
|
||||
2. AI qualification
|
||||
3. If qualified → auto-assign + outreach
|
||||
4. If hot → book meeting immediately
|
||||
"""
|
||||
actions_taken = []
|
||||
|
||||
# Step 1: Trust Score
|
||||
trust = await self.trust_scores.calculate_lead_score(tenant_id, lead_id)
|
||||
actions_taken.append({"action": "trust_score", "result": trust})
|
||||
|
||||
lead = await self.leads.get_lead(tenant_id, lead_id)
|
||||
if not lead:
|
||||
return {"error": "Lead not found", "actions": actions_taken}
|
||||
|
||||
# Step 2: AI Qualification
|
||||
qual_result = await self.router.route(
|
||||
event_type="lead.created",
|
||||
event_data={
|
||||
"lead": lead,
|
||||
"trust_score": trust,
|
||||
},
|
||||
tenant_id=tenant_id,
|
||||
lead_id=lead_id,
|
||||
)
|
||||
actions_taken.append({"action": "ai_qualification", "result": qual_result})
|
||||
|
||||
# Extract score from AI response
|
||||
ai_score = 50 # default
|
||||
if qual_result.get("results"):
|
||||
output = qual_result["results"][0].get("output", {})
|
||||
ai_score = output.get("qualification_score", output.get("score", 50))
|
||||
if isinstance(ai_score, str):
|
||||
try:
|
||||
ai_score = int(ai_score)
|
||||
except (ValueError, TypeError):
|
||||
ai_score = 50
|
||||
|
||||
# Step 3: Update lead
|
||||
await self.leads.qualify_lead(tenant_id, lead_id, ai_score)
|
||||
|
||||
# Step 4: Auto-assign
|
||||
if ai_score >= 40:
|
||||
assign_result = await self.leads.auto_assign_round_robin(tenant_id, lead_id)
|
||||
actions_taken.append({"action": "auto_assign", "result": assign_result})
|
||||
|
||||
if assign_result and assign_result.get("assigned_to"):
|
||||
await self.notifications.notify_new_lead(
|
||||
tenant_id, assign_result["assigned_to"], lead["full_name"]
|
||||
)
|
||||
|
||||
# Step 5: Hot lead → immediate meeting booking attempt
|
||||
if ai_score >= 80 and trust.get("trust_score", 0) >= 60:
|
||||
outreach = await self.router.route(
|
||||
event_type="lead.meeting_ready",
|
||||
event_data={"lead": lead, "score": ai_score},
|
||||
tenant_id=tenant_id,
|
||||
lead_id=lead_id,
|
||||
)
|
||||
actions_taken.append({"action": "meeting_booking_attempt", "result": outreach})
|
||||
|
||||
# Step 6: Warm lead → nurture sequence
|
||||
elif ai_score >= 40:
|
||||
nurture = await self.router.route(
|
||||
event_type="lead.qualified",
|
||||
event_data={"lead": lead, "score": ai_score},
|
||||
tenant_id=tenant_id,
|
||||
lead_id=lead_id,
|
||||
)
|
||||
actions_taken.append({"action": "nurture_outreach", "result": nurture})
|
||||
|
||||
return {
|
||||
"lead_id": lead_id,
|
||||
"trust_score": trust.get("trust_score", 0),
|
||||
"ai_score": ai_score,
|
||||
"classification": trust.get("classification", "cold"),
|
||||
"actions_taken": actions_taken,
|
||||
"next_state": LEAD_STATES.get(lead.get("status", "new"), {}),
|
||||
}
|
||||
|
||||
# ── Handle Inbound Message ────────────────────
|
||||
|
||||
async def handle_inbound_message(
|
||||
self,
|
||||
tenant_id: str,
|
||||
lead_id: str,
|
||||
message: str,
|
||||
channel: str = "whatsapp",
|
||||
language: str = "ar",
|
||||
) -> dict:
|
||||
"""
|
||||
Process an inbound message from a lead:
|
||||
1. Detect language and intent
|
||||
2. Route to appropriate conversation agent
|
||||
3. Check for buying signals
|
||||
4. Auto-escalate if needed
|
||||
"""
|
||||
lead = await self.leads.get_lead(tenant_id, lead_id)
|
||||
if not lead:
|
||||
return {"error": "Lead not found"}
|
||||
|
||||
# Determine event type based on language and channel
|
||||
if language == "ar":
|
||||
event_type = "message.inbound.whatsapp.ar"
|
||||
else:
|
||||
event_type = "message.inbound.whatsapp.en"
|
||||
|
||||
# Execute conversation agent
|
||||
result = await self.router.route(
|
||||
event_type=event_type,
|
||||
event_data={
|
||||
"lead": lead,
|
||||
"message": message,
|
||||
"channel": channel,
|
||||
"language": language,
|
||||
},
|
||||
tenant_id=tenant_id,
|
||||
lead_id=lead_id,
|
||||
)
|
||||
|
||||
# Check for meeting readiness in response
|
||||
if result.get("results"):
|
||||
output = result["results"][0].get("output", {})
|
||||
intent = output.get("intent", output.get("detected_intent", ""))
|
||||
|
||||
if intent in ["book_meeting", "schedule", "meeting", "demo"]:
|
||||
# Trigger meeting booking
|
||||
booking = await self.router.route(
|
||||
event_type="meeting.requested",
|
||||
event_data={"lead": lead, "conversation_output": output},
|
||||
tenant_id=tenant_id,
|
||||
lead_id=lead_id,
|
||||
)
|
||||
result["meeting_booking"] = booking
|
||||
|
||||
elif intent in ["pricing", "quote", "proposal"]:
|
||||
# Trigger proposal generation
|
||||
proposal = await self.router.route(
|
||||
event_type="deal.proposal_needed",
|
||||
event_data={"lead": lead, "conversation_output": output},
|
||||
tenant_id=tenant_id,
|
||||
lead_id=lead_id,
|
||||
)
|
||||
result["proposal"] = proposal
|
||||
|
||||
# Handle escalations
|
||||
if result.get("escalations"):
|
||||
for esc in result["escalations"]:
|
||||
if lead.get("assigned_to"):
|
||||
await self.notifications.notify_escalation(
|
||||
tenant_id,
|
||||
lead["assigned_to"],
|
||||
f"تصعيد من {lead['full_name']}: {esc['reason']}",
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
# ── Process Deal Stage Change ─────────────────
|
||||
|
||||
async def process_deal_update(
|
||||
self, tenant_id: str, deal_id: str, new_stage: str
|
||||
) -> dict:
|
||||
"""Handle deal stage transitions with automated actions."""
|
||||
deal = await self.deals.get_deal(tenant_id, deal_id)
|
||||
if not deal:
|
||||
return {"error": "Deal not found"}
|
||||
|
||||
actions = []
|
||||
|
||||
if new_stage == "proposal":
|
||||
# Auto-generate proposal
|
||||
result = await self.router.route(
|
||||
event_type="deal.proposal_needed",
|
||||
event_data={"deal": deal},
|
||||
tenant_id=tenant_id,
|
||||
)
|
||||
actions.append({"action": "generate_proposal", "result": result})
|
||||
|
||||
elif new_stage == "closed_won":
|
||||
# Revenue attribution + commission
|
||||
result = await self.router.route(
|
||||
event_type="deal.closed_won",
|
||||
event_data={"deal": deal},
|
||||
tenant_id=tenant_id,
|
||||
)
|
||||
actions.append({"action": "revenue_attribution", "result": result})
|
||||
|
||||
# Notify
|
||||
if deal.get("assigned_to"):
|
||||
await self.notifications.notify_deal_won(
|
||||
tenant_id,
|
||||
deal["assigned_to"],
|
||||
deal["title"],
|
||||
deal.get("value", 0),
|
||||
)
|
||||
|
||||
await self.deals.move_stage(tenant_id, deal_id, new_stage)
|
||||
return {"deal_id": deal_id, "new_stage": new_stage, "actions": actions}
|
||||
|
||||
# ── Prepare Meeting ───────────────────────────
|
||||
|
||||
async def prepare_meeting(self, tenant_id: str, meeting_id: str) -> dict:
|
||||
"""
|
||||
AI-powered meeting preparation:
|
||||
1. Company research
|
||||
2. Sector strategy
|
||||
3. Talking points
|
||||
4. Predicted objections
|
||||
5. Recommended presentation
|
||||
"""
|
||||
package = await self.meetings.prepare_meeting_package(tenant_id, meeting_id)
|
||||
if not package or not package.get("lead"):
|
||||
return {"error": "Meeting or lead not found"}
|
||||
|
||||
lead = package["lead"]
|
||||
|
||||
# Get sector strategy
|
||||
strategy = await self.router.route(
|
||||
event_type="meeting.prep_needed",
|
||||
event_data={
|
||||
"lead": lead,
|
||||
"meeting": package,
|
||||
},
|
||||
tenant_id=tenant_id,
|
||||
lead_id=lead.get("id"),
|
||||
)
|
||||
|
||||
package["ai_preparation"] = strategy
|
||||
package["status"] = "ready"
|
||||
|
||||
return package
|
||||
|
||||
# ── Daily Automation ──────────────────────────
|
||||
|
||||
async def run_daily_automation(self, tenant_id: str) -> dict:
|
||||
"""
|
||||
Daily automated tasks:
|
||||
1. Score unscored leads
|
||||
2. Follow up on stale leads
|
||||
3. Remind about upcoming meetings
|
||||
4. Generate management summary
|
||||
"""
|
||||
results = {}
|
||||
|
||||
# Score all unscored leads
|
||||
score_result = await self.trust_scores.score_all_leads(tenant_id)
|
||||
results["scoring"] = score_result
|
||||
|
||||
# Generate daily summary
|
||||
summary = await self.router.route(
|
||||
event_type="report.daily",
|
||||
event_data={"tenant_id": tenant_id, "type": "daily"},
|
||||
tenant_id=tenant_id,
|
||||
)
|
||||
results["summary"] = summary
|
||||
|
||||
return results
|
||||
|
||||
# ── Status ────────────────────────────────────
|
||||
|
||||
def get_lifecycle_states(self) -> dict:
|
||||
return LEAD_STATES
|
||||
|
||||
def get_supported_events(self) -> list:
|
||||
return self.router.get_event_types()
|
||||
253
salesflow-saas/backend/app/ai/saudi_dialect.py
Normal file
253
salesflow-saas/backend/app/ai/saudi_dialect.py
Normal file
@ -0,0 +1,253 @@
|
||||
"""
|
||||
Saudi Dialect Processor — The secret sauce for authentic Saudi Arabic AI conversations.
|
||||
Handles dialect awareness, tone switching, and cultural nuances.
|
||||
"""
|
||||
|
||||
|
||||
class SaudiDialectProcessor:
|
||||
"""
|
||||
Processes and generates text in authentic Saudi Arabic dialect.
|
||||
Supports multiple regional variants and formality levels.
|
||||
"""
|
||||
|
||||
# ── Saudi Greeting Templates ──────────────────
|
||||
|
||||
GREETINGS = {
|
||||
"formal": [
|
||||
"السلام عليكم ورحمة الله وبركاته",
|
||||
"حياك الله ومرحبا بك",
|
||||
"أهلاً وسهلاً، كيف حالك؟",
|
||||
"الله يحييك، نورت",
|
||||
],
|
||||
"casual": [
|
||||
"هلا والله!",
|
||||
"أهلين وسهلين!",
|
||||
"هلا وغلا، كيفك؟",
|
||||
"يا هلا فيك!",
|
||||
"حياك!",
|
||||
],
|
||||
"business": [
|
||||
"السلام عليكم، حياك الله",
|
||||
"أهلاً بك، يسعدنا تواصلك معنا",
|
||||
"مرحباً بك في ديل اي اكس",
|
||||
"حياك الله ومرحبا، كيف نقدر نخدمك؟",
|
||||
],
|
||||
}
|
||||
|
||||
# ── Common Saudi Expressions ──────────────────
|
||||
|
||||
EXPRESSIONS = {
|
||||
"yes": ["إي", "أيوا", "تمام", "أكيد", "بالتأكيد", "ان شاء الله"],
|
||||
"no": ["لا", "ما يناسبني", "مو الحين", "مب الحين", "خلنا نشوف"],
|
||||
"thanks": ["الله يعطيك العافية", "مشكور", "يعطيك ألف عافية", "تسلم"],
|
||||
"goodbye": ["الله يوفقك", "في أمان الله", "الله يسعدك", "تشرفنا"],
|
||||
"interest": ["يهمني الموضوع", "خلني أفهم أكثر", "عطني تفاصيل"],
|
||||
"thinking": ["خلني أفكر", "أشوف الموضوع", "أرجع لك", "خلني أستشير"],
|
||||
"agreement": ["ماشي", "تمام كذا", "موافق", "يا هلا فيها"],
|
||||
"praise": ["ماشاء الله", "الله يبارك", "عمل ممتاز", "أحسنت"],
|
||||
}
|
||||
|
||||
# ── Industry-specific Saudi Terms ─────────────
|
||||
|
||||
INDUSTRY_TERMS = {
|
||||
"real_estate": {
|
||||
"lead": "عميل محتمل",
|
||||
"developer": "مطور عقاري",
|
||||
"brokerage": "مكتب وساطة",
|
||||
"listing": "عقار معروض",
|
||||
"commission": "سعاية / عمولة",
|
||||
},
|
||||
"restaurant": {
|
||||
"franchise": "امتياز تجاري",
|
||||
"delivery": "توصيل",
|
||||
"dine_in": "جلسات داخلية",
|
||||
"health_cert": "شهادة صحية",
|
||||
},
|
||||
"healthcare": {
|
||||
"clinic": "عيادة / مجمع طبي",
|
||||
"appointment": "موعد",
|
||||
"patient": "مريض / مراجع",
|
||||
"insurance": "تأمين طبي",
|
||||
},
|
||||
"education": {
|
||||
"enrollment": "تسجيل",
|
||||
"tuition": "رسوم دراسية",
|
||||
"curriculum": "منهج دراسي",
|
||||
},
|
||||
"ecommerce": {
|
||||
"order": "طلب",
|
||||
"shipping": "شحن",
|
||||
"return": "إرجاع / استبدال",
|
||||
"cart": "سلة المشتريات",
|
||||
},
|
||||
}
|
||||
|
||||
# ── Tone Configurations ───────────────────────
|
||||
|
||||
TONE_CONFIGS = {
|
||||
"professional_friendly": {
|
||||
"description": "محترف وودي — مثالي للتواصل الأولي مع الشركات",
|
||||
"rules": [
|
||||
"استخدم صيغة المخاطب المفرد (أنت/حضرتك)",
|
||||
"ابدأ بالسلام والتحية",
|
||||
"كن مباشراً في الطرح بدون إطالة",
|
||||
"استخدم أمثلة عملية من السوق السعودي",
|
||||
"تجنب المبالغة في الرسمية",
|
||||
"استخدم 'حضرتك' مع الرسميين و'أنت' مع الباقي",
|
||||
],
|
||||
},
|
||||
"casual_warm": {
|
||||
"description": "عفوي ودافئ — للمتابعة والمحادثات غير الرسمية",
|
||||
"rules": [
|
||||
"استخدم تعبيرات سعودية طبيعية",
|
||||
"أضف لمسة شخصية في الكلام",
|
||||
"استخدم الإيموجي بشكل معتدل",
|
||||
"كأنك تكلم صاحبك في شغل",
|
||||
"تجنب الرسمية الزائدة",
|
||||
],
|
||||
},
|
||||
"executive": {
|
||||
"description": "تنفيذي رسمي — لكبار المسؤولين والشركات الكبرى",
|
||||
"rules": [
|
||||
"استخدم لغة احترافية عالية",
|
||||
"أرفق أرقام وإحصائيات",
|
||||
"ركز على ROI والنتائج",
|
||||
"استخدم 'حضرتكم' للمخاطب",
|
||||
"تجنب الإطالة — المدراء مشغولين",
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
# ── Regional Dialect Awareness ────────────────
|
||||
|
||||
REGIONAL_MARKERS = {
|
||||
"najdi": { # Riyadh, Qassim
|
||||
"markers": ["ايش", "كذا", "يا رجال", "وش لونك"],
|
||||
"greeting": "هلا والله، وش لونك؟",
|
||||
},
|
||||
"hijazi": { # Jeddah, Makkah, Madinah
|
||||
"markers": ["كده", "ليش", "يا زين", "دحين"],
|
||||
"greeting": "أهلين، كيف الحال؟",
|
||||
},
|
||||
"sharqawi": { # Dammam, Khobar, Dhahran
|
||||
"markers": ["شلونك", "هاي", "بعد", "يا بوي"],
|
||||
"greeting": "هلا، شلونك؟",
|
||||
},
|
||||
}
|
||||
|
||||
# ── Main Processing Methods ───────────────────
|
||||
|
||||
@classmethod
|
||||
def get_system_prompt_additions(
|
||||
cls,
|
||||
tone: str = "professional_friendly",
|
||||
sector: str = None,
|
||||
region: str = None,
|
||||
) -> str:
|
||||
"""
|
||||
Generate additional prompt instructions for Saudi dialect.
|
||||
Append this to the agent's system prompt.
|
||||
"""
|
||||
parts = []
|
||||
|
||||
# Tone rules
|
||||
tone_config = cls.TONE_CONFIGS.get(tone, cls.TONE_CONFIGS["professional_friendly"])
|
||||
parts.append(f"\n## أسلوب التواصل: {tone_config['description']}")
|
||||
parts.append("### قواعد الأسلوب:")
|
||||
for rule in tone_config["rules"]:
|
||||
parts.append(f"- {rule}")
|
||||
|
||||
# Sector-specific terms
|
||||
if sector and sector in cls.INDUSTRY_TERMS:
|
||||
parts.append(f"\n### مصطلحات القطاع ({sector}):")
|
||||
for eng, ar in cls.INDUSTRY_TERMS[sector].items():
|
||||
parts.append(f"- {eng} = {ar}")
|
||||
|
||||
# Regional awareness
|
||||
if region and region in cls.REGIONAL_MARKERS:
|
||||
regional = cls.REGIONAL_MARKERS[region]
|
||||
parts.append(f"\n### لهجة المنطقة ({region}):")
|
||||
parts.append(f"- تحية مناسبة: {regional['greeting']}")
|
||||
parts.append(f"- كلمات مألوفة: {', '.join(regional['markers'])}")
|
||||
|
||||
# General Saudi rules
|
||||
parts.append("\n### قواعد عامة للتواصل بالسعودية:")
|
||||
parts.append("- لا تستخدم لهجة مصرية أو شامية أو مغربية")
|
||||
parts.append("- استخدم 'ريال' وليس 'جنيه' أو 'دولار'")
|
||||
parts.append("- راعي أوقات العمل السعودية (الأحد-الخميس)")
|
||||
parts.append("- احترم أوقات الصلاة وتجنب التواصل خلالها")
|
||||
parts.append("- استخدم التقويم الهجري إذا ذُكر")
|
||||
parts.append("- الشركات الحكومية = رسمي جداً")
|
||||
parts.append("- القطاع الخاص = محترف وودي")
|
||||
parts.append("- المنشآت الصغيرة = عفوي ومباشر")
|
||||
|
||||
return "\n".join(parts)
|
||||
|
||||
@classmethod
|
||||
def get_greeting(cls, tone: str = "business") -> str:
|
||||
"""Get a random appropriate greeting."""
|
||||
import random
|
||||
greetings = cls.GREETINGS.get(tone, cls.GREETINGS["business"])
|
||||
return random.choice(greetings)
|
||||
|
||||
@classmethod
|
||||
def get_farewell(cls) -> str:
|
||||
import random
|
||||
return random.choice(cls.EXPRESSIONS["goodbye"])
|
||||
|
||||
@classmethod
|
||||
def enhance_message(cls, message: str, tone: str = "professional_friendly") -> str:
|
||||
"""Add Saudi conversational touches to a message."""
|
||||
# This is a simple enhancement; the real magic happens in the LLM prompt
|
||||
if not message.startswith(("السلام", "أهلا", "هلا", "حياك", "مرحب")):
|
||||
greeting = cls.get_greeting(
|
||||
"formal" if tone == "executive" else "business"
|
||||
)
|
||||
message = f"{greeting}\n\n{message}"
|
||||
|
||||
if not message.endswith(("الله", "عافية", "تشرفنا", "أمان")):
|
||||
farewell = cls.get_farewell()
|
||||
message = f"{message}\n\n{farewell}"
|
||||
|
||||
return message
|
||||
|
||||
@classmethod
|
||||
def detect_region(cls, text: str) -> str:
|
||||
"""Detect the regional dialect from text."""
|
||||
text_lower = text.lower()
|
||||
scores = {}
|
||||
|
||||
for region, config in cls.REGIONAL_MARKERS.items():
|
||||
score = sum(1 for marker in config["markers"] if marker in text_lower)
|
||||
scores[region] = score
|
||||
|
||||
if max(scores.values(), default=0) > 0:
|
||||
return max(scores, key=scores.get)
|
||||
return "najdi" # Default to Najdi (most common)
|
||||
|
||||
@classmethod
|
||||
def get_objection_responses(cls, objection_type: str) -> list:
|
||||
"""Get culturally appropriate objection responses."""
|
||||
responses = {
|
||||
"price": [
|
||||
"أفهم تماماً، خلني أوضح لك القيمة اللي بترجع عليك من الاستثمار هذا...",
|
||||
"سعرنا تنافسي مقارنة بالسوق، وعندنا ضمان ذهبي إذا ما حصلت نتائج",
|
||||
"كثير من عملاءنا قالوا نفس الكلام بالبداية، بس بعد ما جربوا شافوا الفرق",
|
||||
],
|
||||
"timing": [
|
||||
"ما فيه أحسن وقت من الحين، المنافسين ما بينتظرونك",
|
||||
"أفهم إنك مشغول، ممكن نحجز لك 15 دقيقة بس عشان توضح لك الصورة",
|
||||
"كثير من الشركات تأجل وبعدين تندم إنها ما بدت بدري",
|
||||
],
|
||||
"competitor": [
|
||||
"كل نظام له مميزاته، بس خلني أوريك وش يميزنا عنهم بالتحديد",
|
||||
"حياك، المقارنة حقك. خلنا نسوي لك عرض مقارنة واضح",
|
||||
"كثير من عملاءنا كانوا يستخدمون [المنافس] وحولوا لنا، وش تبي أشرح لك ليش؟",
|
||||
],
|
||||
"authority": [
|
||||
"طيب، وش رأيك نجهز لك ملخص تقدر تشاركه مع صاحب القرار؟",
|
||||
"ممكن نسوي لك عرض مختصر بالأرقام عشان يسهل عليك الشرح",
|
||||
"عادي، ممكن ندعو صاحب القرار معك في الاجتماع القادم",
|
||||
],
|
||||
}
|
||||
return responses.get(objection_type, responses["price"])
|
||||
217
salesflow-saas/backend/app/api/v1/analytics.py
Normal file
217
salesflow-saas/backend/app/api/v1/analytics.py
Normal file
@ -0,0 +1,217 @@
|
||||
"""
|
||||
Analytics & AI API Routes — ROI tracking, trust scores, AI orchestration.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.database import get_db
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ── Analytics ─────────────────────────────────────
|
||||
|
||||
@router.get("/analytics/summary")
|
||||
async def analytics_summary(
|
||||
tenant_id: str = Query(...),
|
||||
days: int = Query(30),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""KPI summary: leads, deals, revenue, conversion rates."""
|
||||
from app.services.analytics_service import AnalyticsService
|
||||
svc = AnalyticsService(db)
|
||||
return await svc.get_kpi_summary(tenant_id, days)
|
||||
|
||||
|
||||
@router.get("/analytics/funnel")
|
||||
async def analytics_funnel(
|
||||
tenant_id: str = Query(...),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Conversion funnel: Lead → Contacted → Qualified → Converted."""
|
||||
from app.services.analytics_service import AnalyticsService
|
||||
svc = AnalyticsService(db)
|
||||
return await svc.get_conversion_funnel(tenant_id)
|
||||
|
||||
|
||||
@router.get("/analytics/channels")
|
||||
async def analytics_channels(
|
||||
tenant_id: str = Query(...),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Channel performance comparison."""
|
||||
from app.services.analytics_service import AnalyticsService
|
||||
svc = AnalyticsService(db)
|
||||
return await svc.get_channel_performance(tenant_id)
|
||||
|
||||
|
||||
@router.get("/analytics/sectors")
|
||||
async def analytics_sectors(
|
||||
tenant_id: str = Query(...),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Sector performance breakdown."""
|
||||
from app.services.analytics_service import AnalyticsService
|
||||
svc = AnalyticsService(db)
|
||||
return await svc.get_sector_performance(tenant_id)
|
||||
|
||||
|
||||
@router.get("/analytics/agents")
|
||||
async def analytics_agents(
|
||||
tenant_id: str = Query(...),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Agent performance metrics."""
|
||||
from app.services.analytics_service import AnalyticsService
|
||||
svc = AnalyticsService(db)
|
||||
return await svc.get_agent_performance(tenant_id)
|
||||
|
||||
|
||||
@router.get("/analytics/trends")
|
||||
async def analytics_trends(
|
||||
tenant_id: str = Query(...),
|
||||
days: int = Query(90),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Time-series trends."""
|
||||
from app.services.analytics_service import AnalyticsService
|
||||
svc = AnalyticsService(db)
|
||||
return await svc.get_trends(tenant_id, days)
|
||||
|
||||
|
||||
# ── Trust Scores ──────────────────────────────────
|
||||
|
||||
@router.post("/trust-scores/lead/{lead_id}")
|
||||
async def score_lead(
|
||||
lead_id: str,
|
||||
tenant_id: str = Query(...),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Calculate trust score for a lead."""
|
||||
from app.services.trust_score_service import TrustScoreService
|
||||
svc = TrustScoreService(db)
|
||||
return await svc.calculate_lead_score(tenant_id, lead_id)
|
||||
|
||||
|
||||
@router.post("/trust-scores/affiliate/{affiliate_id}")
|
||||
async def score_affiliate(
|
||||
affiliate_id: str,
|
||||
tenant_id: str = Query(...),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Calculate trust score for an affiliate."""
|
||||
from app.services.trust_score_service import TrustScoreService
|
||||
svc = TrustScoreService(db)
|
||||
return await svc.calculate_affiliate_score(tenant_id, affiliate_id)
|
||||
|
||||
|
||||
@router.post("/trust-scores/batch")
|
||||
async def score_all(
|
||||
tenant_id: str = Query(...),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Batch score all unscored leads."""
|
||||
from app.services.trust_score_service import TrustScoreService
|
||||
svc = TrustScoreService(db)
|
||||
return await svc.score_all_leads(tenant_id)
|
||||
|
||||
|
||||
# ── AI Orchestration ──────────────────────────────
|
||||
|
||||
@router.post("/orchestrator/process-lead/{lead_id}")
|
||||
async def orchestrate_lead(
|
||||
lead_id: str,
|
||||
tenant_id: str = Query(...),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Process a new lead through the full AI pipeline."""
|
||||
from app.ai.orchestrator import Orchestrator
|
||||
orch = Orchestrator(db)
|
||||
return await orch.process_new_lead(tenant_id, lead_id)
|
||||
|
||||
|
||||
@router.post("/orchestrator/handle-message")
|
||||
async def handle_message(
|
||||
tenant_id: str = Query(...),
|
||||
lead_id: str = Query(...),
|
||||
message: str = Query(...),
|
||||
channel: str = Query("whatsapp"),
|
||||
language: str = Query("ar"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Process an inbound message through AI agents."""
|
||||
from app.ai.orchestrator import Orchestrator
|
||||
orch = Orchestrator(db)
|
||||
return await orch.handle_inbound_message(
|
||||
tenant_id, lead_id, message, channel, language
|
||||
)
|
||||
|
||||
|
||||
@router.post("/orchestrator/prepare-meeting/{meeting_id}")
|
||||
async def prepare_meeting(
|
||||
meeting_id: str,
|
||||
tenant_id: str = Query(...),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Generate AI meeting preparation package."""
|
||||
from app.ai.orchestrator import Orchestrator
|
||||
orch = Orchestrator(db)
|
||||
return await orch.prepare_meeting(tenant_id, meeting_id)
|
||||
|
||||
|
||||
@router.post("/orchestrator/daily")
|
||||
async def run_daily(
|
||||
tenant_id: str = Query(...),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Run daily automation tasks."""
|
||||
from app.ai.orchestrator import Orchestrator
|
||||
orch = Orchestrator(db)
|
||||
return await orch.run_daily_automation(tenant_id)
|
||||
|
||||
|
||||
@router.get("/orchestrator/states")
|
||||
async def get_states():
|
||||
"""Get the lead lifecycle state machine."""
|
||||
from app.ai.orchestrator import Orchestrator
|
||||
return Orchestrator.__init__ # Will return states without DB
|
||||
# Simplified response
|
||||
return {
|
||||
"states": {
|
||||
"new": {"next_states": ["contacted", "lost"], "auto_agent": "lead_qualification"},
|
||||
"contacted": {"next_states": ["qualified", "lost"], "auto_agent": "outreach_writer"},
|
||||
"qualified": {"next_states": ["converted", "contacted", "lost"], "auto_agent": "meeting_booking"},
|
||||
"converted": {"next_states": [], "auto_agent": None},
|
||||
"lost": {"next_states": ["new"], "auto_agent": None},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@router.get("/orchestrator/events")
|
||||
async def get_events():
|
||||
"""List all supported event types."""
|
||||
from app.ai.agent_router import EVENT_AGENT_MAP
|
||||
return {
|
||||
"events": [
|
||||
{"type": k, "agents": v}
|
||||
for k, v in EVENT_AGENT_MAP.items()
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
# ── AI Agent Direct Invocation ────────────────────
|
||||
|
||||
@router.get("/ai/agents")
|
||||
async def list_ai_agents():
|
||||
"""List all 18 available AI agents."""
|
||||
from app.ai.agent_executor import AgentExecutor
|
||||
executor = AgentExecutor()
|
||||
return {"agents": executor.get_available_agents()}
|
||||
|
||||
|
||||
@router.get("/ai/usage")
|
||||
async def ai_usage():
|
||||
"""Get AI token usage stats."""
|
||||
from app.ai.llm_provider import LLMProvider
|
||||
llm = LLMProvider()
|
||||
return llm.get_usage_stats()
|
||||
@ -3,7 +3,7 @@ from app.api.v1 import (
|
||||
auth, leads, deals, dashboard, tenants, users, affiliates, ai_agents,
|
||||
companies, contacts, calls, meetings, commissions, payouts, disputes,
|
||||
guarantees, consents, complaints, knowledge, sectors, presentations,
|
||||
supervisor, admin, health,
|
||||
supervisor, admin, health, analytics, webhooks,
|
||||
)
|
||||
|
||||
api_router = APIRouter()
|
||||
@ -32,3 +32,5 @@ api_router.include_router(presentations.router, prefix="/presentations", tags=["
|
||||
api_router.include_router(supervisor.router, prefix="/supervisor", tags=["Supervisor"])
|
||||
api_router.include_router(admin.router, prefix="/admin", tags=["Admin"])
|
||||
api_router.include_router(health.router, tags=["Health"])
|
||||
api_router.include_router(analytics.router, tags=["Analytics & AI"])
|
||||
api_router.include_router(webhooks.router, tags=["Webhooks"])
|
||||
|
||||
192
salesflow-saas/backend/app/api/v1/webhooks.py
Normal file
192
salesflow-saas/backend/app/api/v1/webhooks.py
Normal file
@ -0,0 +1,192 @@
|
||||
"""
|
||||
Webhook Routes — Receive events from WhatsApp, Email, CRM, Calendar, Payment.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
from fastapi import APIRouter, Request, HTTPException, Query, BackgroundTasks
|
||||
from app.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
router = APIRouter(prefix="/webhooks", tags=["Webhooks"])
|
||||
|
||||
|
||||
# ── WhatsApp ──────────────────────────────────────
|
||||
|
||||
@router.get("/whatsapp")
|
||||
async def whatsapp_verify(
|
||||
hub_mode: str = Query(None, alias="hub.mode"),
|
||||
hub_verify_token: str = Query(None, alias="hub.verify_token"),
|
||||
hub_challenge: str = Query(None, alias="hub.challenge"),
|
||||
):
|
||||
"""WhatsApp webhook verification (Meta Cloud API)."""
|
||||
if hub_mode == "subscribe" and hub_verify_token == settings.WHATSAPP_VERIFY_TOKEN:
|
||||
return int(hub_challenge)
|
||||
raise HTTPException(status_code=403, detail="Verification failed")
|
||||
|
||||
|
||||
@router.post("/whatsapp")
|
||||
async def whatsapp_incoming(request: Request, background_tasks: BackgroundTasks):
|
||||
"""
|
||||
Receive inbound WhatsApp messages from Meta Cloud API.
|
||||
Processes: text messages, media, reactions, status updates.
|
||||
"""
|
||||
body = await request.json()
|
||||
|
||||
entries = body.get("entry", [])
|
||||
for entry in entries:
|
||||
changes = entry.get("changes", [])
|
||||
for change in changes:
|
||||
value = change.get("value", {})
|
||||
messages = value.get("messages", [])
|
||||
statuses = value.get("statuses", [])
|
||||
|
||||
# Process incoming messages
|
||||
for msg in messages:
|
||||
background_tasks.add_task(
|
||||
_process_whatsapp_message,
|
||||
phone=msg.get("from", ""),
|
||||
message_type=msg.get("type", "text"),
|
||||
content=_extract_whatsapp_content(msg),
|
||||
message_id=msg.get("id", ""),
|
||||
timestamp=msg.get("timestamp", ""),
|
||||
)
|
||||
|
||||
# Process delivery statuses
|
||||
for status in statuses:
|
||||
background_tasks.add_task(
|
||||
_process_whatsapp_status,
|
||||
message_id=status.get("id", ""),
|
||||
status=status.get("status", ""),
|
||||
recipient=status.get("recipient_id", ""),
|
||||
)
|
||||
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
async def _process_whatsapp_message(
|
||||
phone: str, message_type: str, content: str, message_id: str, timestamp: str
|
||||
):
|
||||
"""Background task to process WhatsApp message through AI pipeline."""
|
||||
# Will be connected to Orchestrator
|
||||
pass
|
||||
|
||||
|
||||
async def _process_whatsapp_status(message_id: str, status: str, recipient: str):
|
||||
"""Background task to update message delivery status."""
|
||||
pass
|
||||
|
||||
|
||||
def _extract_whatsapp_content(msg: dict) -> str:
|
||||
"""Extract text content from various WhatsApp message types."""
|
||||
msg_type = msg.get("type", "text")
|
||||
if msg_type == "text":
|
||||
return msg.get("text", {}).get("body", "")
|
||||
elif msg_type == "image":
|
||||
return f"[صورة: {msg.get('image', {}).get('caption', '')}]"
|
||||
elif msg_type == "document":
|
||||
return f"[ملف: {msg.get('document', {}).get('filename', '')}]"
|
||||
elif msg_type == "audio":
|
||||
return "[رسالة صوتية]"
|
||||
elif msg_type == "video":
|
||||
return "[فيديو]"
|
||||
elif msg_type == "location":
|
||||
loc = msg.get("location", {})
|
||||
return f"[موقع: {loc.get('latitude')}, {loc.get('longitude')}]"
|
||||
elif msg_type == "reaction":
|
||||
return f"[تفاعل: {msg.get('reaction', {}).get('emoji', '')}]"
|
||||
return ""
|
||||
|
||||
|
||||
# ── Email ─────────────────────────────────────────
|
||||
|
||||
@router.post("/email/inbound")
|
||||
async def email_inbound(request: Request, background_tasks: BackgroundTasks):
|
||||
"""
|
||||
Receive inbound emails via SendGrid Inbound Parse.
|
||||
"""
|
||||
form = await request.form()
|
||||
sender = form.get("from", "")
|
||||
subject = form.get("subject", "")
|
||||
body = form.get("text", form.get("html", ""))
|
||||
|
||||
background_tasks.add_task(
|
||||
_process_inbound_email,
|
||||
sender=sender,
|
||||
subject=subject,
|
||||
body=body,
|
||||
)
|
||||
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
async def _process_inbound_email(sender: str, subject: str, body: str):
|
||||
"""Background task to process inbound email."""
|
||||
pass
|
||||
|
||||
|
||||
# ── CRM Sync ─────────────────────────────────────
|
||||
|
||||
@router.post("/crm/salesforce")
|
||||
async def salesforce_webhook(request: Request, background_tasks: BackgroundTasks):
|
||||
"""Receive Salesforce outbound messages / platform events."""
|
||||
body = await request.json()
|
||||
background_tasks.add_task(_process_crm_sync, provider="salesforce", data=body)
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.post("/crm/hubspot")
|
||||
async def hubspot_webhook(request: Request, background_tasks: BackgroundTasks):
|
||||
"""Receive HubSpot webhook events."""
|
||||
body = await request.json()
|
||||
background_tasks.add_task(_process_crm_sync, provider="hubspot", data=body)
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
async def _process_crm_sync(provider: str, data: dict):
|
||||
"""Background task to sync CRM data."""
|
||||
pass
|
||||
|
||||
|
||||
# ── Calendar ──────────────────────────────────────
|
||||
|
||||
@router.post("/calendar/google")
|
||||
async def google_calendar_webhook(request: Request, background_tasks: BackgroundTasks):
|
||||
"""Receive Google Calendar push notifications."""
|
||||
body = await request.json()
|
||||
background_tasks.add_task(_process_calendar_event, provider="google", data=body)
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
async def _process_calendar_event(provider: str, data: dict):
|
||||
"""Background task to sync calendar events."""
|
||||
pass
|
||||
|
||||
|
||||
# ── Payment ───────────────────────────────────────
|
||||
|
||||
@router.post("/payment/moyasar")
|
||||
async def moyasar_webhook(request: Request, background_tasks: BackgroundTasks):
|
||||
"""Receive Moyasar payment events."""
|
||||
body = await request.json()
|
||||
background_tasks.add_task(_process_payment, provider="moyasar", data=body)
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.post("/payment/stripe")
|
||||
async def stripe_webhook(request: Request, background_tasks: BackgroundTasks):
|
||||
"""Receive Stripe webhook events."""
|
||||
body = await request.body()
|
||||
sig = request.headers.get("stripe-signature", "")
|
||||
background_tasks.add_task(
|
||||
_process_payment,
|
||||
provider="stripe",
|
||||
data={"body": body.decode(), "signature": sig},
|
||||
)
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
async def _process_payment(provider: str, data: dict):
|
||||
"""Background task to process payment events."""
|
||||
pass
|
||||
@ -1,9 +1,10 @@
|
||||
from pydantic_settings import BaseSettings
|
||||
from functools import lru_cache
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
# App
|
||||
# ── App ──────────────────────────────────────────────
|
||||
APP_NAME: str = "Dealix"
|
||||
APP_NAME_AR: str = "ديل اي اكس"
|
||||
DEBUG: bool = False
|
||||
@ -11,40 +12,95 @@ class Settings(BaseSettings):
|
||||
DEFAULT_CURRENCY: str = "SAR"
|
||||
DEFAULT_LOCALE: str = "ar"
|
||||
|
||||
# Database
|
||||
# ── Database ─────────────────────────────────────────
|
||||
DATABASE_URL: str = "postgresql+asyncpg://salesflow:salesflow_secret_2024@db:5432/salesflow"
|
||||
|
||||
# Redis
|
||||
# ── Redis ────────────────────────────────────────────
|
||||
REDIS_URL: str = "redis://redis:6379/0"
|
||||
|
||||
# Security
|
||||
# ── Security ─────────────────────────────────────────
|
||||
SECRET_KEY: str = "change-this-to-a-random-secret-key"
|
||||
ALGORITHM: str = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
|
||||
REFRESH_TOKEN_EXPIRE_DAYS: int = 7
|
||||
|
||||
# URLs
|
||||
# ── URLs ─────────────────────────────────────────────
|
||||
API_URL: str = "http://localhost:8000"
|
||||
FRONTEND_URL: str = "http://localhost:3000"
|
||||
|
||||
# WhatsApp
|
||||
# ── LLM Providers ────────────────────────────────────
|
||||
# Primary: Groq (free/cheap, very fast)
|
||||
GROQ_API_KEY: str = ""
|
||||
GROQ_MODEL: str = "llama-3.3-70b-versatile"
|
||||
GROQ_FAST_MODEL: str = "llama-3.1-8b-instant"
|
||||
|
||||
# Fallback: OpenAI
|
||||
OPENAI_API_KEY: str = ""
|
||||
OPENAI_MODEL: str = "gpt-4o"
|
||||
OPENAI_MINI_MODEL: str = "gpt-4o-mini"
|
||||
|
||||
# Embeddings
|
||||
EMBEDDING_PROVIDER: str = "openai" # openai, sentence-transformers
|
||||
EMBEDDING_MODEL: str = "text-embedding-3-small"
|
||||
EMBEDDING_DIMENSIONS: int = 1536
|
||||
|
||||
# LLM defaults
|
||||
LLM_PRIMARY_PROVIDER: str = "groq" # groq, openai
|
||||
LLM_TEMPERATURE: float = 0.3
|
||||
LLM_MAX_TOKENS: int = 2048
|
||||
LLM_TIMEOUT: int = 30
|
||||
|
||||
# ── WhatsApp Business API ────────────────────────────
|
||||
WHATSAPP_API_TOKEN: str = ""
|
||||
WHATSAPP_PHONE_NUMBER_ID: str = ""
|
||||
WHATSAPP_BUSINESS_ACCOUNT_ID: str = ""
|
||||
WHATSAPP_VERIFY_TOKEN: str = ""
|
||||
WHATSAPP_API_URL: str = "https://graph.facebook.com/v21.0"
|
||||
WHATSAPP_MOCK_MODE: bool = True # Use mock for development
|
||||
|
||||
# Email
|
||||
EMAIL_PROVIDER: str = "smtp"
|
||||
# ── Email ────────────────────────────────────────────
|
||||
EMAIL_PROVIDER: str = "smtp" # smtp, sendgrid
|
||||
SMTP_HOST: str = "smtp.gmail.com"
|
||||
SMTP_PORT: int = 587
|
||||
SMTP_USER: str = ""
|
||||
SMTP_PASSWORD: str = ""
|
||||
SENDGRID_API_KEY: str = ""
|
||||
EMAIL_FROM_NAME: str = "Dealix"
|
||||
EMAIL_FROM_ADDRESS: str = "noreply@dealix.sa"
|
||||
|
||||
# SMS (Unifonic)
|
||||
# ── SMS (Unifonic - Saudi) ───────────────────────────
|
||||
UNIFONIC_APP_SID: str = ""
|
||||
UNIFONIC_SENDER_ID: str = "Dealix"
|
||||
|
||||
# ── Calendar Integration ─────────────────────────────
|
||||
GOOGLE_CALENDAR_CREDENTIALS: str = ""
|
||||
GOOGLE_CALENDAR_ID: str = ""
|
||||
MICROSOFT_CLIENT_ID: str = ""
|
||||
MICROSOFT_CLIENT_SECRET: str = ""
|
||||
|
||||
# ── CRM Connectors ───────────────────────────────────
|
||||
HUBSPOT_API_KEY: str = ""
|
||||
SALESFORCE_CLIENT_ID: str = ""
|
||||
SALESFORCE_CLIENT_SECRET: str = ""
|
||||
SALESFORCE_DOMAIN: str = ""
|
||||
|
||||
# ── Scraping / Lead Gen ──────────────────────────────
|
||||
GOOGLE_MAPS_API_KEY: str = ""
|
||||
RAPIDAPI_KEY: str = "" # For LinkedIn data enrichment
|
||||
|
||||
# ── Rate Limiting ────────────────────────────────────
|
||||
RATE_LIMIT_PER_MINUTE: int = 60
|
||||
RATE_LIMIT_PER_HOUR: int = 1000
|
||||
WHATSAPP_RATE_LIMIT_PER_SECOND: int = 80
|
||||
|
||||
# ── Celery ───────────────────────────────────────────
|
||||
CELERY_BROKER_URL: str = "redis://redis:6379/1"
|
||||
CELERY_RESULT_BACKEND: str = "redis://redis:6379/2"
|
||||
|
||||
# ── File Storage ─────────────────────────────────────
|
||||
UPLOAD_DIR: str = "/app/uploads"
|
||||
MAX_UPLOAD_SIZE_MB: int = 10
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
case_sensitive = True
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
from sqlalchemy import event, text
|
||||
from app.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
@ -29,3 +30,13 @@ async def get_db() -> AsyncSession:
|
||||
raise
|
||||
finally:
|
||||
await session.close()
|
||||
|
||||
|
||||
async def init_db():
|
||||
"""Initialize database extensions and create tables."""
|
||||
async with engine.begin() as conn:
|
||||
# Enable pgvector extension for RAG embeddings
|
||||
await conn.execute(text("CREATE EXTENSION IF NOT EXISTS vector"))
|
||||
await conn.execute(text("CREATE EXTENSION IF NOT EXISTS pg_trgm"))
|
||||
# Create all tables
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
@ -1,5 +1,8 @@
|
||||
import httpx
|
||||
from app.config import get_settings
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger("dealix.integrations.whatsapp")
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
@ -8,7 +11,12 @@ WHATSAPP_API_URL = "https://graph.facebook.com/v21.0"
|
||||
|
||||
async def send_whatsapp_message(phone: str, message: str) -> dict:
|
||||
"""Send a text message via WhatsApp Business API."""
|
||||
if settings.WHATSAPP_MOCK_MODE:
|
||||
logger.info(f"[MOCK WHATSAPP] To: {phone} | Message: {message}")
|
||||
return {"status": "success", "mocked": True, "message_id": "mock_123"}
|
||||
|
||||
if not settings.WHATSAPP_API_TOKEN or not settings.WHATSAPP_PHONE_NUMBER_ID:
|
||||
logger.error("WhatsApp credentials missing.")
|
||||
return {"status": "error", "detail": "WhatsApp not configured"}
|
||||
|
||||
url = f"{WHATSAPP_API_URL}/{settings.WHATSAPP_PHONE_NUMBER_ID}/messages"
|
||||
|
||||
@ -1,34 +1,69 @@
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from app.config import get_settings
|
||||
from app.api.v1.router import api_router
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""Application startup and shutdown events."""
|
||||
# Startup
|
||||
print(f"🚀 {settings.APP_NAME} ({settings.APP_NAME_AR}) starting...")
|
||||
print(f" Environment: {settings.ENVIRONMENT}")
|
||||
print(f" LLM Primary: {settings.LLM_PRIMARY_PROVIDER}")
|
||||
print(f" LLM Fallback: {settings.LLM_FALLBACK_PROVIDER}")
|
||||
yield
|
||||
# Shutdown
|
||||
print(f"👋 {settings.APP_NAME} shutting down...")
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title=f"{settings.APP_NAME} API",
|
||||
description="AI Sales SaaS Platform for SMEs - Multi-tenant, Multi-industry Sales Automation",
|
||||
version="1.0.0",
|
||||
description=(
|
||||
"AI-powered B2B Revenue Operating System for the Saudi market. "
|
||||
"Lead management, AI agents, affiliate system, meeting automation, "
|
||||
"deal pipeline, and commission processing — all driven by 18 specialized AI agents."
|
||||
),
|
||||
version="2.0.0",
|
||||
docs_url="/api/docs",
|
||||
redoc_url="/api/redoc",
|
||||
openapi_url="/api/openapi.json",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
# CORS
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=[settings.FRONTEND_URL, "http://localhost:3000"],
|
||||
allow_origins=[
|
||||
settings.FRONTEND_URL,
|
||||
"http://localhost:3000",
|
||||
"http://localhost:5173",
|
||||
"https://dealix.sa",
|
||||
"https://app.dealix.sa",
|
||||
],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# API Routes
|
||||
app.include_router(api_router, prefix="/api/v1")
|
||||
|
||||
|
||||
# Health check (outside router for direct access)
|
||||
@app.get("/api/v1/health")
|
||||
async def health_check():
|
||||
return {
|
||||
"status": "healthy",
|
||||
"app": settings.APP_NAME,
|
||||
"version": "1.0.0",
|
||||
"version": "2.0.0",
|
||||
"environment": settings.ENVIRONMENT,
|
||||
"ai_engine": {
|
||||
"primary": settings.LLM_PRIMARY_PROVIDER,
|
||||
"fallback": settings.LLM_FALLBACK_PROVIDER,
|
||||
},
|
||||
}
|
||||
|
||||
@ -21,6 +21,7 @@ from app.models.dispute import Dispute
|
||||
from app.models.guarantee import GuaranteeClaim
|
||||
from app.models.compliance import Consent, Complaint, Policy
|
||||
from app.models.knowledge import KnowledgeArticle, SectorAsset
|
||||
from app.models.advanced import TrustScore, Prospect, Scorecard, AIRehearsal
|
||||
|
||||
__all__ = [
|
||||
"BaseModel", "TenantModel", "Tenant", "User", "Lead", "Customer",
|
||||
@ -31,4 +32,5 @@ __all__ = [
|
||||
"Company", "Contact", "Call", "Commission", "Payout",
|
||||
"Dispute", "GuaranteeClaim", "Consent", "Complaint", "Policy",
|
||||
"KnowledgeArticle", "SectorAsset",
|
||||
"TrustScore", "Prospect", "Scorecard", "AIRehearsal",
|
||||
]
|
||||
|
||||
158
salesflow-saas/backend/app/models/advanced.py
Normal file
158
salesflow-saas/backend/app/models/advanced.py
Normal file
@ -0,0 +1,158 @@
|
||||
"""Trust Score & Prospect models — new additions to Dealix architecture."""
|
||||
import enum
|
||||
from sqlalchemy import Column, String, Integer, Float, Text, DateTime, Boolean, Enum, ForeignKey, Date
|
||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||
from sqlalchemy.orm import relationship
|
||||
from app.models.base import TenantModel, BaseModel
|
||||
|
||||
|
||||
# ─── Trust Score ───────────────────────────────────────────────
|
||||
|
||||
class EntityType(str, enum.Enum):
|
||||
AFFILIATE = "affiliate"
|
||||
LEAD = "lead"
|
||||
COMPANY = "company"
|
||||
CONTACT = "contact"
|
||||
|
||||
|
||||
class TrustScore(TenantModel):
|
||||
"""Trust assessment for affiliates, leads, and companies.
|
||||
Helps the system focus effort on people who actually buy/perform."""
|
||||
__tablename__ = "trust_scores"
|
||||
|
||||
entity_type = Column(Enum(EntityType), nullable=False, index=True)
|
||||
entity_id = Column(UUID(as_uuid=True), nullable=False, index=True)
|
||||
|
||||
# Composite score (0-100)
|
||||
score = Column(Float, default=50.0, nullable=False)
|
||||
|
||||
# Breakdown dimensions
|
||||
engagement_score = Column(Float, default=50.0) # Response rate, activity level
|
||||
conversion_score = Column(Float, default=50.0) # Historical conversion rate
|
||||
reliability_score = Column(Float, default=50.0) # Show-up rate, commitment
|
||||
quality_score = Column(Float, default=50.0) # Lead quality, deal value
|
||||
|
||||
# Signals
|
||||
positive_signals = Column(JSONB, default=[]) # List of positive indicators
|
||||
negative_signals = Column(JSONB, default=[]) # List of risk indicators
|
||||
last_computed_at = Column(DateTime(timezone=True))
|
||||
|
||||
# History
|
||||
history = Column(JSONB, default=[]) # Score history over time
|
||||
|
||||
|
||||
# ─── Prospect (pre-lead, from scraping) ───────────────────────
|
||||
|
||||
class ProspectStatus(str, enum.Enum):
|
||||
IDENTIFIED = "identified"
|
||||
RESEARCHING = "researching"
|
||||
APPROACHING = "approaching"
|
||||
ENGAGED = "engaged"
|
||||
CONVERTED = "converted"
|
||||
DISQUALIFIED = "disqualified"
|
||||
|
||||
|
||||
class Prospect(TenantModel):
|
||||
"""Pre-lead record created by the AI Lead Generator from scraping.
|
||||
Gets promoted to a Lead when qualified."""
|
||||
__tablename__ = "prospects"
|
||||
|
||||
# Source data
|
||||
source = Column(String(50), nullable=False, index=True) # google_maps, linkedin, saudi_registry
|
||||
source_url = Column(String(1000), nullable=True)
|
||||
source_id = Column(String(255), nullable=True) # External ID from source
|
||||
|
||||
# Business info
|
||||
company_name = Column(String(255), nullable=True)
|
||||
company_name_ar = Column(String(255), nullable=True)
|
||||
sector = Column(String(100), nullable=True, index=True)
|
||||
website = Column(String(500), nullable=True)
|
||||
city = Column(String(100), nullable=True)
|
||||
region = Column(String(100), nullable=True)
|
||||
|
||||
# Contact info
|
||||
contact_name = Column(String(255), nullable=True)
|
||||
contact_title = Column(String(255), nullable=True)
|
||||
phone = Column(String(20), nullable=True)
|
||||
email = Column(String(255), nullable=True)
|
||||
whatsapp = Column(String(20), nullable=True)
|
||||
|
||||
# AI analysis
|
||||
status = Column(Enum(ProspectStatus), default=ProspectStatus.IDENTIFIED, nullable=False)
|
||||
buying_intent_score = Column(Float, default=0.0) # AI-computed 0-100
|
||||
estimated_value = Column(Float, default=0.0) # Estimated deal size SAR
|
||||
fit_score = Column(Float, default=0.0) # Product-market fit 0-100
|
||||
priority = Column(String(20), default="medium") # low, medium, high, critical
|
||||
|
||||
# Enrichment data
|
||||
enrichment_data = Column(JSONB, default={}) # Raw scraped/enriched data
|
||||
notes = Column(Text, nullable=True)
|
||||
|
||||
# Conversion
|
||||
converted_to_lead_id = Column(UUID(as_uuid=True), nullable=True)
|
||||
converted_at = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
|
||||
# ─── Scorecard ─────────────────────────────────────────────────
|
||||
|
||||
class Scorecard(TenantModel):
|
||||
"""Performance scorecard for sales agents and affiliates."""
|
||||
__tablename__ = "scorecards"
|
||||
|
||||
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False, index=True)
|
||||
period = Column(Date, nullable=False)
|
||||
period_type = Column(String(20), default="monthly") # weekly, monthly, quarterly
|
||||
|
||||
# Activity metrics
|
||||
leads_handled = Column(Integer, default=0)
|
||||
calls_made = Column(Integer, default=0)
|
||||
meetings_booked = Column(Integer, default=0)
|
||||
meetings_completed = Column(Integer, default=0)
|
||||
|
||||
# Outcome metrics
|
||||
deals_closed = Column(Integer, default=0)
|
||||
revenue_generated = Column(Float, default=0.0)
|
||||
avg_deal_size = Column(Float, default=0.0)
|
||||
|
||||
# Quality metrics
|
||||
avg_response_time_seconds = Column(Integer, default=0)
|
||||
customer_satisfaction = Column(Float, default=0.0) # 0-5
|
||||
ai_assist_rate = Column(Float, default=0.0) # % of AI-assisted interactions
|
||||
|
||||
# Composite
|
||||
composite_score = Column(Float, default=0.0) # Weighted aggregate
|
||||
|
||||
user = relationship("User")
|
||||
|
||||
|
||||
# ─── AI Rehearsal (Meeting Preview) ────────────────────────────
|
||||
|
||||
class AIRehearsal(TenantModel):
|
||||
"""AI-powered meeting rehearsal — simulates the upcoming meeting
|
||||
so the sales rep can practice the best closing strategy."""
|
||||
__tablename__ = "ai_rehearsals"
|
||||
|
||||
meeting_id = Column(UUID(as_uuid=True), ForeignKey("auto_bookings.id"), nullable=True)
|
||||
lead_id = Column(UUID(as_uuid=True), ForeignKey("leads.id"), nullable=True)
|
||||
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True) # Sales rep
|
||||
|
||||
# Context
|
||||
client_profile_summary = Column(Text, nullable=True) # AI-generated summary of client
|
||||
industry_insights = Column(Text, nullable=True) # Relevant sector intelligence
|
||||
predicted_objections = Column(JSONB, default=[]) # Expected objections
|
||||
recommended_approach = Column(Text, nullable=True) # AI closing strategy
|
||||
talking_points = Column(JSONB, default=[]) # Key talking points
|
||||
competitive_intel = Column(Text, nullable=True) # Competitor positioning
|
||||
|
||||
# Rehearsal session
|
||||
rehearsal_transcript = Column(JSONB, default=[]) # Simulated conversation
|
||||
feedback = Column(Text, nullable=True) # AI feedback on performance
|
||||
readiness_score = Column(Float, default=0.0) # 0-100
|
||||
|
||||
# Status
|
||||
status = Column(String(20), default="pending") # pending, in_progress, completed
|
||||
completed_at = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
meeting = relationship("AutoBooking")
|
||||
lead = relationship("Lead")
|
||||
user = relationship("User")
|
||||
@ -1,6 +1,6 @@
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from sqlalchemy import Column, DateTime, Boolean
|
||||
from sqlalchemy import Column, DateTime, Boolean, String, event
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from app.database import Base
|
||||
|
||||
@ -10,6 +10,7 @@ class BaseModel(Base):
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
|
||||
updated_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
class TenantModel(BaseModel):
|
||||
|
||||
385
salesflow-saas/backend/app/schemas/schemas.py
Normal file
385
salesflow-saas/backend/app/schemas/schemas.py
Normal file
@ -0,0 +1,385 @@
|
||||
"""Pydantic schemas for request/response validation."""
|
||||
from datetime import datetime, date
|
||||
from typing import Optional, List, Any
|
||||
from uuid import UUID
|
||||
from pydantic import BaseModel, EmailStr, Field, ConfigDict
|
||||
|
||||
|
||||
# ── Auth Schemas ────────────────────────────────────────────────
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
email: str
|
||||
password: str
|
||||
|
||||
class RegisterRequest(BaseModel):
|
||||
email: str
|
||||
password: str = Field(min_length=8)
|
||||
full_name: str
|
||||
company_name: str
|
||||
industry: Optional[str] = None
|
||||
phone: Optional[str] = None
|
||||
|
||||
class TokenResponse(BaseModel):
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
token_type: str = "bearer"
|
||||
expires_in: int
|
||||
user: "UserResponse"
|
||||
|
||||
class RefreshTokenRequest(BaseModel):
|
||||
refresh_token: str
|
||||
|
||||
class PasswordResetRequest(BaseModel):
|
||||
email: str
|
||||
|
||||
class PasswordResetConfirm(BaseModel):
|
||||
token: str
|
||||
new_password: str = Field(min_length=8)
|
||||
|
||||
|
||||
# ── User Schemas ────────────────────────────────────────────────
|
||||
|
||||
class UserResponse(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: UUID
|
||||
email: str
|
||||
full_name: Optional[str] = None
|
||||
full_name_ar: Optional[str] = None
|
||||
role: str
|
||||
phone: Optional[str] = None
|
||||
is_active: bool
|
||||
last_login: Optional[datetime] = None
|
||||
created_at: datetime
|
||||
|
||||
class UserUpdate(BaseModel):
|
||||
full_name: Optional[str] = None
|
||||
full_name_ar: Optional[str] = None
|
||||
phone: Optional[str] = None
|
||||
|
||||
class UserCreate(BaseModel):
|
||||
email: str
|
||||
password: str = Field(min_length=8)
|
||||
full_name: str
|
||||
role: str = "agent"
|
||||
phone: Optional[str] = None
|
||||
|
||||
|
||||
# ── Tenant Schemas ──────────────────────────────────────────────
|
||||
|
||||
class TenantResponse(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: UUID
|
||||
name: str
|
||||
name_ar: Optional[str] = None
|
||||
slug: str
|
||||
industry: Optional[str] = None
|
||||
plan: str
|
||||
is_active: bool
|
||||
created_at: datetime
|
||||
|
||||
class TenantUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
name_ar: Optional[str] = None
|
||||
industry: Optional[str] = None
|
||||
logo_url: Optional[str] = None
|
||||
phone: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
whatsapp_number: Optional[str] = None
|
||||
settings: Optional[dict] = None
|
||||
|
||||
|
||||
# ── Lead Schemas ────────────────────────────────────────────────
|
||||
|
||||
class LeadCreate(BaseModel):
|
||||
name: str
|
||||
phone: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
source: Optional[str] = "manual"
|
||||
company_name: Optional[str] = None
|
||||
sector: Optional[str] = None
|
||||
city: Optional[str] = None
|
||||
notes: Optional[str] = None
|
||||
metadata: Optional[dict] = None
|
||||
|
||||
class LeadUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
phone: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
status: Optional[str] = None
|
||||
score: Optional[int] = None
|
||||
assigned_to: Optional[UUID] = None
|
||||
notes: Optional[str] = None
|
||||
metadata: Optional[dict] = None
|
||||
|
||||
class LeadResponse(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: UUID
|
||||
tenant_id: UUID
|
||||
name: str
|
||||
phone: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
source: Optional[str] = None
|
||||
status: str
|
||||
score: int
|
||||
notes: Optional[str] = None
|
||||
metadata: Optional[dict] = None
|
||||
assigned_to: Optional[UUID] = None
|
||||
created_at: datetime
|
||||
updated_at: Optional[datetime] = None
|
||||
|
||||
class LeadQualifyResponse(BaseModel):
|
||||
lead_id: UUID
|
||||
score: int
|
||||
status: str
|
||||
reasoning: str
|
||||
suggested_action: str
|
||||
bant_analysis: dict
|
||||
|
||||
|
||||
# ── Deal Schemas ────────────────────────────────────────────────
|
||||
|
||||
class DealCreate(BaseModel):
|
||||
title: str
|
||||
lead_id: Optional[UUID] = None
|
||||
value: Optional[float] = None
|
||||
currency: str = "SAR"
|
||||
stage: str = "new"
|
||||
probability: int = 0
|
||||
expected_close_date: Optional[date] = None
|
||||
notes: Optional[str] = None
|
||||
|
||||
class DealUpdate(BaseModel):
|
||||
title: Optional[str] = None
|
||||
value: Optional[float] = None
|
||||
stage: Optional[str] = None
|
||||
probability: Optional[int] = None
|
||||
expected_close_date: Optional[date] = None
|
||||
notes: Optional[str] = None
|
||||
assigned_to: Optional[UUID] = None
|
||||
|
||||
class DealResponse(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: UUID
|
||||
tenant_id: UUID
|
||||
lead_id: Optional[UUID] = None
|
||||
customer_id: Optional[UUID] = None
|
||||
assigned_to: Optional[UUID] = None
|
||||
title: str
|
||||
value: Optional[float] = None
|
||||
currency: str
|
||||
stage: str
|
||||
probability: int
|
||||
expected_close_date: Optional[date] = None
|
||||
closed_at: Optional[datetime] = None
|
||||
notes: Optional[str] = None
|
||||
created_at: datetime
|
||||
updated_at: Optional[datetime] = None
|
||||
|
||||
|
||||
# ── Company Schemas ─────────────────────────────────────────────
|
||||
|
||||
class CompanyCreate(BaseModel):
|
||||
name: str
|
||||
name_ar: Optional[str] = None
|
||||
website: Optional[str] = None
|
||||
phone: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
industry: Optional[str] = None
|
||||
size: Optional[str] = None
|
||||
city: Optional[str] = None
|
||||
address: Optional[str] = None
|
||||
notes: Optional[str] = None
|
||||
|
||||
class CompanyResponse(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: UUID
|
||||
tenant_id: UUID
|
||||
name: str
|
||||
name_ar: Optional[str] = None
|
||||
website: Optional[str] = None
|
||||
phone: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
industry: Optional[str] = None
|
||||
size: Optional[str] = None
|
||||
city: Optional[str] = None
|
||||
is_active: bool
|
||||
created_at: datetime
|
||||
|
||||
|
||||
# ── Contact Schemas ─────────────────────────────────────────────
|
||||
|
||||
class ContactCreate(BaseModel):
|
||||
company_id: UUID
|
||||
full_name: str
|
||||
role: Optional[str] = None
|
||||
phone: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
is_decision_maker: bool = False
|
||||
preferred_language: str = "ar"
|
||||
preferred_channel: str = "whatsapp"
|
||||
|
||||
class ContactResponse(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: UUID
|
||||
tenant_id: UUID
|
||||
company_id: UUID
|
||||
full_name: str
|
||||
role: Optional[str] = None
|
||||
phone: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
is_decision_maker: bool
|
||||
preferred_language: str
|
||||
preferred_channel: str
|
||||
created_at: datetime
|
||||
|
||||
|
||||
# ── Meeting Schemas ─────────────────────────────────────────────
|
||||
|
||||
class MeetingCreate(BaseModel):
|
||||
lead_id: Optional[UUID] = None
|
||||
meeting_type: str = "demo"
|
||||
meeting_datetime: datetime
|
||||
duration_minutes: int = 30
|
||||
client_name: str
|
||||
client_phone: Optional[str] = None
|
||||
client_email: Optional[str] = None
|
||||
client_company: Optional[str] = None
|
||||
assigned_sales_rep: Optional[UUID] = None
|
||||
notes: Optional[str] = None
|
||||
|
||||
class MeetingUpdate(BaseModel):
|
||||
meeting_datetime: Optional[datetime] = None
|
||||
duration_minutes: Optional[int] = None
|
||||
status: Optional[str] = None
|
||||
notes: Optional[str] = None
|
||||
outcome: Optional[str] = None
|
||||
|
||||
class MeetingResponse(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: UUID
|
||||
tenant_id: UUID
|
||||
lead_id: Optional[UUID] = None
|
||||
meeting_type: str
|
||||
meeting_datetime: datetime
|
||||
duration_minutes: int
|
||||
client_name: str
|
||||
client_company: Optional[str] = None
|
||||
status: str
|
||||
assigned_sales_rep: Optional[UUID] = None
|
||||
notes: Optional[str] = None
|
||||
outcome: Optional[str] = None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
# ── AI Agent Schemas ────────────────────────────────────────────
|
||||
|
||||
class AgentInvokeRequest(BaseModel):
|
||||
agent_type: str
|
||||
input_data: dict
|
||||
lead_id: Optional[UUID] = None
|
||||
conversation_id: Optional[UUID] = None
|
||||
async_mode: bool = True
|
||||
|
||||
class AgentInvokeResponse(BaseModel):
|
||||
task_id: Optional[str] = None
|
||||
agent_type: str
|
||||
status: str # queued, processing, completed, error
|
||||
output: Optional[dict] = None
|
||||
tokens_used: Optional[int] = None
|
||||
latency_ms: Optional[int] = None
|
||||
|
||||
class ConversationResponse(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: UUID
|
||||
tenant_id: UUID
|
||||
channel: str
|
||||
status: str
|
||||
contact_name: Optional[str] = None
|
||||
contact_phone: Optional[str] = None
|
||||
contact_company: Optional[str] = None
|
||||
messages_count: int
|
||||
sentiment_score: int
|
||||
interest_level: int
|
||||
qualified: bool
|
||||
meeting_booked: bool
|
||||
last_message_at: Optional[datetime] = None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
# ── Affiliate Schemas ───────────────────────────────────────────
|
||||
|
||||
class AffiliateCreate(BaseModel):
|
||||
full_name: str
|
||||
full_name_ar: Optional[str] = None
|
||||
email: str
|
||||
phone: str
|
||||
whatsapp: Optional[str] = None
|
||||
city: Optional[str] = None
|
||||
notes: Optional[str] = None
|
||||
|
||||
class AffiliateResponse(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: UUID
|
||||
full_name: str
|
||||
full_name_ar: Optional[str] = None
|
||||
email: str
|
||||
phone: str
|
||||
status: str
|
||||
referral_code: Optional[str] = None
|
||||
total_leads_generated: int
|
||||
total_deals_closed: int
|
||||
total_commission_earned: float
|
||||
created_at: datetime
|
||||
|
||||
|
||||
# ── Commission Schemas ──────────────────────────────────────────
|
||||
|
||||
class CommissionResponse(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: UUID
|
||||
affiliate_id: UUID
|
||||
deal_id: UUID
|
||||
amount: float
|
||||
rate: float
|
||||
status: str
|
||||
approved_at: Optional[datetime] = None
|
||||
paid_at: Optional[datetime] = None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
# ── Dashboard Schemas ───────────────────────────────────────────
|
||||
|
||||
class DashboardSummary(BaseModel):
|
||||
total_leads: int = 0
|
||||
new_leads_today: int = 0
|
||||
qualified_leads: int = 0
|
||||
active_conversations: int = 0
|
||||
meetings_today: int = 0
|
||||
meetings_this_week: int = 0
|
||||
total_deals: int = 0
|
||||
deals_won: int = 0
|
||||
pipeline_value: float = 0.0
|
||||
revenue_this_month: float = 0.0
|
||||
active_affiliates: int = 0
|
||||
ai_conversations_today: int = 0
|
||||
|
||||
class PipelineSummary(BaseModel):
|
||||
stage: str
|
||||
count: int
|
||||
total_value: float
|
||||
|
||||
class RevenueMetrics(BaseModel):
|
||||
period: str
|
||||
revenue: float
|
||||
deals_closed: int
|
||||
avg_deal_size: float
|
||||
|
||||
|
||||
# ── Pagination ──────────────────────────────────────────────────
|
||||
|
||||
class PaginatedResponse(BaseModel):
|
||||
items: List[Any]
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
pages: int
|
||||
@ -0,0 +1,26 @@
|
||||
"""
|
||||
Dealix Services Layer
|
||||
Business logic for all core platform operations.
|
||||
"""
|
||||
|
||||
from app.services.auth_service import AuthService
|
||||
from app.services.lead_service import LeadService
|
||||
from app.services.deal_service import DealService
|
||||
from app.services.company_service import CompanyService
|
||||
from app.services.meeting_service import MeetingService
|
||||
from app.services.affiliate_service import AffiliateService
|
||||
from app.services.notification_service import NotificationService
|
||||
from app.services.analytics_service import AnalyticsService
|
||||
from app.services.trust_score_service import TrustScoreService
|
||||
|
||||
__all__ = [
|
||||
"AuthService",
|
||||
"LeadService",
|
||||
"DealService",
|
||||
"CompanyService",
|
||||
"MeetingService",
|
||||
"AffiliateService",
|
||||
"NotificationService",
|
||||
"AnalyticsService",
|
||||
"TrustScoreService",
|
||||
]
|
||||
311
salesflow-saas/backend/app/services/affiliate_service.py
Normal file
311
salesflow-saas/backend/app/services/affiliate_service.py
Normal file
@ -0,0 +1,311 @@
|
||||
"""
|
||||
Affiliate Service — Recruitment, commissions, career path, performance tracking.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from decimal import Decimal
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
|
||||
TIER_THRESHOLDS = {
|
||||
"bronze": {"min_deals": 0, "commission_rate": 10.0},
|
||||
"silver": {"min_deals": 5, "commission_rate": 12.5},
|
||||
"gold": {"min_deals": 15, "commission_rate": 15.0},
|
||||
"platinum": {"min_deals": 30, "commission_rate": 20.0},
|
||||
}
|
||||
|
||||
CAREER_PATH = {
|
||||
"affiliate": {"next": "senior_affiliate", "deals_required": 10, "months": 3},
|
||||
"senior_affiliate": {"next": "team_lead", "deals_required": 25, "months": 6},
|
||||
"team_lead": {"next": "employee", "deals_required": 50, "months": 12},
|
||||
}
|
||||
|
||||
|
||||
class AffiliateService:
|
||||
"""Full affiliate lifecycle: recruitment, performance, commissions, career path."""
|
||||
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
|
||||
# ── Recruitment ───────────────────────────────
|
||||
|
||||
async def apply(
|
||||
self,
|
||||
tenant_id: str,
|
||||
user_id: str,
|
||||
referral_code: str = None,
|
||||
) -> dict:
|
||||
from app.models.affiliate import Affiliate
|
||||
import secrets
|
||||
|
||||
affiliate = Affiliate(
|
||||
id=uuid.uuid4(),
|
||||
tenant_id=uuid.UUID(tenant_id),
|
||||
user_id=uuid.UUID(user_id),
|
||||
status="applied",
|
||||
tier="bronze",
|
||||
referral_code=referral_code or secrets.token_urlsafe(8).upper()[:8],
|
||||
commission_rate=Decimal("10.0"),
|
||||
)
|
||||
self.db.add(affiliate)
|
||||
await self.db.flush()
|
||||
return self._to_dict(affiliate)
|
||||
|
||||
async def approve(self, tenant_id: str, affiliate_id: str) -> Optional[dict]:
|
||||
from app.models.affiliate import Affiliate
|
||||
|
||||
result = await self.db.execute(
|
||||
select(Affiliate).where(
|
||||
Affiliate.id == uuid.UUID(affiliate_id),
|
||||
Affiliate.tenant_id == uuid.UUID(tenant_id),
|
||||
)
|
||||
)
|
||||
aff = result.scalar_one_or_none()
|
||||
if not aff:
|
||||
return None
|
||||
|
||||
aff.status = "active"
|
||||
aff.approved_at = datetime.now(timezone.utc)
|
||||
await self.db.flush()
|
||||
return self._to_dict(aff)
|
||||
|
||||
async def suspend(self, tenant_id: str, affiliate_id: str, reason: str = "") -> Optional[dict]:
|
||||
from app.models.affiliate import Affiliate
|
||||
|
||||
result = await self.db.execute(
|
||||
select(Affiliate).where(
|
||||
Affiliate.id == uuid.UUID(affiliate_id),
|
||||
Affiliate.tenant_id == uuid.UUID(tenant_id),
|
||||
)
|
||||
)
|
||||
aff = result.scalar_one_or_none()
|
||||
if not aff:
|
||||
return None
|
||||
|
||||
aff.status = "suspended"
|
||||
await self.db.flush()
|
||||
return self._to_dict(aff)
|
||||
|
||||
# ── Commission Calculation ────────────────────
|
||||
|
||||
async def calculate_commission(
|
||||
self,
|
||||
tenant_id: str,
|
||||
affiliate_id: str,
|
||||
deal_id: str,
|
||||
deal_value: float,
|
||||
) -> dict:
|
||||
from app.models.commission import Commission
|
||||
from app.models.affiliate import Affiliate
|
||||
|
||||
result = await self.db.execute(
|
||||
select(Affiliate).where(
|
||||
Affiliate.id == uuid.UUID(affiliate_id),
|
||||
Affiliate.tenant_id == uuid.UUID(tenant_id),
|
||||
)
|
||||
)
|
||||
aff = result.scalar_one_or_none()
|
||||
if not aff:
|
||||
return {}
|
||||
|
||||
rate = float(aff.commission_rate)
|
||||
amount = round(deal_value * rate / 100, 2)
|
||||
|
||||
commission = Commission(
|
||||
id=uuid.uuid4(),
|
||||
tenant_id=uuid.UUID(tenant_id),
|
||||
affiliate_id=uuid.UUID(affiliate_id),
|
||||
deal_id=uuid.UUID(deal_id),
|
||||
amount=Decimal(str(amount)),
|
||||
currency="SAR",
|
||||
rate=aff.commission_rate,
|
||||
status="pending",
|
||||
period=datetime.now(timezone.utc).date().replace(day=1),
|
||||
)
|
||||
self.db.add(commission)
|
||||
await self.db.flush()
|
||||
|
||||
return {
|
||||
"commission_id": str(commission.id),
|
||||
"amount": amount,
|
||||
"rate": rate,
|
||||
"status": "pending",
|
||||
}
|
||||
|
||||
# ── Tier Progression ──────────────────────────
|
||||
|
||||
async def check_tier_upgrade(self, tenant_id: str, affiliate_id: str) -> Optional[dict]:
|
||||
from app.models.affiliate import Affiliate, AffiliatePerformance
|
||||
|
||||
result = await self.db.execute(
|
||||
select(Affiliate).where(
|
||||
Affiliate.id == uuid.UUID(affiliate_id),
|
||||
Affiliate.tenant_id == uuid.UUID(tenant_id),
|
||||
)
|
||||
)
|
||||
aff = result.scalar_one_or_none()
|
||||
if not aff:
|
||||
return None
|
||||
|
||||
# Get total deals closed
|
||||
perf_q = select(func.coalesce(func.sum(AffiliatePerformance.deals_closed), 0)).where(
|
||||
AffiliatePerformance.affiliate_id == uuid.UUID(affiliate_id),
|
||||
)
|
||||
total_deals = (await self.db.execute(perf_q)).scalar() or 0
|
||||
|
||||
# Check upgrade
|
||||
tiers = ["bronze", "silver", "gold", "platinum"]
|
||||
current_idx = tiers.index(aff.tier) if aff.tier in tiers else 0
|
||||
|
||||
for i in range(current_idx + 1, len(tiers)):
|
||||
tier = tiers[i]
|
||||
if total_deals >= TIER_THRESHOLDS[tier]["min_deals"]:
|
||||
aff.tier = tier
|
||||
aff.commission_rate = Decimal(str(TIER_THRESHOLDS[tier]["commission_rate"]))
|
||||
await self.db.flush()
|
||||
return {
|
||||
"upgraded": True,
|
||||
"new_tier": tier,
|
||||
"new_rate": TIER_THRESHOLDS[tier]["commission_rate"],
|
||||
"total_deals": total_deals,
|
||||
}
|
||||
|
||||
return {
|
||||
"upgraded": False,
|
||||
"current_tier": aff.tier,
|
||||
"total_deals": total_deals,
|
||||
"next_tier": tiers[current_idx + 1] if current_idx < len(tiers) - 1 else None,
|
||||
"deals_needed": TIER_THRESHOLDS[tiers[min(current_idx + 1, len(tiers) - 1)]]["min_deals"] - total_deals,
|
||||
}
|
||||
|
||||
# ── Career Path (Affiliate → Employee) ────────
|
||||
|
||||
async def check_career_path(self, tenant_id: str, affiliate_id: str) -> dict:
|
||||
from app.models.affiliate import Affiliate, AffiliatePerformance
|
||||
|
||||
result = await self.db.execute(
|
||||
select(Affiliate).where(
|
||||
Affiliate.id == uuid.UUID(affiliate_id),
|
||||
Affiliate.tenant_id == uuid.UUID(tenant_id),
|
||||
)
|
||||
)
|
||||
aff = result.scalar_one_or_none()
|
||||
if not aff:
|
||||
return {}
|
||||
|
||||
perf_q = select(func.coalesce(func.sum(AffiliatePerformance.deals_closed), 0)).where(
|
||||
AffiliatePerformance.affiliate_id == uuid.UUID(affiliate_id),
|
||||
)
|
||||
total_deals = (await self.db.execute(perf_q)).scalar() or 0
|
||||
|
||||
months_active = 0
|
||||
if aff.approved_at:
|
||||
delta = datetime.now(timezone.utc) - aff.approved_at.replace(tzinfo=timezone.utc)
|
||||
months_active = delta.days // 30
|
||||
|
||||
# Employee eligibility
|
||||
eligible = total_deals >= 50 and months_active >= 12
|
||||
|
||||
return {
|
||||
"affiliate_id": str(affiliate_id),
|
||||
"total_deals": total_deals,
|
||||
"months_active": months_active,
|
||||
"eligible_for_employment": eligible,
|
||||
"current_tier": aff.tier,
|
||||
"progress": {
|
||||
"deals": {"current": total_deals, "required": 50, "percent": min(100, total_deals * 100 // 50)},
|
||||
"months": {"current": months_active, "required": 12, "percent": min(100, months_active * 100 // 12)},
|
||||
},
|
||||
}
|
||||
|
||||
# ── Leaderboard ───────────────────────────────
|
||||
|
||||
async def get_leaderboard(self, tenant_id: str, limit: int = 20) -> list:
|
||||
from app.models.affiliate import Affiliate, AffiliatePerformance
|
||||
|
||||
q = (
|
||||
select(
|
||||
Affiliate.id,
|
||||
Affiliate.tier,
|
||||
Affiliate.referral_code,
|
||||
func.coalesce(func.sum(AffiliatePerformance.deals_closed), 0).label("total_deals"),
|
||||
func.coalesce(func.sum(AffiliatePerformance.revenue_attributed), 0).label("total_revenue"),
|
||||
func.coalesce(func.sum(AffiliatePerformance.commission_earned), 0).label("total_commission"),
|
||||
)
|
||||
.outerjoin(AffiliatePerformance, Affiliate.id == AffiliatePerformance.affiliate_id)
|
||||
.where(
|
||||
Affiliate.tenant_id == uuid.UUID(tenant_id),
|
||||
Affiliate.status == "active",
|
||||
)
|
||||
.group_by(Affiliate.id, Affiliate.tier, Affiliate.referral_code)
|
||||
.order_by(func.sum(AffiliatePerformance.revenue_attributed).desc().nullslast())
|
||||
.limit(limit)
|
||||
)
|
||||
|
||||
rows = (await self.db.execute(q)).all()
|
||||
return [
|
||||
{
|
||||
"rank": i + 1,
|
||||
"affiliate_id": str(row.id),
|
||||
"tier": row.tier,
|
||||
"referral_code": row.referral_code,
|
||||
"total_deals": int(row.total_deals),
|
||||
"total_revenue": float(row.total_revenue),
|
||||
"total_commission": float(row.total_commission),
|
||||
}
|
||||
for i, row in enumerate(rows)
|
||||
]
|
||||
|
||||
# ── Performance Summary ───────────────────────
|
||||
|
||||
async def get_performance(self, tenant_id: str, affiliate_id: str) -> dict:
|
||||
from app.models.affiliate import AffiliatePerformance
|
||||
|
||||
q = select(AffiliatePerformance).where(
|
||||
AffiliatePerformance.affiliate_id == uuid.UUID(affiliate_id),
|
||||
).order_by(AffiliatePerformance.period.desc()).limit(12)
|
||||
|
||||
rows = (await self.db.execute(q)).scalars().all()
|
||||
|
||||
monthly = [
|
||||
{
|
||||
"period": row.period.isoformat() if row.period else None,
|
||||
"leads_generated": row.leads_generated,
|
||||
"deals_closed": row.deals_closed,
|
||||
"revenue_attributed": float(row.revenue_attributed) if row.revenue_attributed else 0,
|
||||
"commission_earned": float(row.commission_earned) if row.commission_earned else 0,
|
||||
"conversion_rate": float(row.conversion_rate) if row.conversion_rate else 0,
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
|
||||
return {
|
||||
"affiliate_id": str(affiliate_id),
|
||||
"monthly": monthly,
|
||||
"totals": {
|
||||
"leads": sum(m["leads_generated"] for m in monthly),
|
||||
"deals": sum(m["deals_closed"] for m in monthly),
|
||||
"revenue": sum(m["revenue_attributed"] for m in monthly),
|
||||
"commission": sum(m["commission_earned"] for m in monthly),
|
||||
},
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _to_dict(aff) -> dict:
|
||||
if not aff:
|
||||
return {}
|
||||
return {
|
||||
"id": str(aff.id),
|
||||
"tenant_id": str(aff.tenant_id),
|
||||
"user_id": str(aff.user_id),
|
||||
"status": aff.status,
|
||||
"tier": aff.tier,
|
||||
"referral_code": aff.referral_code,
|
||||
"commission_rate": float(aff.commission_rate) if aff.commission_rate else 0,
|
||||
"approved_at": aff.approved_at.isoformat() if aff.approved_at else None,
|
||||
"created_at": aff.created_at.isoformat() if aff.created_at else None,
|
||||
}
|
||||
98
salesflow-saas/backend/app/services/agents/embeddings.py
Normal file
98
salesflow-saas/backend/app/services/agents/embeddings.py
Normal file
@ -0,0 +1,98 @@
|
||||
"""
|
||||
Vector Embeddings & RAG Engine
|
||||
Handles text embedding and semantic search using pgvector.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import List, Optional
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import text
|
||||
from app.config import get_settings
|
||||
|
||||
logger = logging.getLogger("dealix.agents.embeddings")
|
||||
settings = get_settings()
|
||||
|
||||
class EmbeddingsEngine:
|
||||
"""Generates embeddings and performs vector search against knowledge base."""
|
||||
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
|
||||
async def get_embedding(self, text: str) -> List[float]:
|
||||
"""Generate vector embedding for text using configured provider."""
|
||||
if settings.EMBEDDING_PROVIDER == "openai":
|
||||
import openai
|
||||
client = openai.AsyncOpenAI(api_key=settings.OPENAI_API_KEY)
|
||||
response = await client.embeddings.create(
|
||||
input=text,
|
||||
model=settings.EMBEDDING_MODEL
|
||||
)
|
||||
return response.data[0].embedding
|
||||
else:
|
||||
# Fallback for local models
|
||||
raise NotImplementedError(f"Embedding provider {settings.EMBEDDING_PROVIDER} not fully implemented yet.")
|
||||
|
||||
async def add_knowledge(self, tenant_id: str, title: str, content: str, metadata: dict = None) -> str:
|
||||
"""Embed document and store in database vector index."""
|
||||
try:
|
||||
vector = await self.get_embedding(f"{title}\n\n{content}")
|
||||
|
||||
# Using pgvector to insert knowledge.
|
||||
query = text("""
|
||||
INSERT INTO knowledge_articles (id, tenant_id, title, content, embedding, metadata)
|
||||
VALUES (gen_random_uuid(), :tenant_id, :title, :content, :embedding, :metadata)
|
||||
RETURNING id
|
||||
""")
|
||||
|
||||
# Note: The knowledge_articles model needs to have the vector column added
|
||||
# We'll use raw SQL here to interface directly with pgvector
|
||||
# We assume the column `embedding` exists as vector(1536)
|
||||
import json
|
||||
result = await self.db.execute(query, {
|
||||
"tenant_id": tenant_id,
|
||||
"title": title,
|
||||
"content": content,
|
||||
"embedding": str(vector), # pgvector parses strings of arrays directly
|
||||
"metadata": json.dumps(metadata or {})
|
||||
})
|
||||
await self.db.flush()
|
||||
|
||||
return str(result.scalar())
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to add knowledge: {e}")
|
||||
raise
|
||||
|
||||
async def search_knowledge(self, tenant_id: str, query_text: str, limit: int = 3) -> List[dict]:
|
||||
"""Semantic search using L2 distance (or cosine similarity via pgvector)."""
|
||||
try:
|
||||
query_vector = await self.get_embedding(query_text)
|
||||
|
||||
# Using pgvector cosine distance `<=>` operator to find closest rows
|
||||
query = text("""
|
||||
SELECT id, title, content, metadata, 1 - (embedding <=> :query_vector) as similarity
|
||||
FROM knowledge_articles
|
||||
WHERE tenant_id = :tenant_id
|
||||
ORDER BY embedding <=> :query_vector
|
||||
LIMIT :limit
|
||||
""")
|
||||
|
||||
result = await self.db.execute(query, {
|
||||
"tenant_id": tenant_id,
|
||||
"query_vector": str(query_vector),
|
||||
"limit": limit
|
||||
})
|
||||
|
||||
rows = result.fetchall()
|
||||
return [
|
||||
{
|
||||
"id": str(row.id),
|
||||
"title": row.title,
|
||||
"content": row.content,
|
||||
"metadata": row.metadata,
|
||||
"similarity": float(row.similarity)
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to search knowledge: {e}")
|
||||
return []
|
||||
339
salesflow-saas/backend/app/services/agents/executor.py
Normal file
339
salesflow-saas/backend/app/services/agents/executor.py
Normal file
@ -0,0 +1,339 @@
|
||||
"""
|
||||
Agent Executor — Runs AI agents with LLM calls, input validation,
|
||||
output parsing, escalation checks, and action dispatch.
|
||||
|
||||
This is the engine that powers every single AI agent in Dealix.
|
||||
"""
|
||||
|
||||
import time
|
||||
import uuid
|
||||
import json
|
||||
import logging
|
||||
from typing import Optional
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.services.llm.provider import get_llm, LLMResponse
|
||||
from app.services.agents.router import AgentRouter
|
||||
|
||||
logger = logging.getLogger("dealix.agents.executor")
|
||||
|
||||
# Load prompt files path
|
||||
PROMPTS_DIR = Path(__file__).parent.parent.parent.parent.parent / "ai-agents" / "prompts"
|
||||
|
||||
|
||||
class AgentResult:
|
||||
"""Standardized agent execution result."""
|
||||
def __init__(self, agent_type: str, output: dict, tokens_used: int = 0,
|
||||
latency_ms: int = 0, status: str = "success",
|
||||
escalation: dict = None, actions: list = None):
|
||||
self.agent_type = agent_type
|
||||
self.output = output
|
||||
self.tokens_used = tokens_used
|
||||
self.latency_ms = latency_ms
|
||||
self.status = status # success, error, escalated
|
||||
self.escalation = escalation # {needed: bool, reason: str, target: str}
|
||||
self.actions = actions or [] # [{type: "send_message", ...}, ...]
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"agent_type": self.agent_type,
|
||||
"output": self.output,
|
||||
"tokens_used": self.tokens_used,
|
||||
"latency_ms": self.latency_ms,
|
||||
"status": self.status,
|
||||
"escalation": self.escalation,
|
||||
"actions": self.actions,
|
||||
}
|
||||
|
||||
|
||||
class AgentExecutor:
|
||||
"""
|
||||
Executes AI agents by:
|
||||
1. Loading the agent's system prompt
|
||||
2. Building context from input data
|
||||
3. Calling the LLM
|
||||
4. Parsing structured output
|
||||
5. Checking escalation rules
|
||||
6. Dispatching actions (DB updates, messages, bookings)
|
||||
7. Logging to ai_conversations
|
||||
"""
|
||||
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
self.llm = get_llm()
|
||||
self.router = AgentRouter()
|
||||
|
||||
async def execute(self, agent_type: str, input_data: dict,
|
||||
tenant_id: str = None, lead_id: str = None,
|
||||
conversation_id: str = None) -> AgentResult:
|
||||
"""Execute an AI agent and return structured result."""
|
||||
start = time.time()
|
||||
|
||||
try:
|
||||
# 1. Load system prompt
|
||||
system_prompt = self._load_prompt(agent_type)
|
||||
|
||||
# 2. Build user message from input data
|
||||
user_message = self._build_user_message(agent_type, input_data)
|
||||
|
||||
# 3. Call LLM
|
||||
llm_response = await self.llm.complete(
|
||||
system_prompt=system_prompt,
|
||||
user_message=user_message,
|
||||
json_mode=True,
|
||||
temperature=self._get_temperature(agent_type),
|
||||
max_tokens=self._get_max_tokens(agent_type),
|
||||
)
|
||||
|
||||
# 4. Parse output
|
||||
output = llm_response.parse_json()
|
||||
if output is None:
|
||||
output = {"raw_response": llm_response.content}
|
||||
|
||||
# 5. Check escalation
|
||||
escalation = self._check_escalation(agent_type, output, input_data)
|
||||
|
||||
# 6. Build actions
|
||||
actions = self._build_actions(agent_type, output, input_data)
|
||||
|
||||
latency = int((time.time() - start) * 1000)
|
||||
|
||||
result = AgentResult(
|
||||
agent_type=agent_type,
|
||||
output=output,
|
||||
tokens_used=llm_response.tokens_used,
|
||||
latency_ms=latency,
|
||||
status="escalated" if escalation and escalation.get("needed") else "success",
|
||||
escalation=escalation,
|
||||
actions=actions,
|
||||
)
|
||||
|
||||
# 7. Log to database
|
||||
await self._log_conversation(
|
||||
tenant_id=tenant_id,
|
||||
agent_type=agent_type,
|
||||
lead_id=lead_id,
|
||||
input_data=input_data,
|
||||
output=result.to_dict(),
|
||||
tokens_used=llm_response.tokens_used,
|
||||
latency_ms=latency,
|
||||
status=result.status,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Agent {agent_type} executed: "
|
||||
f"tokens={llm_response.tokens_used} "
|
||||
f"latency={latency}ms "
|
||||
f"status={result.status}"
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
latency = int((time.time() - start) * 1000)
|
||||
logger.error(f"Agent {agent_type} failed: {e}")
|
||||
|
||||
result = AgentResult(
|
||||
agent_type=agent_type,
|
||||
output={"error": str(e)},
|
||||
latency_ms=latency,
|
||||
status="error",
|
||||
)
|
||||
|
||||
await self._log_conversation(
|
||||
tenant_id=tenant_id,
|
||||
agent_type=agent_type,
|
||||
lead_id=lead_id,
|
||||
input_data=input_data,
|
||||
output=result.to_dict(),
|
||||
tokens_used=0,
|
||||
latency_ms=latency,
|
||||
status="error",
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
async def execute_event(self, event_type: str, input_data: dict,
|
||||
tenant_id: str = None, **kwargs) -> list[AgentResult]:
|
||||
"""Execute all agents registered for an event type."""
|
||||
agent_ids = self.router.get_agents_for_event(event_type)
|
||||
results = []
|
||||
|
||||
for agent_id in agent_ids:
|
||||
result = await self.execute(
|
||||
agent_type=agent_id,
|
||||
input_data=input_data,
|
||||
tenant_id=tenant_id,
|
||||
**kwargs,
|
||||
)
|
||||
results.append(result)
|
||||
|
||||
# Stop chain if escalation needed
|
||||
if result.escalation and result.escalation.get("needed"):
|
||||
logger.info(f"Agent chain stopped at {agent_id} due to escalation")
|
||||
break
|
||||
|
||||
return results
|
||||
|
||||
# ── Prompt Loading ──────────────────────────────
|
||||
|
||||
def _load_prompt(self, agent_type: str) -> str:
|
||||
"""Load system prompt from the ai-agents/prompts directory."""
|
||||
# Map agent_type to filename
|
||||
filename_map = {
|
||||
"lead_qualification": "lead-qualification-agent.md",
|
||||
"arabic_whatsapp": "arabic-whatsapp-agent.md",
|
||||
"english_conversation": "english-conversation-agent.md",
|
||||
"outreach_writer": "outreach-message-writer.md",
|
||||
"meeting_booking": "meeting-booking-agent.md",
|
||||
"objection_handler": "objection-handling-agent.md",
|
||||
"proposal_drafter": "proposal-drafting-agent.md",
|
||||
"sector_strategist": "sector-sales-strategist.md",
|
||||
"knowledge_retrieval": "knowledge-retrieval-agent.md",
|
||||
"compliance_reviewer": "compliance-reviewer.md",
|
||||
"fraud_reviewer": "fraud-reviewer.md",
|
||||
"revenue_attribution": "revenue-attribution-agent.md",
|
||||
"management_summary": "management-summary-agent.md",
|
||||
"qa_reviewer": "conversation-qa-reviewer.md",
|
||||
"affiliate_evaluator": "affiliate-recruitment-evaluator.md",
|
||||
"onboarding_coach": "affiliate-onboarding-coach.md",
|
||||
"guarantee_reviewer": "guarantee-claim-reviewer.md",
|
||||
"voice_call": "voice-call-flow-agent.md",
|
||||
}
|
||||
|
||||
filename = filename_map.get(agent_type)
|
||||
if not filename:
|
||||
return f"You are the {agent_type} agent for Dealix. Respond with structured JSON."
|
||||
|
||||
prompt_path = PROMPTS_DIR / filename
|
||||
if prompt_path.exists():
|
||||
return prompt_path.read_text(encoding="utf-8")
|
||||
else:
|
||||
logger.warning(f"Prompt file not found: {prompt_path}")
|
||||
return f"You are the {agent_type} agent for Dealix. Respond with structured JSON."
|
||||
|
||||
def _build_user_message(self, agent_type: str, input_data: dict) -> str:
|
||||
"""Build the user message from input data."""
|
||||
# General format: JSON dump of input data with clear instructions
|
||||
context = json.dumps(input_data, ensure_ascii=False, indent=2, default=str)
|
||||
|
||||
return f"""## Input Data
|
||||
{context}
|
||||
|
||||
## Instructions
|
||||
Process this input according to your role and return a structured JSON response.
|
||||
Include all required output fields as defined in your schema.
|
||||
Use Arabic where appropriate (especially for client-facing content).
|
||||
Respond ONLY with valid JSON."""
|
||||
|
||||
# ── Configuration per Agent ────────────────────
|
||||
|
||||
def _get_temperature(self, agent_type: str) -> float:
|
||||
"""Agent-specific temperature settings."""
|
||||
# Creative agents need higher temperature
|
||||
creative = {"outreach_writer": 0.7, "proposal_drafter": 0.5, "sector_strategist": 0.5}
|
||||
# Analytical agents need low temperature
|
||||
analytical = {
|
||||
"lead_qualification": 0.1, "compliance_reviewer": 0.1,
|
||||
"fraud_reviewer": 0.1, "revenue_attribution": 0.1,
|
||||
}
|
||||
return creative.get(agent_type, analytical.get(agent_type, 0.3))
|
||||
|
||||
def _get_max_tokens(self, agent_type: str) -> int:
|
||||
"""Agent-specific max token settings."""
|
||||
verbose = {"proposal_drafter": 4096, "management_summary": 4096, "sector_strategist": 3000}
|
||||
return verbose.get(agent_type, 2048)
|
||||
|
||||
# ── Escalation Rules ──────────────────────────
|
||||
|
||||
def _check_escalation(self, agent_type: str, output: dict, input_data: dict) -> Optional[dict]:
|
||||
"""Check if the agent output requires escalation to a human."""
|
||||
escalation = output.get("escalation", {})
|
||||
if isinstance(escalation, dict) and escalation.get("needed"):
|
||||
return escalation
|
||||
|
||||
# Agent-specific checks
|
||||
if agent_type == "arabic_whatsapp":
|
||||
confidence = output.get("confidence", 1.0)
|
||||
if confidence < 0.5:
|
||||
return {"needed": True, "reason": "Low confidence response", "target": "human_agent"}
|
||||
|
||||
if agent_type == "lead_qualification":
|
||||
score = output.get("score", 50)
|
||||
if 40 <= score <= 60:
|
||||
return {"needed": True, "reason": "Ambiguous qualification score", "target": "sales_manager"}
|
||||
|
||||
if agent_type == "fraud_reviewer":
|
||||
risk_score = output.get("risk_score", 0)
|
||||
if risk_score > 80:
|
||||
return {"needed": True, "reason": "High fraud risk detected", "target": "admin"}
|
||||
|
||||
return None
|
||||
|
||||
# ── Action Building ───────────────────────────
|
||||
|
||||
def _build_actions(self, agent_type: str, output: dict, input_data: dict) -> list:
|
||||
"""Build a list of actions to execute based on agent output."""
|
||||
actions = []
|
||||
|
||||
if agent_type == "arabic_whatsapp" and output.get("response_message_ar"):
|
||||
actions.append({
|
||||
"type": "send_whatsapp",
|
||||
"message": output["response_message_ar"],
|
||||
"phone": input_data.get("contact_phone", ""),
|
||||
})
|
||||
|
||||
if agent_type == "meeting_booking" and output.get("meeting_booked", {}).get("confirmed"):
|
||||
actions.append({
|
||||
"type": "create_meeting",
|
||||
"datetime": output["meeting_booked"].get("datetime"),
|
||||
"lead_id": input_data.get("lead_id"),
|
||||
})
|
||||
|
||||
if agent_type == "outreach_writer" and output.get("draft_message"):
|
||||
actions.append({
|
||||
"type": "queue_message",
|
||||
"channel": input_data.get("channel", "whatsapp"),
|
||||
"message": output["draft_message"],
|
||||
})
|
||||
|
||||
if agent_type == "lead_qualification":
|
||||
actions.append({
|
||||
"type": "update_lead_score",
|
||||
"lead_id": input_data.get("lead_id"),
|
||||
"score": output.get("score", 0),
|
||||
"status": output.get("status_recommendation", "contacted"),
|
||||
})
|
||||
|
||||
return actions
|
||||
|
||||
# ── Database Logging ──────────────────────────
|
||||
|
||||
async def _log_conversation(self, tenant_id: str, agent_type: str,
|
||||
lead_id: str, input_data: dict, output: dict,
|
||||
tokens_used: int, latency_ms: int, status: str):
|
||||
"""Log agent execution to ai_conversations table."""
|
||||
try:
|
||||
from app.models.ai_conversation import AIConversation
|
||||
|
||||
log_entry = AIConversation(
|
||||
tenant_id=uuid.UUID(tenant_id) if tenant_id else None,
|
||||
contact_name=input_data.get("contact_name"),
|
||||
contact_phone=input_data.get("contact_phone"),
|
||||
channel="system",
|
||||
status=status,
|
||||
lead_id=uuid.UUID(lead_id) if lead_id else None,
|
||||
context={
|
||||
"agent_type": agent_type,
|
||||
"input": input_data,
|
||||
"output": output,
|
||||
"tokens_used": tokens_used,
|
||||
"latency_ms": latency_ms,
|
||||
},
|
||||
)
|
||||
self.db.add(log_entry)
|
||||
await self.db.flush()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to log agent conversation: {e}")
|
||||
86
salesflow-saas/backend/app/services/agents/router.py
Normal file
86
salesflow-saas/backend/app/services/agents/router.py
Normal file
@ -0,0 +1,86 @@
|
||||
"""
|
||||
Agent Router — Determines which AI agent handles which event.
|
||||
The central nervous system of Dealix's AI engine.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
logger = logging.getLogger("dealix.agents")
|
||||
|
||||
|
||||
# ── Event → Agent Mapping ─────────────────────────────────────
|
||||
|
||||
AGENT_REGISTRY = {
|
||||
# Lead lifecycle
|
||||
"lead_created": ["lead_qualification"],
|
||||
"lead_score_updated": ["lead_qualification"],
|
||||
"lead_qualified": ["outreach_writer", "meeting_booking"],
|
||||
|
||||
# Communication
|
||||
"whatsapp_inbound": ["arabic_whatsapp"],
|
||||
"whatsapp_outbound": ["outreach_writer"],
|
||||
"email_inbound": ["english_conversation"],
|
||||
"email_outbound": ["outreach_writer"],
|
||||
"voice_call_completed": ["voice_call"],
|
||||
|
||||
# Meeting lifecycle
|
||||
"meeting_requested": ["meeting_booking"],
|
||||
"meeting_confirmed": ["ai_rehearsal"],
|
||||
"meeting_upcoming": ["ai_rehearsal"],
|
||||
|
||||
# Deal lifecycle
|
||||
"deal_created": ["sector_strategist"],
|
||||
"deal_stage_changed": ["proposal_drafter"],
|
||||
"deal_proposal_requested": ["proposal_drafter"],
|
||||
|
||||
# Quality & Compliance
|
||||
"content_review": ["qa_reviewer"],
|
||||
"compliance_check": ["compliance_reviewer"],
|
||||
"objection_detected": ["objection_handler"],
|
||||
|
||||
# Affiliate lifecycle
|
||||
"affiliate_applied": ["affiliate_evaluator"],
|
||||
"affiliate_approved": ["onboarding_coach"],
|
||||
|
||||
# Analytics
|
||||
"revenue_attribution": ["revenue_attribution"],
|
||||
"fraud_check": ["fraud_reviewer"],
|
||||
"guarantee_claim": ["guarantee_reviewer"],
|
||||
"management_report": ["management_summary"],
|
||||
|
||||
# Knowledge
|
||||
"knowledge_query": ["knowledge_retrieval"],
|
||||
"sector_strategy": ["sector_strategist"],
|
||||
}
|
||||
|
||||
|
||||
class AgentRouter:
|
||||
"""Routes events to the appropriate AI agent(s)."""
|
||||
|
||||
def get_agents_for_event(self, event_type: str) -> list[str]:
|
||||
"""Return list of agent IDs that should handle this event."""
|
||||
agents = AGENT_REGISTRY.get(event_type, [])
|
||||
if not agents:
|
||||
logger.warning(f"No agent registered for event: {event_type}")
|
||||
return agents
|
||||
|
||||
def get_primary_agent(self, event_type: str) -> Optional[str]:
|
||||
"""Return the primary (first) agent for an event."""
|
||||
agents = self.get_agents_for_event(event_type)
|
||||
return agents[0] if agents else None
|
||||
|
||||
def list_all_agents(self) -> list[dict]:
|
||||
"""List all registered agents with their event triggers."""
|
||||
agent_events = {}
|
||||
for event, agents in AGENT_REGISTRY.items():
|
||||
for agent in agents:
|
||||
if agent not in agent_events:
|
||||
agent_events[agent] = []
|
||||
agent_events[agent].append(event)
|
||||
|
||||
return [
|
||||
{"agent_id": agent_id, "events": events}
|
||||
for agent_id, events in agent_events.items()
|
||||
]
|
||||
220
salesflow-saas/backend/app/services/analytics_service.py
Normal file
220
salesflow-saas/backend/app/services/analytics_service.py
Normal file
@ -0,0 +1,220 @@
|
||||
"""
|
||||
Analytics Service — ROI tracking, conversion funnels, channel performance.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import select, func, and_, case, extract
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
|
||||
class AnalyticsService:
|
||||
"""Platform-wide analytics and ROI tracking for B2B clients."""
|
||||
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
|
||||
async def get_kpi_summary(self, tenant_id: str, days: int = 30) -> dict:
|
||||
from app.models.lead import Lead
|
||||
from app.models.deal import Deal
|
||||
|
||||
tid = uuid.UUID(tenant_id)
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(days=days)
|
||||
|
||||
# Leads
|
||||
total_leads = await self._count(Lead, tid)
|
||||
new_leads = await self._count(Lead, tid, Lead.created_at >= cutoff)
|
||||
qualified = await self._count(Lead, tid, Lead.status == "qualified")
|
||||
converted = await self._count(Lead, tid, Lead.status == "converted")
|
||||
|
||||
# Deals
|
||||
total_deals = await self._count(Deal, tid)
|
||||
won_deals = await self._count(Deal, tid, Deal.stage == "closed_won")
|
||||
total_revenue = await self._sum(Deal, Deal.value, tid, Deal.stage == "closed_won")
|
||||
pipeline_value = await self._sum(
|
||||
Deal, Deal.value, tid,
|
||||
Deal.stage.in_(["discovery", "proposal", "negotiation"])
|
||||
)
|
||||
|
||||
# Rates
|
||||
conversion_rate = (converted / total_leads * 100) if total_leads > 0 else 0
|
||||
win_rate = (won_deals / total_deals * 100) if total_deals > 0 else 0
|
||||
|
||||
return {
|
||||
"period_days": days,
|
||||
"leads": {
|
||||
"total": total_leads,
|
||||
"new": new_leads,
|
||||
"qualified": qualified,
|
||||
"converted": converted,
|
||||
"conversion_rate": round(conversion_rate, 1),
|
||||
},
|
||||
"deals": {
|
||||
"total": total_deals,
|
||||
"won": won_deals,
|
||||
"win_rate": round(win_rate, 1),
|
||||
"total_revenue": total_revenue,
|
||||
"pipeline_value": pipeline_value,
|
||||
},
|
||||
"roi": {
|
||||
"revenue": total_revenue,
|
||||
"cost_per_lead": 0, # Calculated when billing is active
|
||||
"cost_per_meeting": 0,
|
||||
"cost_per_deal": 0,
|
||||
},
|
||||
}
|
||||
|
||||
async def get_conversion_funnel(self, tenant_id: str) -> dict:
|
||||
from app.models.lead import Lead
|
||||
|
||||
tid = uuid.UUID(tenant_id)
|
||||
stages = {
|
||||
"total_leads": await self._count(Lead, tid),
|
||||
"contacted": await self._count(Lead, tid, Lead.status.in_(["contacted", "qualified", "converted"])),
|
||||
"qualified": await self._count(Lead, tid, Lead.status.in_(["qualified", "converted"])),
|
||||
"converted": await self._count(Lead, tid, Lead.status == "converted"),
|
||||
}
|
||||
|
||||
total = stages["total_leads"] or 1
|
||||
funnel = [
|
||||
{"stage": "العملاء المحتملين", "stage_en": "Leads", "count": stages["total_leads"], "rate": 100},
|
||||
{"stage": "تم التواصل", "stage_en": "Contacted", "count": stages["contacted"], "rate": round(stages["contacted"] / total * 100, 1)},
|
||||
{"stage": "مؤهل", "stage_en": "Qualified", "count": stages["qualified"], "rate": round(stages["qualified"] / total * 100, 1)},
|
||||
{"stage": "تم التحويل", "stage_en": "Converted", "count": stages["converted"], "rate": round(stages["converted"] / total * 100, 1)},
|
||||
]
|
||||
|
||||
return {"funnel": funnel}
|
||||
|
||||
async def get_channel_performance(self, tenant_id: str) -> dict:
|
||||
from app.models.lead import Lead
|
||||
|
||||
tid = uuid.UUID(tenant_id)
|
||||
q = (
|
||||
select(
|
||||
Lead.source,
|
||||
func.count().label("count"),
|
||||
func.avg(Lead.score).label("avg_score"),
|
||||
)
|
||||
.where(Lead.tenant_id == tid)
|
||||
.group_by(Lead.source)
|
||||
.order_by(func.count().desc())
|
||||
)
|
||||
rows = (await self.db.execute(q)).all()
|
||||
|
||||
channels = []
|
||||
for row in rows:
|
||||
converted_q = select(func.count()).where(
|
||||
Lead.tenant_id == tid,
|
||||
Lead.source == row.source,
|
||||
Lead.status == "converted",
|
||||
)
|
||||
converted = (await self.db.execute(converted_q)).scalar() or 0
|
||||
channels.append({
|
||||
"channel": row.source,
|
||||
"leads": row.count,
|
||||
"avg_score": round(float(row.avg_score or 0), 1),
|
||||
"converted": converted,
|
||||
"conversion_rate": round(converted / row.count * 100, 1) if row.count > 0 else 0,
|
||||
})
|
||||
|
||||
return {"channels": channels}
|
||||
|
||||
async def get_sector_performance(self, tenant_id: str) -> dict:
|
||||
from app.models.lead import Lead
|
||||
|
||||
tid = uuid.UUID(tenant_id)
|
||||
q = (
|
||||
select(
|
||||
Lead.sector,
|
||||
func.count().label("total"),
|
||||
func.avg(Lead.score).label("avg_score"),
|
||||
)
|
||||
.where(Lead.tenant_id == tid, Lead.sector != "")
|
||||
.group_by(Lead.sector)
|
||||
.order_by(func.count().desc())
|
||||
)
|
||||
rows = (await self.db.execute(q)).all()
|
||||
|
||||
sectors = []
|
||||
for row in rows:
|
||||
converted_q = select(func.count()).where(
|
||||
Lead.tenant_id == tid,
|
||||
Lead.sector == row.sector,
|
||||
Lead.status == "converted",
|
||||
)
|
||||
converted = (await self.db.execute(converted_q)).scalar() or 0
|
||||
sectors.append({
|
||||
"sector": row.sector,
|
||||
"total_leads": row.total,
|
||||
"avg_score": round(float(row.avg_score or 0), 1),
|
||||
"converted": converted,
|
||||
"conversion_rate": round(converted / row.total * 100, 1) if row.total > 0 else 0,
|
||||
})
|
||||
|
||||
return {"sectors": sectors}
|
||||
|
||||
async def get_agent_performance(self, tenant_id: str) -> dict:
|
||||
from app.models.lead import Lead
|
||||
from app.models.user import User
|
||||
|
||||
tid = uuid.UUID(tenant_id)
|
||||
agents_q = select(User).where(
|
||||
User.tenant_id == tid,
|
||||
User.role.in_(["agent", "manager"]),
|
||||
User.is_active == True,
|
||||
)
|
||||
agents = (await self.db.execute(agents_q)).scalars().all()
|
||||
|
||||
performance = []
|
||||
for agent in agents:
|
||||
total = await self._count(Lead, tid, Lead.assigned_to == agent.id)
|
||||
converted = await self._count(
|
||||
Lead, tid, Lead.assigned_to == agent.id, Lead.status == "converted"
|
||||
)
|
||||
performance.append({
|
||||
"agent_id": str(agent.id),
|
||||
"name": agent.full_name,
|
||||
"total_leads": total,
|
||||
"converted": converted,
|
||||
"conversion_rate": round(converted / total * 100, 1) if total > 0 else 0,
|
||||
})
|
||||
|
||||
performance.sort(key=lambda x: x["converted"], reverse=True)
|
||||
return {"agents": performance}
|
||||
|
||||
async def get_trends(self, tenant_id: str, days: int = 90) -> dict:
|
||||
from app.models.lead import Lead
|
||||
|
||||
tid = uuid.UUID(tenant_id)
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(days=days)
|
||||
|
||||
q = (
|
||||
select(
|
||||
func.date_trunc("day", Lead.created_at).label("day"),
|
||||
func.count().label("count"),
|
||||
)
|
||||
.where(Lead.tenant_id == tid, Lead.created_at >= cutoff)
|
||||
.group_by("day")
|
||||
.order_by("day")
|
||||
)
|
||||
rows = (await self.db.execute(q)).all()
|
||||
|
||||
return {
|
||||
"daily_leads": [
|
||||
{"date": str(row.day), "count": row.count} for row in rows
|
||||
],
|
||||
}
|
||||
|
||||
# ── Helpers ───────────────────────────────────
|
||||
|
||||
async def _count(self, model, tenant_id, *filters):
|
||||
q = select(func.count()).where(model.tenant_id == tenant_id, *filters)
|
||||
return (await self.db.execute(q)).scalar() or 0
|
||||
|
||||
async def _sum(self, model, field, tenant_id, *filters):
|
||||
q = select(func.coalesce(func.sum(field), 0)).where(
|
||||
model.tenant_id == tenant_id, *filters
|
||||
)
|
||||
return float((await self.db.execute(q)).scalar() or 0)
|
||||
205
salesflow-saas/backend/app/services/auth_service.py
Normal file
205
salesflow-saas/backend/app/services/auth_service.py
Normal file
@ -0,0 +1,205 @@
|
||||
"""
|
||||
Auth Service — JWT tokens, RBAC, OTP, multi-tenant authentication.
|
||||
"""
|
||||
|
||||
import secrets
|
||||
import string
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
from jose import JWTError, jwt
|
||||
from passlib.context import CryptContext
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
|
||||
class AuthService:
|
||||
"""Handles authentication, authorization, and tenant isolation."""
|
||||
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
|
||||
# ── Password Hashing ──────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def hash_password(password: str) -> str:
|
||||
return pwd_context.hash(password)
|
||||
|
||||
@staticmethod
|
||||
def verify_password(plain: str, hashed: str) -> bool:
|
||||
return pwd_context.verify(plain, hashed)
|
||||
|
||||
# ── JWT Tokens ────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def create_access_token(
|
||||
user_id: str,
|
||||
tenant_id: str,
|
||||
role: str,
|
||||
extra: dict = None,
|
||||
) -> str:
|
||||
payload = {
|
||||
"sub": user_id,
|
||||
"tenant_id": tenant_id,
|
||||
"role": role,
|
||||
"type": "access",
|
||||
"exp": datetime.now(timezone.utc)
|
||||
+ timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES),
|
||||
"iat": datetime.now(timezone.utc),
|
||||
}
|
||||
if extra:
|
||||
payload.update(extra)
|
||||
return jwt.encode(payload, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
|
||||
|
||||
@staticmethod
|
||||
def create_refresh_token(user_id: str, tenant_id: str) -> str:
|
||||
payload = {
|
||||
"sub": user_id,
|
||||
"tenant_id": tenant_id,
|
||||
"type": "refresh",
|
||||
"exp": datetime.now(timezone.utc)
|
||||
+ timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS),
|
||||
"iat": datetime.now(timezone.utc),
|
||||
}
|
||||
return jwt.encode(payload, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
|
||||
|
||||
@staticmethod
|
||||
def decode_token(token: str) -> Optional[dict]:
|
||||
try:
|
||||
payload = jwt.decode(
|
||||
token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]
|
||||
)
|
||||
return payload
|
||||
except JWTError:
|
||||
return None
|
||||
|
||||
# ── OTP ───────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def generate_otp() -> str:
|
||||
return "".join(
|
||||
secrets.choice(string.digits) for _ in range(settings.OTP_LENGTH)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def verify_otp(stored_otp: str, provided_otp: str, created_at: datetime) -> bool:
|
||||
if stored_otp != provided_otp:
|
||||
return False
|
||||
expiry = created_at + timedelta(minutes=settings.OTP_EXPIRE_MINUTES)
|
||||
return datetime.now(timezone.utc) <= expiry
|
||||
|
||||
# ── Registration ──────────────────────────────
|
||||
|
||||
async def register_tenant(
|
||||
self,
|
||||
name: str,
|
||||
email: str,
|
||||
password: str,
|
||||
phone: str = "",
|
||||
plan: str = "free",
|
||||
) -> dict:
|
||||
"""Register a new tenant with an owner user."""
|
||||
from app.models.tenant import Tenant
|
||||
from app.models.user import User
|
||||
import uuid
|
||||
|
||||
tenant_id = uuid.uuid4()
|
||||
user_id = uuid.uuid4()
|
||||
slug = name.lower().replace(" ", "-").replace(".", "")[:50]
|
||||
|
||||
tenant = Tenant(
|
||||
id=tenant_id,
|
||||
name=name,
|
||||
slug=slug,
|
||||
plan=plan,
|
||||
is_active=True,
|
||||
)
|
||||
self.db.add(tenant)
|
||||
|
||||
user = User(
|
||||
id=user_id,
|
||||
tenant_id=tenant_id,
|
||||
email=email,
|
||||
phone=phone,
|
||||
hashed_password=self.hash_password(password),
|
||||
full_name=name,
|
||||
role="owner",
|
||||
language="ar",
|
||||
is_active=True,
|
||||
)
|
||||
self.db.add(user)
|
||||
await self.db.flush()
|
||||
|
||||
access = self.create_access_token(str(user_id), str(tenant_id), "owner")
|
||||
refresh = self.create_refresh_token(str(user_id), str(tenant_id))
|
||||
|
||||
return {
|
||||
"user_id": str(user_id),
|
||||
"tenant_id": str(tenant_id),
|
||||
"access_token": access,
|
||||
"refresh_token": refresh,
|
||||
"token_type": "bearer",
|
||||
}
|
||||
|
||||
async def login(self, email: str, password: str) -> Optional[dict]:
|
||||
"""Authenticate user and return tokens."""
|
||||
from app.models.user import User
|
||||
|
||||
result = await self.db.execute(
|
||||
select(User).where(User.email == email, User.is_active == True)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if not user or not self.verify_password(password, user.hashed_password):
|
||||
return None
|
||||
|
||||
user.last_login_at = datetime.now(timezone.utc)
|
||||
await self.db.flush()
|
||||
|
||||
access = self.create_access_token(
|
||||
str(user.id), str(user.tenant_id), user.role
|
||||
)
|
||||
refresh = self.create_refresh_token(str(user.id), str(user.tenant_id))
|
||||
|
||||
return {
|
||||
"user_id": str(user.id),
|
||||
"tenant_id": str(user.tenant_id),
|
||||
"role": user.role,
|
||||
"access_token": access,
|
||||
"refresh_token": refresh,
|
||||
"token_type": "bearer",
|
||||
}
|
||||
|
||||
async def get_current_user(self, token: str) -> Optional[dict]:
|
||||
"""Validate token and return user info."""
|
||||
payload = self.decode_token(token)
|
||||
if not payload or payload.get("type") != "access":
|
||||
return None
|
||||
return {
|
||||
"user_id": payload["sub"],
|
||||
"tenant_id": payload["tenant_id"],
|
||||
"role": payload["role"],
|
||||
}
|
||||
|
||||
# ── RBAC Helpers ──────────────────────────────
|
||||
|
||||
ROLE_HIERARCHY = {
|
||||
"viewer": 0,
|
||||
"affiliate": 1,
|
||||
"agent": 2,
|
||||
"manager": 3,
|
||||
"admin": 4,
|
||||
"owner": 5,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def has_permission(cls, user_role: str, required_role: str) -> bool:
|
||||
return cls.ROLE_HIERARCHY.get(user_role, 0) >= cls.ROLE_HIERARCHY.get(
|
||||
required_role, 0
|
||||
)
|
||||
201
salesflow-saas/backend/app/services/company_service.py
Normal file
201
salesflow-saas/backend/app/services/company_service.py
Normal file
@ -0,0 +1,201 @@
|
||||
"""
|
||||
Company Service — B2B company management, enrichment, CR validation.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import select, func, or_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
|
||||
class CompanyService:
|
||||
"""Manages B2B company profiles and account intelligence."""
|
||||
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
|
||||
async def create_company(
|
||||
self,
|
||||
tenant_id: str,
|
||||
name: str,
|
||||
name_ar: str = "",
|
||||
sector: str = "",
|
||||
size: str = "small",
|
||||
city: str = "",
|
||||
region: str = "",
|
||||
cr_number: str = "",
|
||||
website: str = "",
|
||||
) -> dict:
|
||||
from app.models.company import Company
|
||||
|
||||
company = Company(
|
||||
id=uuid.uuid4(),
|
||||
tenant_id=uuid.UUID(tenant_id),
|
||||
name=name,
|
||||
name_ar=name_ar,
|
||||
sector=sector,
|
||||
size=size,
|
||||
city=city,
|
||||
region=region,
|
||||
cr_number=cr_number,
|
||||
website=website,
|
||||
is_active=True,
|
||||
)
|
||||
self.db.add(company)
|
||||
await self.db.flush()
|
||||
return self._to_dict(company)
|
||||
|
||||
async def get_company(self, tenant_id: str, company_id: str) -> Optional[dict]:
|
||||
from app.models.company import Company
|
||||
|
||||
result = await self.db.execute(
|
||||
select(Company).where(
|
||||
Company.id == uuid.UUID(company_id),
|
||||
Company.tenant_id == uuid.UUID(tenant_id),
|
||||
)
|
||||
)
|
||||
c = result.scalar_one_or_none()
|
||||
return self._to_dict(c) if c else None
|
||||
|
||||
async def list_companies(
|
||||
self,
|
||||
tenant_id: str,
|
||||
sector: str = None,
|
||||
size: str = None,
|
||||
city: str = None,
|
||||
search: str = None,
|
||||
page: int = 1,
|
||||
per_page: int = 25,
|
||||
) -> dict:
|
||||
from app.models.company import Company
|
||||
|
||||
query = select(Company).where(
|
||||
Company.tenant_id == uuid.UUID(tenant_id),
|
||||
Company.is_active == True,
|
||||
)
|
||||
|
||||
if sector:
|
||||
query = query.where(Company.sector == sector)
|
||||
if size:
|
||||
query = query.where(Company.size == size)
|
||||
if city:
|
||||
query = query.where(Company.city == city)
|
||||
if search:
|
||||
pattern = f"%{search}%"
|
||||
query = query.where(
|
||||
or_(
|
||||
Company.name.ilike(pattern),
|
||||
Company.name_ar.ilike(pattern),
|
||||
Company.cr_number.ilike(pattern),
|
||||
)
|
||||
)
|
||||
|
||||
count_q = select(func.count()).select_from(query.subquery())
|
||||
total = (await self.db.execute(count_q)).scalar() or 0
|
||||
|
||||
query = query.order_by(Company.created_at.desc())
|
||||
query = query.offset((page - 1) * per_page).limit(per_page)
|
||||
result = await self.db.execute(query)
|
||||
companies = [self._to_dict(c) for c in result.scalars().all()]
|
||||
|
||||
return {"items": companies, "total": total, "page": page, "per_page": per_page}
|
||||
|
||||
async def update_company(self, tenant_id: str, company_id: str, **updates) -> Optional[dict]:
|
||||
from app.models.company import Company
|
||||
|
||||
result = await self.db.execute(
|
||||
select(Company).where(
|
||||
Company.id == uuid.UUID(company_id),
|
||||
Company.tenant_id == uuid.UUID(tenant_id),
|
||||
)
|
||||
)
|
||||
company = result.scalar_one_or_none()
|
||||
if not company:
|
||||
return None
|
||||
|
||||
for key, value in updates.items():
|
||||
if hasattr(company, key) and value is not None:
|
||||
setattr(company, key, value)
|
||||
|
||||
company.updated_at = datetime.now(timezone.utc)
|
||||
await self.db.flush()
|
||||
return self._to_dict(company)
|
||||
|
||||
async def get_company_contacts(self, tenant_id: str, company_id: str) -> list:
|
||||
from app.models.company import Contact
|
||||
|
||||
result = await self.db.execute(
|
||||
select(Contact).where(
|
||||
Contact.company_id == uuid.UUID(company_id),
|
||||
Contact.tenant_id == uuid.UUID(tenant_id),
|
||||
)
|
||||
)
|
||||
return [
|
||||
{
|
||||
"id": str(c.id),
|
||||
"full_name": c.full_name,
|
||||
"job_title": c.job_title,
|
||||
"email": c.email,
|
||||
"phone": c.phone,
|
||||
}
|
||||
for c in result.scalars().all()
|
||||
]
|
||||
|
||||
async def get_company_deals(self, tenant_id: str, company_id: str) -> list:
|
||||
from app.models.deal import Deal
|
||||
from app.models.lead import Lead
|
||||
|
||||
result = await self.db.execute(
|
||||
select(Deal)
|
||||
.join(Lead, Deal.lead_id == Lead.id)
|
||||
.where(
|
||||
Deal.tenant_id == uuid.UUID(tenant_id),
|
||||
Lead.company_name != "",
|
||||
)
|
||||
)
|
||||
return [
|
||||
{
|
||||
"id": str(d.id),
|
||||
"title": d.title,
|
||||
"stage": d.stage,
|
||||
"value": float(d.value) if d.value else 0,
|
||||
}
|
||||
for d in result.scalars().all()
|
||||
]
|
||||
|
||||
async def get_sector_breakdown(self, tenant_id: str) -> dict:
|
||||
from app.models.company import Company
|
||||
|
||||
q = (
|
||||
select(Company.sector, func.count().label("count"))
|
||||
.where(
|
||||
Company.tenant_id == uuid.UUID(tenant_id),
|
||||
Company.is_active == True,
|
||||
Company.sector != "",
|
||||
)
|
||||
.group_by(Company.sector)
|
||||
.order_by(func.count().desc())
|
||||
)
|
||||
rows = (await self.db.execute(q)).all()
|
||||
return {row.sector: row.count for row in rows}
|
||||
|
||||
@staticmethod
|
||||
def _to_dict(company) -> dict:
|
||||
if not company:
|
||||
return {}
|
||||
return {
|
||||
"id": str(company.id),
|
||||
"tenant_id": str(company.tenant_id),
|
||||
"name": company.name,
|
||||
"name_ar": company.name_ar,
|
||||
"sector": company.sector,
|
||||
"size": company.size,
|
||||
"city": company.city,
|
||||
"region": company.region,
|
||||
"cr_number": company.cr_number,
|
||||
"website": company.website,
|
||||
"is_active": company.is_active,
|
||||
"created_at": company.created_at.isoformat() if company.created_at else None,
|
||||
}
|
||||
269
salesflow-saas/backend/app/services/crm_sync_service.py
Normal file
269
salesflow-saas/backend/app/services/crm_sync_service.py
Normal file
@ -0,0 +1,269 @@
|
||||
"""
|
||||
CRM Sync Service — Bidirectional sync with Salesforce, HubSpot, and generic CRMs.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
class CRMSyncService:
|
||||
"""
|
||||
Manages bidirectional data sync between Dealix and external CRM systems.
|
||||
Supports Salesforce, HubSpot, and generic webhook-based CRMs.
|
||||
"""
|
||||
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
|
||||
# ── Salesforce ────────────────────────────────
|
||||
|
||||
async def salesforce_push_lead(self, lead: dict, credentials: dict) -> dict:
|
||||
"""Push a lead from Dealix to Salesforce."""
|
||||
access_token = credentials.get("access_token")
|
||||
instance_url = credentials.get("instance_url")
|
||||
|
||||
if not access_token or not instance_url:
|
||||
return {"status": "error", "message": "Invalid Salesforce credentials"}
|
||||
|
||||
sf_lead = {
|
||||
"FirstName": lead.get("full_name", "").split()[0] if lead.get("full_name") else "",
|
||||
"LastName": lead.get("full_name", "").split()[-1] if lead.get("full_name") else "Unknown",
|
||||
"Phone": lead.get("phone", ""),
|
||||
"Email": lead.get("email", ""),
|
||||
"Company": lead.get("company_name", "Unknown"),
|
||||
"Industry": lead.get("sector", ""),
|
||||
"City": lead.get("city", ""),
|
||||
"LeadSource": f"Dealix - {lead.get('source', 'web')}",
|
||||
"Description": lead.get("notes", ""),
|
||||
"Rating": self._score_to_sf_rating(lead.get("score", 0)),
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(
|
||||
f"{instance_url}/services/data/{settings.SALESFORCE_API_VERSION}/sobjects/Lead/",
|
||||
headers={
|
||||
"Authorization": f"Bearer {access_token}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
json=sf_lead,
|
||||
)
|
||||
|
||||
if response.status_code in (200, 201):
|
||||
sf_id = response.json().get("id")
|
||||
return {"status": "success", "salesforce_id": sf_id}
|
||||
return {"status": "error", "message": response.text}
|
||||
|
||||
async def salesforce_pull_leads(self, credentials: dict, since: str = None) -> list:
|
||||
"""Pull leads from Salesforce into Dealix."""
|
||||
access_token = credentials.get("access_token")
|
||||
instance_url = credentials.get("instance_url")
|
||||
|
||||
query = "SELECT Id, FirstName, LastName, Phone, Email, Company, Industry, City, Rating FROM Lead"
|
||||
if since:
|
||||
query += f" WHERE LastModifiedDate > {since}"
|
||||
query += " ORDER BY LastModifiedDate DESC LIMIT 100"
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(
|
||||
f"{instance_url}/services/data/{settings.SALESFORCE_API_VERSION}/query/",
|
||||
params={"q": query},
|
||||
headers={"Authorization": f"Bearer {access_token}"},
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
return []
|
||||
|
||||
records = response.json().get("records", [])
|
||||
return [
|
||||
{
|
||||
"external_id": r["Id"],
|
||||
"full_name": f"{r.get('FirstName', '')} {r.get('LastName', '')}".strip(),
|
||||
"phone": r.get("Phone", ""),
|
||||
"email": r.get("Email", ""),
|
||||
"company_name": r.get("Company", ""),
|
||||
"sector": r.get("Industry", ""),
|
||||
"city": r.get("City", ""),
|
||||
}
|
||||
for r in records
|
||||
]
|
||||
|
||||
# ── HubSpot ───────────────────────────────────
|
||||
|
||||
async def hubspot_push_contact(self, lead: dict, api_key: str) -> dict:
|
||||
"""Push a contact from Dealix to HubSpot."""
|
||||
hs_contact = {
|
||||
"properties": {
|
||||
"firstname": lead.get("full_name", "").split()[0] if lead.get("full_name") else "",
|
||||
"lastname": lead.get("full_name", "").split()[-1] if lead.get("full_name") else "",
|
||||
"phone": lead.get("phone", ""),
|
||||
"email": lead.get("email", ""),
|
||||
"company": lead.get("company_name", ""),
|
||||
"industry": lead.get("sector", ""),
|
||||
"city": lead.get("city", ""),
|
||||
"leadsource": f"Dealix - {lead.get('source', 'web')}",
|
||||
"hs_lead_status": self._status_to_hs(lead.get("status", "new")),
|
||||
}
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(
|
||||
"https://api.hubapi.com/crm/v3/objects/contacts",
|
||||
headers={
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
json=hs_contact,
|
||||
)
|
||||
|
||||
if response.status_code in (200, 201):
|
||||
hs_id = response.json().get("id")
|
||||
return {"status": "success", "hubspot_id": hs_id}
|
||||
return {"status": "error", "message": response.text}
|
||||
|
||||
async def hubspot_pull_contacts(self, api_key: str, after: str = None) -> list:
|
||||
"""Pull contacts from HubSpot into Dealix."""
|
||||
params = {
|
||||
"limit": 100,
|
||||
"properties": "firstname,lastname,phone,email,company,industry,city",
|
||||
}
|
||||
if after:
|
||||
params["after"] = after
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(
|
||||
"https://api.hubapi.com/crm/v3/objects/contacts",
|
||||
params=params,
|
||||
headers={"Authorization": f"Bearer {api_key}"},
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
return []
|
||||
|
||||
results = response.json().get("results", [])
|
||||
return [
|
||||
{
|
||||
"external_id": r["id"],
|
||||
"full_name": f"{r['properties'].get('firstname', '')} {r['properties'].get('lastname', '')}".strip(),
|
||||
"phone": r["properties"].get("phone", ""),
|
||||
"email": r["properties"].get("email", ""),
|
||||
"company_name": r["properties"].get("company", ""),
|
||||
"sector": r["properties"].get("industry", ""),
|
||||
"city": r["properties"].get("city", ""),
|
||||
}
|
||||
for r in results
|
||||
]
|
||||
|
||||
# ── Generic Sync ──────────────────────────────
|
||||
|
||||
async def sync_lead_to_crm(
|
||||
self, tenant_id: str, lead_id: str, provider: str
|
||||
) -> dict:
|
||||
"""Sync a lead to the configured CRM for this tenant."""
|
||||
from app.services.lead_service import LeadService
|
||||
|
||||
lead_svc = LeadService(self.db)
|
||||
lead = await lead_svc.get_lead(tenant_id, lead_id)
|
||||
if not lead:
|
||||
return {"status": "error", "message": "Lead not found"}
|
||||
|
||||
# Get CRM credentials for tenant (from tenant settings)
|
||||
credentials = await self._get_crm_credentials(tenant_id, provider)
|
||||
if not credentials:
|
||||
return {"status": "error", "message": f"No {provider} credentials configured"}
|
||||
|
||||
if provider == "salesforce":
|
||||
return await self.salesforce_push_lead(lead, credentials)
|
||||
elif provider == "hubspot":
|
||||
return await self.hubspot_push_contact(lead, credentials.get("api_key", ""))
|
||||
|
||||
return {"status": "error", "message": f"Unsupported provider: {provider}"}
|
||||
|
||||
async def full_sync(self, tenant_id: str, provider: str) -> dict:
|
||||
"""Full bidirectional sync with CRM."""
|
||||
pushed = 0
|
||||
pulled = 0
|
||||
errors = []
|
||||
|
||||
credentials = await self._get_crm_credentials(tenant_id, provider)
|
||||
if not credentials:
|
||||
return {"status": "error", "message": "No credentials configured"}
|
||||
|
||||
# Pull from CRM
|
||||
try:
|
||||
if provider == "salesforce":
|
||||
external_leads = await self.salesforce_pull_leads(credentials)
|
||||
elif provider == "hubspot":
|
||||
external_leads = await self.hubspot_pull_contacts(credentials.get("api_key", ""))
|
||||
else:
|
||||
external_leads = []
|
||||
|
||||
from app.services.lead_service import LeadService
|
||||
lead_svc = LeadService(self.db)
|
||||
|
||||
for ext_lead in external_leads:
|
||||
try:
|
||||
await lead_svc.create_lead(
|
||||
tenant_id=tenant_id,
|
||||
full_name=ext_lead["full_name"],
|
||||
phone=ext_lead.get("phone", ""),
|
||||
email=ext_lead.get("email", ""),
|
||||
company_name=ext_lead.get("company_name", ""),
|
||||
sector=ext_lead.get("sector", ""),
|
||||
city=ext_lead.get("city", ""),
|
||||
source=provider,
|
||||
)
|
||||
pulled += 1
|
||||
except Exception as e:
|
||||
errors.append({"type": "pull", "error": str(e)})
|
||||
|
||||
except Exception as e:
|
||||
errors.append({"type": "pull_batch", "error": str(e)})
|
||||
|
||||
return {
|
||||
"status": "completed",
|
||||
"pushed": pushed,
|
||||
"pulled": pulled,
|
||||
"errors": errors,
|
||||
}
|
||||
|
||||
# ── Helpers ───────────────────────────────────
|
||||
|
||||
async def _get_crm_credentials(self, tenant_id: str, provider: str) -> Optional[dict]:
|
||||
"""Get CRM credentials from tenant settings."""
|
||||
# In production, this would fetch from encrypted tenant settings
|
||||
if provider == "salesforce":
|
||||
return {
|
||||
"access_token": settings.SALESFORCE_CLIENT_SECRET,
|
||||
"instance_url": "",
|
||||
} if settings.SALESFORCE_CLIENT_ID else None
|
||||
elif provider == "hubspot":
|
||||
return {
|
||||
"api_key": settings.HUBSPOT_API_KEY,
|
||||
} if settings.HUBSPOT_API_KEY else None
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _score_to_sf_rating(score: int) -> str:
|
||||
if score >= 80:
|
||||
return "Hot"
|
||||
elif score >= 50:
|
||||
return "Warm"
|
||||
return "Cold"
|
||||
|
||||
@staticmethod
|
||||
def _status_to_hs(status: str) -> str:
|
||||
mapping = {
|
||||
"new": "NEW",
|
||||
"contacted": "IN_PROGRESS",
|
||||
"qualified": "QUALIFIED",
|
||||
"converted": "CUSTOMER",
|
||||
"lost": "UNQUALIFIED",
|
||||
}
|
||||
return mapping.get(status, "NEW")
|
||||
237
salesflow-saas/backend/app/services/deal_service.py
Normal file
237
salesflow-saas/backend/app/services/deal_service.py
Normal file
@ -0,0 +1,237 @@
|
||||
"""
|
||||
Deal Service — Pipeline management, stage transitions, forecasting.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from decimal import Decimal
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import select, func, and_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
|
||||
VALID_STAGES = ["discovery", "proposal", "negotiation", "closed_won", "closed_lost"]
|
||||
STAGE_PROBABILITIES = {
|
||||
"discovery": 20,
|
||||
"proposal": 40,
|
||||
"negotiation": 60,
|
||||
"closed_won": 100,
|
||||
"closed_lost": 0,
|
||||
}
|
||||
|
||||
|
||||
class DealService:
|
||||
"""Manages the deal pipeline from discovery to close."""
|
||||
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
|
||||
# ── CRUD ──────────────────────────────────────
|
||||
|
||||
async def create_deal(
|
||||
self,
|
||||
tenant_id: str,
|
||||
lead_id: str = None,
|
||||
assigned_to: str = None,
|
||||
title: str = "",
|
||||
stage: str = "discovery",
|
||||
value: float = 0,
|
||||
currency: str = "SAR",
|
||||
expected_close: str = None,
|
||||
) -> dict:
|
||||
from app.models.deal import Deal
|
||||
|
||||
deal = Deal(
|
||||
id=uuid.uuid4(),
|
||||
tenant_id=uuid.UUID(tenant_id),
|
||||
lead_id=uuid.UUID(lead_id) if lead_id else None,
|
||||
assigned_to=uuid.UUID(assigned_to) if assigned_to else None,
|
||||
title=title,
|
||||
stage=stage,
|
||||
value=Decimal(str(value)),
|
||||
currency=currency,
|
||||
probability=STAGE_PROBABILITIES.get(stage, 20),
|
||||
expected_close=datetime.fromisoformat(expected_close) if expected_close else None,
|
||||
)
|
||||
self.db.add(deal)
|
||||
await self.db.flush()
|
||||
return self._to_dict(deal)
|
||||
|
||||
async def get_deal(self, tenant_id: str, deal_id: str) -> Optional[dict]:
|
||||
from app.models.deal import Deal
|
||||
|
||||
result = await self.db.execute(
|
||||
select(Deal).where(
|
||||
Deal.id == uuid.UUID(deal_id),
|
||||
Deal.tenant_id == uuid.UUID(tenant_id),
|
||||
)
|
||||
)
|
||||
deal = result.scalar_one_or_none()
|
||||
return self._to_dict(deal) if deal else None
|
||||
|
||||
async def list_deals(
|
||||
self,
|
||||
tenant_id: str,
|
||||
stage: str = None,
|
||||
assigned_to: str = None,
|
||||
min_value: float = None,
|
||||
page: int = 1,
|
||||
per_page: int = 25,
|
||||
) -> dict:
|
||||
from app.models.deal import Deal
|
||||
|
||||
query = select(Deal).where(Deal.tenant_id == uuid.UUID(tenant_id))
|
||||
|
||||
if stage:
|
||||
query = query.where(Deal.stage == stage)
|
||||
if assigned_to:
|
||||
query = query.where(Deal.assigned_to == uuid.UUID(assigned_to))
|
||||
if min_value is not None:
|
||||
query = query.where(Deal.value >= Decimal(str(min_value)))
|
||||
|
||||
count_q = select(func.count()).select_from(query.subquery())
|
||||
total = (await self.db.execute(count_q)).scalar() or 0
|
||||
|
||||
query = query.order_by(Deal.created_at.desc())
|
||||
query = query.offset((page - 1) * per_page).limit(per_page)
|
||||
result = await self.db.execute(query)
|
||||
deals = [self._to_dict(d) for d in result.scalars().all()]
|
||||
|
||||
return {
|
||||
"items": deals,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"per_page": per_page,
|
||||
}
|
||||
|
||||
async def update_deal(self, tenant_id: str, deal_id: str, **updates) -> Optional[dict]:
|
||||
from app.models.deal import Deal
|
||||
|
||||
result = await self.db.execute(
|
||||
select(Deal).where(
|
||||
Deal.id == uuid.UUID(deal_id),
|
||||
Deal.tenant_id == uuid.UUID(tenant_id),
|
||||
)
|
||||
)
|
||||
deal = result.scalar_one_or_none()
|
||||
if not deal:
|
||||
return None
|
||||
|
||||
for key, value in updates.items():
|
||||
if hasattr(deal, key) and value is not None:
|
||||
setattr(deal, key, value)
|
||||
|
||||
deal.updated_at = datetime.now(timezone.utc)
|
||||
await self.db.flush()
|
||||
return self._to_dict(deal)
|
||||
|
||||
# ── Stage Management ──────────────────────────
|
||||
|
||||
async def move_stage(
|
||||
self,
|
||||
tenant_id: str,
|
||||
deal_id: str,
|
||||
new_stage: str,
|
||||
lost_reason: str = None,
|
||||
) -> Optional[dict]:
|
||||
if new_stage not in VALID_STAGES:
|
||||
return None
|
||||
|
||||
updates = {
|
||||
"stage": new_stage,
|
||||
"probability": STAGE_PROBABILITIES[new_stage],
|
||||
}
|
||||
|
||||
if new_stage == "closed_won":
|
||||
updates["closed_at"] = datetime.now(timezone.utc)
|
||||
elif new_stage == "closed_lost":
|
||||
updates["closed_at"] = datetime.now(timezone.utc)
|
||||
if lost_reason:
|
||||
updates["lost_reason"] = lost_reason
|
||||
|
||||
return await self.update_deal(tenant_id, deal_id, **updates)
|
||||
|
||||
# ── Pipeline Analytics ────────────────────────
|
||||
|
||||
async def get_pipeline(self, tenant_id: str) -> dict:
|
||||
from app.models.deal import Deal
|
||||
|
||||
pipeline = {}
|
||||
for stage in VALID_STAGES:
|
||||
count_q = select(func.count()).where(
|
||||
Deal.tenant_id == uuid.UUID(tenant_id),
|
||||
Deal.stage == stage,
|
||||
)
|
||||
value_q = select(func.coalesce(func.sum(Deal.value), 0)).where(
|
||||
Deal.tenant_id == uuid.UUID(tenant_id),
|
||||
Deal.stage == stage,
|
||||
)
|
||||
count = (await self.db.execute(count_q)).scalar() or 0
|
||||
value = (await self.db.execute(value_q)).scalar() or 0
|
||||
pipeline[stage] = {
|
||||
"count": count,
|
||||
"value": float(value),
|
||||
"weighted": float(value) * STAGE_PROBABILITIES[stage] / 100,
|
||||
}
|
||||
|
||||
total_value = sum(s["value"] for s in pipeline.values())
|
||||
total_weighted = sum(s["weighted"] for s in pipeline.values())
|
||||
|
||||
return {
|
||||
"stages": pipeline,
|
||||
"total_deals": sum(s["count"] for s in pipeline.values()),
|
||||
"total_value": total_value,
|
||||
"weighted_value": total_weighted,
|
||||
}
|
||||
|
||||
async def get_forecast(self, tenant_id: str) -> dict:
|
||||
from app.models.deal import Deal
|
||||
|
||||
open_stages = ["discovery", "proposal", "negotiation"]
|
||||
monthly = {}
|
||||
|
||||
for stage in open_stages:
|
||||
q = select(
|
||||
func.date_trunc("month", Deal.expected_close).label("month"),
|
||||
func.sum(Deal.value).label("value"),
|
||||
func.count().label("count"),
|
||||
).where(
|
||||
Deal.tenant_id == uuid.UUID(tenant_id),
|
||||
Deal.stage == stage,
|
||||
Deal.expected_close.isnot(None),
|
||||
).group_by("month")
|
||||
|
||||
rows = (await self.db.execute(q)).all()
|
||||
for row in rows:
|
||||
key = str(row.month)
|
||||
if key not in monthly:
|
||||
monthly[key] = {"value": 0, "weighted": 0, "count": 0}
|
||||
monthly[key]["value"] += float(row.value or 0)
|
||||
monthly[key]["weighted"] += float(row.value or 0) * STAGE_PROBABILITIES[stage] / 100
|
||||
monthly[key]["count"] += row.count
|
||||
|
||||
return {"monthly_forecast": monthly}
|
||||
|
||||
# ── Helpers ───────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def _to_dict(deal) -> dict:
|
||||
if not deal:
|
||||
return {}
|
||||
return {
|
||||
"id": str(deal.id),
|
||||
"tenant_id": str(deal.tenant_id),
|
||||
"lead_id": str(deal.lead_id) if deal.lead_id else None,
|
||||
"assigned_to": str(deal.assigned_to) if deal.assigned_to else None,
|
||||
"title": deal.title,
|
||||
"stage": deal.stage,
|
||||
"value": float(deal.value) if deal.value else 0,
|
||||
"currency": deal.currency,
|
||||
"probability": deal.probability,
|
||||
"expected_close": deal.expected_close.isoformat() if deal.expected_close else None,
|
||||
"closed_at": deal.closed_at.isoformat() if deal.closed_at else None,
|
||||
"lost_reason": deal.lost_reason,
|
||||
"created_at": deal.created_at.isoformat() if deal.created_at else None,
|
||||
"updated_at": deal.updated_at.isoformat() if deal.updated_at else None,
|
||||
}
|
||||
377
salesflow-saas/backend/app/services/lead_service.py
Normal file
377
salesflow-saas/backend/app/services/lead_service.py
Normal file
@ -0,0 +1,377 @@
|
||||
"""
|
||||
Lead Service — CRUD, qualification, scoring, assignment, import/export.
|
||||
The heart of the sales pipeline.
|
||||
"""
|
||||
|
||||
import csv
|
||||
import io
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import select, func, and_, or_, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
|
||||
class LeadService:
|
||||
"""Manages the full lifecycle of leads from creation to conversion."""
|
||||
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
|
||||
# ── CRUD ──────────────────────────────────────
|
||||
|
||||
async def create_lead(
|
||||
self,
|
||||
tenant_id: str,
|
||||
full_name: str,
|
||||
phone: str = "",
|
||||
email: str = "",
|
||||
company_name: str = "",
|
||||
sector: str = "",
|
||||
city: str = "",
|
||||
source: str = "web",
|
||||
notes: str = "",
|
||||
assigned_to: str = None,
|
||||
) -> dict:
|
||||
from app.models.lead import Lead
|
||||
|
||||
lead = Lead(
|
||||
id=uuid.uuid4(),
|
||||
tenant_id=uuid.UUID(tenant_id),
|
||||
full_name=full_name,
|
||||
phone=phone,
|
||||
email=email,
|
||||
company_name=company_name,
|
||||
sector=sector,
|
||||
city=city,
|
||||
source=source,
|
||||
status="new",
|
||||
score=0,
|
||||
notes=notes,
|
||||
assigned_to=uuid.UUID(assigned_to) if assigned_to else None,
|
||||
)
|
||||
self.db.add(lead)
|
||||
await self.db.flush()
|
||||
return self._to_dict(lead)
|
||||
|
||||
async def get_lead(self, tenant_id: str, lead_id: str) -> Optional[dict]:
|
||||
from app.models.lead import Lead
|
||||
|
||||
result = await self.db.execute(
|
||||
select(Lead).where(
|
||||
Lead.id == uuid.UUID(lead_id),
|
||||
Lead.tenant_id == uuid.UUID(tenant_id),
|
||||
)
|
||||
)
|
||||
lead = result.scalar_one_or_none()
|
||||
return self._to_dict(lead) if lead else None
|
||||
|
||||
async def list_leads(
|
||||
self,
|
||||
tenant_id: str,
|
||||
status: str = None,
|
||||
source: str = None,
|
||||
sector: str = None,
|
||||
city: str = None,
|
||||
assigned_to: str = None,
|
||||
min_score: int = None,
|
||||
search: str = None,
|
||||
page: int = 1,
|
||||
per_page: int = 25,
|
||||
sort_by: str = "created_at",
|
||||
sort_dir: str = "desc",
|
||||
) -> dict:
|
||||
from app.models.lead import Lead
|
||||
|
||||
query = select(Lead).where(Lead.tenant_id == uuid.UUID(tenant_id))
|
||||
|
||||
if status:
|
||||
query = query.where(Lead.status == status)
|
||||
if source:
|
||||
query = query.where(Lead.source == source)
|
||||
if sector:
|
||||
query = query.where(Lead.sector == sector)
|
||||
if city:
|
||||
query = query.where(Lead.city == city)
|
||||
if assigned_to:
|
||||
query = query.where(Lead.assigned_to == uuid.UUID(assigned_to))
|
||||
if min_score is not None:
|
||||
query = query.where(Lead.score >= min_score)
|
||||
if search:
|
||||
pattern = f"%{search}%"
|
||||
query = query.where(
|
||||
or_(
|
||||
Lead.full_name.ilike(pattern),
|
||||
Lead.email.ilike(pattern),
|
||||
Lead.phone.ilike(pattern),
|
||||
Lead.company_name.ilike(pattern),
|
||||
)
|
||||
)
|
||||
|
||||
# Count
|
||||
count_q = select(func.count()).select_from(query.subquery())
|
||||
total = (await self.db.execute(count_q)).scalar() or 0
|
||||
|
||||
# Sort
|
||||
sort_col = getattr(Lead, sort_by, Lead.created_at)
|
||||
if sort_dir == "asc":
|
||||
query = query.order_by(sort_col.asc())
|
||||
else:
|
||||
query = query.order_by(sort_col.desc())
|
||||
|
||||
# Paginate
|
||||
query = query.offset((page - 1) * per_page).limit(per_page)
|
||||
result = await self.db.execute(query)
|
||||
leads = [self._to_dict(l) for l in result.scalars().all()]
|
||||
|
||||
return {
|
||||
"items": leads,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"per_page": per_page,
|
||||
"pages": (total + per_page - 1) // per_page,
|
||||
}
|
||||
|
||||
async def update_lead(
|
||||
self, tenant_id: str, lead_id: str, **updates
|
||||
) -> Optional[dict]:
|
||||
from app.models.lead import Lead
|
||||
|
||||
result = await self.db.execute(
|
||||
select(Lead).where(
|
||||
Lead.id == uuid.UUID(lead_id),
|
||||
Lead.tenant_id == uuid.UUID(tenant_id),
|
||||
)
|
||||
)
|
||||
lead = result.scalar_one_or_none()
|
||||
if not lead:
|
||||
return None
|
||||
|
||||
for key, value in updates.items():
|
||||
if hasattr(lead, key) and value is not None:
|
||||
setattr(lead, key, value)
|
||||
|
||||
lead.updated_at = datetime.now(timezone.utc)
|
||||
await self.db.flush()
|
||||
return self._to_dict(lead)
|
||||
|
||||
async def delete_lead(self, tenant_id: str, lead_id: str) -> bool:
|
||||
from app.models.lead import Lead
|
||||
|
||||
result = await self.db.execute(
|
||||
select(Lead).where(
|
||||
Lead.id == uuid.UUID(lead_id),
|
||||
Lead.tenant_id == uuid.UUID(tenant_id),
|
||||
)
|
||||
)
|
||||
lead = result.scalar_one_or_none()
|
||||
if not lead:
|
||||
return False
|
||||
|
||||
lead.status = "deleted"
|
||||
lead.updated_at = datetime.now(timezone.utc)
|
||||
await self.db.flush()
|
||||
return True
|
||||
|
||||
# ── Assignment ────────────────────────────────
|
||||
|
||||
async def assign_lead(
|
||||
self,
|
||||
tenant_id: str,
|
||||
lead_id: str,
|
||||
agent_id: str,
|
||||
) -> Optional[dict]:
|
||||
return await self.update_lead(
|
||||
tenant_id, lead_id, assigned_to=uuid.UUID(agent_id)
|
||||
)
|
||||
|
||||
async def auto_assign_round_robin(self, tenant_id: str, lead_id: str) -> Optional[dict]:
|
||||
"""Assign lead to the agent with the fewest active leads."""
|
||||
from app.models.user import User
|
||||
from app.models.lead import Lead
|
||||
|
||||
# Get active agents
|
||||
agents_q = select(User.id).where(
|
||||
User.tenant_id == uuid.UUID(tenant_id),
|
||||
User.role.in_(["agent", "manager"]),
|
||||
User.is_active == True,
|
||||
)
|
||||
agents = (await self.db.execute(agents_q)).scalars().all()
|
||||
if not agents:
|
||||
return None
|
||||
|
||||
# Count active leads per agent
|
||||
best_agent = None
|
||||
min_leads = float("inf")
|
||||
for agent_id in agents:
|
||||
count_q = select(func.count()).where(
|
||||
Lead.tenant_id == uuid.UUID(tenant_id),
|
||||
Lead.assigned_to == agent_id,
|
||||
Lead.status.in_(["new", "contacted", "qualified"]),
|
||||
)
|
||||
count = (await self.db.execute(count_q)).scalar() or 0
|
||||
if count < min_leads:
|
||||
min_leads = count
|
||||
best_agent = agent_id
|
||||
|
||||
if best_agent:
|
||||
return await self.assign_lead(tenant_id, lead_id, str(best_agent))
|
||||
return None
|
||||
|
||||
# ── Qualification ─────────────────────────────
|
||||
|
||||
async def qualify_lead(
|
||||
self,
|
||||
tenant_id: str,
|
||||
lead_id: str,
|
||||
score: int,
|
||||
status: str = None,
|
||||
reasoning: str = "",
|
||||
) -> Optional[dict]:
|
||||
updates = {"score": score}
|
||||
if status:
|
||||
updates["status"] = status
|
||||
if score >= 70:
|
||||
updates["status"] = "qualified"
|
||||
updates["qualified_at"] = datetime.now(timezone.utc)
|
||||
elif score < 30:
|
||||
updates["status"] = "lost"
|
||||
else:
|
||||
updates["status"] = "contacted"
|
||||
|
||||
return await self.update_lead(tenant_id, lead_id, **updates)
|
||||
|
||||
# ── Conversion ────────────────────────────────
|
||||
|
||||
async def convert_to_deal(
|
||||
self,
|
||||
tenant_id: str,
|
||||
lead_id: str,
|
||||
deal_title: str = "",
|
||||
deal_value: float = 0,
|
||||
) -> Optional[dict]:
|
||||
from app.models.deal import Deal
|
||||
|
||||
lead = await self.get_lead(tenant_id, lead_id)
|
||||
if not lead:
|
||||
return None
|
||||
|
||||
deal = Deal(
|
||||
id=uuid.uuid4(),
|
||||
tenant_id=uuid.UUID(tenant_id),
|
||||
lead_id=uuid.UUID(lead_id),
|
||||
assigned_to=uuid.UUID(lead["assigned_to"]) if lead.get("assigned_to") else None,
|
||||
title=deal_title or f"Deal - {lead['full_name']}",
|
||||
stage="discovery",
|
||||
value=deal_value,
|
||||
currency="SAR",
|
||||
probability=20,
|
||||
)
|
||||
self.db.add(deal)
|
||||
|
||||
await self.update_lead(
|
||||
tenant_id,
|
||||
lead_id,
|
||||
status="converted",
|
||||
converted_at=datetime.now(timezone.utc),
|
||||
)
|
||||
await self.db.flush()
|
||||
|
||||
return {
|
||||
"deal_id": str(deal.id),
|
||||
"lead_id": lead_id,
|
||||
"title": deal.title,
|
||||
"stage": deal.stage,
|
||||
"value": float(deal.value),
|
||||
}
|
||||
|
||||
# ── Import/Export ─────────────────────────────
|
||||
|
||||
async def import_from_csv(self, tenant_id: str, csv_content: str) -> dict:
|
||||
reader = csv.DictReader(io.StringIO(csv_content))
|
||||
created = 0
|
||||
errors = []
|
||||
|
||||
for i, row in enumerate(reader, 1):
|
||||
try:
|
||||
await self.create_lead(
|
||||
tenant_id=tenant_id,
|
||||
full_name=row.get("name", row.get("full_name", "")),
|
||||
phone=row.get("phone", ""),
|
||||
email=row.get("email", ""),
|
||||
company_name=row.get("company", row.get("company_name", "")),
|
||||
sector=row.get("sector", row.get("industry", "")),
|
||||
city=row.get("city", ""),
|
||||
source="import",
|
||||
)
|
||||
created += 1
|
||||
except Exception as e:
|
||||
errors.append({"row": i, "error": str(e)})
|
||||
|
||||
return {"created": created, "errors": errors, "total_rows": created + len(errors)}
|
||||
|
||||
async def export_to_csv(self, tenant_id: str, **filters) -> str:
|
||||
data = await self.list_leads(tenant_id, per_page=10000, **filters)
|
||||
output = io.StringIO()
|
||||
if not data["items"]:
|
||||
return ""
|
||||
|
||||
writer = csv.DictWriter(output, fieldnames=data["items"][0].keys())
|
||||
writer.writeheader()
|
||||
writer.writerows(data["items"])
|
||||
return output.getvalue()
|
||||
|
||||
# ── Stats ─────────────────────────────────────
|
||||
|
||||
async def get_stats(self, tenant_id: str) -> dict:
|
||||
from app.models.lead import Lead
|
||||
|
||||
base = select(func.count()).where(Lead.tenant_id == uuid.UUID(tenant_id))
|
||||
total = (await self.db.execute(base)).scalar() or 0
|
||||
|
||||
statuses = {}
|
||||
for s in ["new", "contacted", "qualified", "converted", "lost"]:
|
||||
q = base.where(Lead.status == s)
|
||||
statuses[s] = (await self.db.execute(q)).scalar() or 0
|
||||
|
||||
avg_score_q = select(func.avg(Lead.score)).where(
|
||||
Lead.tenant_id == uuid.UUID(tenant_id),
|
||||
Lead.score > 0,
|
||||
)
|
||||
avg_score = (await self.db.execute(avg_score_q)).scalar() or 0
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"by_status": statuses,
|
||||
"avg_score": round(float(avg_score), 1),
|
||||
"conversion_rate": round(
|
||||
(statuses.get("converted", 0) / total * 100) if total > 0 else 0, 1
|
||||
),
|
||||
}
|
||||
|
||||
# ── Helpers ───────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def _to_dict(lead) -> dict:
|
||||
if not lead:
|
||||
return {}
|
||||
return {
|
||||
"id": str(lead.id),
|
||||
"tenant_id": str(lead.tenant_id),
|
||||
"assigned_to": str(lead.assigned_to) if lead.assigned_to else None,
|
||||
"source": lead.source,
|
||||
"status": lead.status,
|
||||
"score": lead.score,
|
||||
"full_name": lead.full_name,
|
||||
"phone": lead.phone,
|
||||
"email": lead.email,
|
||||
"company_name": lead.company_name,
|
||||
"sector": lead.sector,
|
||||
"city": lead.city,
|
||||
"notes": lead.notes,
|
||||
"qualified_at": lead.qualified_at.isoformat() if lead.qualified_at else None,
|
||||
"converted_at": lead.converted_at.isoformat() if lead.converted_at else None,
|
||||
"created_at": lead.created_at.isoformat() if lead.created_at else None,
|
||||
"updated_at": lead.updated_at.isoformat() if lead.updated_at else None,
|
||||
}
|
||||
4
salesflow-saas/backend/app/services/llm/__init__.py
Normal file
4
salesflow-saas/backend/app/services/llm/__init__.py
Normal file
@ -0,0 +1,4 @@
|
||||
"""LLM services package."""
|
||||
from app.services.llm.provider import LLMRouter, get_llm, LLMResponse
|
||||
|
||||
__all__ = ["LLMRouter", "get_llm", "LLMResponse"]
|
||||
249
salesflow-saas/backend/app/services/llm/provider.py
Normal file
249
salesflow-saas/backend/app/services/llm/provider.py
Normal file
@ -0,0 +1,249 @@
|
||||
"""
|
||||
LLM Provider Abstraction Layer
|
||||
Supports Groq (primary) and OpenAI (fallback) with automatic failover.
|
||||
"""
|
||||
|
||||
import time
|
||||
import json
|
||||
import logging
|
||||
from typing import Optional, AsyncGenerator
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
logger = logging.getLogger("dealix.llm")
|
||||
|
||||
|
||||
class LLMResponse:
|
||||
"""Standardized LLM response across providers."""
|
||||
def __init__(self, content: str, tokens_used: int = 0, latency_ms: int = 0,
|
||||
provider: str = "", model: str = "", raw: dict = None):
|
||||
self.content = content
|
||||
self.tokens_used = tokens_used
|
||||
self.latency_ms = latency_ms
|
||||
self.provider = provider
|
||||
self.model = model
|
||||
self.raw = raw or {}
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"content": self.content,
|
||||
"tokens_used": self.tokens_used,
|
||||
"latency_ms": self.latency_ms,
|
||||
"provider": self.provider,
|
||||
"model": self.model,
|
||||
}
|
||||
|
||||
def parse_json(self) -> Optional[dict]:
|
||||
"""Try to parse content as JSON."""
|
||||
try:
|
||||
# Handle markdown code blocks
|
||||
text = self.content.strip()
|
||||
if text.startswith("```json"):
|
||||
text = text[7:]
|
||||
if text.startswith("```"):
|
||||
text = text[3:]
|
||||
if text.endswith("```"):
|
||||
text = text[:-3]
|
||||
return json.loads(text.strip())
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
class BaseLLMProvider(ABC):
|
||||
"""Abstract base for LLM providers."""
|
||||
|
||||
@abstractmethod
|
||||
async def complete(self, system_prompt: str, user_message: str,
|
||||
temperature: float = None, max_tokens: int = None,
|
||||
json_mode: bool = False) -> LLMResponse:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def is_available(self) -> bool:
|
||||
pass
|
||||
|
||||
|
||||
class GroqProvider(BaseLLMProvider):
|
||||
"""Groq API provider — ultra-fast inference."""
|
||||
|
||||
def __init__(self):
|
||||
from groq import AsyncGroq
|
||||
self.client = AsyncGroq(api_key=settings.GROQ_API_KEY) if settings.GROQ_API_KEY else None
|
||||
self.model = settings.GROQ_MODEL
|
||||
self.fast_model = settings.GROQ_FAST_MODEL
|
||||
|
||||
async def is_available(self) -> bool:
|
||||
return bool(settings.GROQ_API_KEY and self.client)
|
||||
|
||||
async def complete(self, system_prompt: str, user_message: str,
|
||||
temperature: float = None, max_tokens: int = None,
|
||||
json_mode: bool = False, fast: bool = False) -> LLMResponse:
|
||||
if not self.client:
|
||||
raise RuntimeError("Groq API key not configured")
|
||||
|
||||
model = self.fast_model if fast else self.model
|
||||
start = time.time()
|
||||
|
||||
kwargs = {
|
||||
"model": model,
|
||||
"messages": [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": user_message},
|
||||
],
|
||||
"temperature": temperature or settings.LLM_TEMPERATURE,
|
||||
"max_tokens": max_tokens or settings.LLM_MAX_TOKENS,
|
||||
}
|
||||
|
||||
if json_mode:
|
||||
kwargs["response_format"] = {"type": "json_object"}
|
||||
|
||||
response = await self.client.chat.completions.create(**kwargs)
|
||||
latency = int((time.time() - start) * 1000)
|
||||
|
||||
return LLMResponse(
|
||||
content=response.choices[0].message.content or "",
|
||||
tokens_used=response.usage.total_tokens if response.usage else 0,
|
||||
latency_ms=latency,
|
||||
provider="groq",
|
||||
model=model,
|
||||
raw=response.model_dump() if hasattr(response, "model_dump") else {},
|
||||
)
|
||||
|
||||
|
||||
class OpenAIProvider(BaseLLMProvider):
|
||||
"""OpenAI API provider — highest quality, fallback."""
|
||||
|
||||
def __init__(self):
|
||||
from openai import AsyncOpenAI
|
||||
self.client = AsyncOpenAI(api_key=settings.OPENAI_API_KEY) if settings.OPENAI_API_KEY else None
|
||||
self.model = settings.OPENAI_MODEL
|
||||
self.mini_model = settings.OPENAI_MINI_MODEL
|
||||
|
||||
async def is_available(self) -> bool:
|
||||
return bool(settings.OPENAI_API_KEY and self.client)
|
||||
|
||||
async def complete(self, system_prompt: str, user_message: str,
|
||||
temperature: float = None, max_tokens: int = None,
|
||||
json_mode: bool = False, mini: bool = False) -> LLMResponse:
|
||||
if not self.client:
|
||||
raise RuntimeError("OpenAI API key not configured")
|
||||
|
||||
model = self.mini_model if mini else self.model
|
||||
start = time.time()
|
||||
|
||||
kwargs = {
|
||||
"model": model,
|
||||
"messages": [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": user_message},
|
||||
],
|
||||
"temperature": temperature or settings.LLM_TEMPERATURE,
|
||||
"max_tokens": max_tokens or settings.LLM_MAX_TOKENS,
|
||||
}
|
||||
|
||||
if json_mode:
|
||||
kwargs["response_format"] = {"type": "json_object"}
|
||||
|
||||
response = await self.client.chat.completions.create(**kwargs)
|
||||
latency = int((time.time() - start) * 1000)
|
||||
|
||||
return LLMResponse(
|
||||
content=response.choices[0].message.content or "",
|
||||
tokens_used=response.usage.total_tokens if response.usage else 0,
|
||||
latency_ms=latency,
|
||||
provider="openai",
|
||||
model=model,
|
||||
raw=response.model_dump() if hasattr(response, "model_dump") else {},
|
||||
)
|
||||
|
||||
|
||||
class LLMRouter:
|
||||
"""
|
||||
Intelligent LLM routing with automatic failover.
|
||||
Primary: Groq (fast, free/cheap)
|
||||
Fallback: OpenAI (reliable, high quality)
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.groq = GroqProvider()
|
||||
self.openai = OpenAIProvider()
|
||||
self._primary = settings.LLM_PRIMARY_PROVIDER
|
||||
|
||||
async def complete(self, system_prompt: str, user_message: str,
|
||||
temperature: float = None, max_tokens: int = None,
|
||||
json_mode: bool = False, provider: str = None,
|
||||
fast: bool = False) -> LLMResponse:
|
||||
"""
|
||||
Send a completion request to the best available provider.
|
||||
|
||||
Args:
|
||||
system_prompt: System instructions
|
||||
user_message: User input
|
||||
temperature: Override default temperature
|
||||
max_tokens: Override default max tokens
|
||||
json_mode: Request JSON output
|
||||
provider: Force specific provider ("groq" or "openai")
|
||||
fast: Use faster/smaller model variant
|
||||
"""
|
||||
# Determine provider order
|
||||
if provider == "openai":
|
||||
providers = [("openai", self.openai)]
|
||||
elif provider == "groq":
|
||||
providers = [("groq", self.groq)]
|
||||
elif self._primary == "groq":
|
||||
providers = [("groq", self.groq), ("openai", self.openai)]
|
||||
else:
|
||||
providers = [("openai", self.openai), ("groq", self.groq)]
|
||||
|
||||
last_error = None
|
||||
for name, prov in providers:
|
||||
if not await prov.is_available():
|
||||
logger.warning(f"LLM provider {name} not available, trying next...")
|
||||
continue
|
||||
try:
|
||||
kwargs = {
|
||||
"system_prompt": system_prompt,
|
||||
"user_message": user_message,
|
||||
"temperature": temperature,
|
||||
"max_tokens": max_tokens,
|
||||
"json_mode": json_mode,
|
||||
}
|
||||
if name == "groq":
|
||||
kwargs["fast"] = fast
|
||||
elif name == "openai":
|
||||
kwargs["mini"] = fast
|
||||
|
||||
result = await prov.complete(**kwargs)
|
||||
logger.info(
|
||||
f"LLM call: provider={name} model={result.model} "
|
||||
f"tokens={result.tokens_used} latency={result.latency_ms}ms"
|
||||
)
|
||||
return result
|
||||
except Exception as e:
|
||||
last_error = e
|
||||
logger.warning(f"LLM provider {name} failed: {e}, trying next...")
|
||||
continue
|
||||
|
||||
raise RuntimeError(f"All LLM providers failed. Last error: {last_error}")
|
||||
|
||||
async def complete_json(self, system_prompt: str, user_message: str,
|
||||
**kwargs) -> dict:
|
||||
"""Shortcut: complete and parse as JSON."""
|
||||
response = await self.complete(system_prompt, user_message,
|
||||
json_mode=True, **kwargs)
|
||||
parsed = response.parse_json()
|
||||
if parsed is None:
|
||||
raise ValueError(f"Failed to parse LLM response as JSON: {response.content[:200]}")
|
||||
return parsed
|
||||
|
||||
|
||||
# Singleton
|
||||
_router: Optional[LLMRouter] = None
|
||||
|
||||
def get_llm() -> LLMRouter:
|
||||
global _router
|
||||
if _router is None:
|
||||
_router = LLMRouter()
|
||||
return _router
|
||||
247
salesflow-saas/backend/app/services/meeting_service.py
Normal file
247
salesflow-saas/backend/app/services/meeting_service.py
Normal file
@ -0,0 +1,247 @@
|
||||
"""
|
||||
Meeting Service — AI-driven scheduling, calendar sync, preparation packages.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import select, func, and_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
|
||||
class MeetingService:
|
||||
"""Manages meeting lifecycle: schedule, confirm, prepare, remind."""
|
||||
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
|
||||
async def create_meeting(
|
||||
self,
|
||||
tenant_id: str,
|
||||
lead_id: str,
|
||||
agent_id: str,
|
||||
proposed_time: str,
|
||||
channel: str = "whatsapp",
|
||||
notes: str = "",
|
||||
) -> dict:
|
||||
from app.models.ai_conversation import AutoBooking
|
||||
|
||||
booking = AutoBooking(
|
||||
id=uuid.uuid4(),
|
||||
tenant_id=uuid.UUID(tenant_id),
|
||||
lead_id=uuid.UUID(lead_id),
|
||||
agent_id=uuid.UUID(agent_id),
|
||||
proposed_time=datetime.fromisoformat(proposed_time),
|
||||
status="proposed",
|
||||
channel=channel,
|
||||
)
|
||||
self.db.add(booking)
|
||||
await self.db.flush()
|
||||
return self._to_dict(booking)
|
||||
|
||||
async def confirm_meeting(
|
||||
self, tenant_id: str, meeting_id: str, confirmed_time: str = None
|
||||
) -> Optional[dict]:
|
||||
from app.models.ai_conversation import AutoBooking
|
||||
|
||||
result = await self.db.execute(
|
||||
select(AutoBooking).where(
|
||||
AutoBooking.id == uuid.UUID(meeting_id),
|
||||
AutoBooking.tenant_id == uuid.UUID(tenant_id),
|
||||
)
|
||||
)
|
||||
booking = result.scalar_one_or_none()
|
||||
if not booking:
|
||||
return None
|
||||
|
||||
booking.status = "confirmed"
|
||||
if confirmed_time:
|
||||
booking.confirmed_time = datetime.fromisoformat(confirmed_time)
|
||||
else:
|
||||
booking.confirmed_time = booking.proposed_time
|
||||
booking.updated_at = datetime.now(timezone.utc)
|
||||
await self.db.flush()
|
||||
return self._to_dict(booking)
|
||||
|
||||
async def cancel_meeting(
|
||||
self, tenant_id: str, meeting_id: str, reason: str = ""
|
||||
) -> Optional[dict]:
|
||||
from app.models.ai_conversation import AutoBooking
|
||||
|
||||
result = await self.db.execute(
|
||||
select(AutoBooking).where(
|
||||
AutoBooking.id == uuid.UUID(meeting_id),
|
||||
AutoBooking.tenant_id == uuid.UUID(tenant_id),
|
||||
)
|
||||
)
|
||||
booking = result.scalar_one_or_none()
|
||||
if not booking:
|
||||
return None
|
||||
|
||||
booking.status = "cancelled"
|
||||
booking.updated_at = datetime.now(timezone.utc)
|
||||
await self.db.flush()
|
||||
return self._to_dict(booking)
|
||||
|
||||
async def reschedule_meeting(
|
||||
self, tenant_id: str, meeting_id: str, new_time: str
|
||||
) -> Optional[dict]:
|
||||
from app.models.ai_conversation import AutoBooking
|
||||
|
||||
result = await self.db.execute(
|
||||
select(AutoBooking).where(
|
||||
AutoBooking.id == uuid.UUID(meeting_id),
|
||||
AutoBooking.tenant_id == uuid.UUID(tenant_id),
|
||||
)
|
||||
)
|
||||
booking = result.scalar_one_or_none()
|
||||
if not booking:
|
||||
return None
|
||||
|
||||
booking.proposed_time = datetime.fromisoformat(new_time)
|
||||
booking.confirmed_time = None
|
||||
booking.status = "rescheduled"
|
||||
booking.updated_at = datetime.now(timezone.utc)
|
||||
await self.db.flush()
|
||||
return self._to_dict(booking)
|
||||
|
||||
async def list_meetings(
|
||||
self,
|
||||
tenant_id: str,
|
||||
agent_id: str = None,
|
||||
status: str = None,
|
||||
from_date: str = None,
|
||||
to_date: str = None,
|
||||
page: int = 1,
|
||||
per_page: int = 25,
|
||||
) -> dict:
|
||||
from app.models.ai_conversation import AutoBooking
|
||||
|
||||
query = select(AutoBooking).where(
|
||||
AutoBooking.tenant_id == uuid.UUID(tenant_id)
|
||||
)
|
||||
|
||||
if agent_id:
|
||||
query = query.where(AutoBooking.agent_id == uuid.UUID(agent_id))
|
||||
if status:
|
||||
query = query.where(AutoBooking.status == status)
|
||||
if from_date:
|
||||
query = query.where(AutoBooking.proposed_time >= datetime.fromisoformat(from_date))
|
||||
if to_date:
|
||||
query = query.where(AutoBooking.proposed_time <= datetime.fromisoformat(to_date))
|
||||
|
||||
count_q = select(func.count()).select_from(query.subquery())
|
||||
total = (await self.db.execute(count_q)).scalar() or 0
|
||||
|
||||
query = query.order_by(AutoBooking.proposed_time.asc())
|
||||
query = query.offset((page - 1) * per_page).limit(per_page)
|
||||
result = await self.db.execute(query)
|
||||
meetings = [self._to_dict(m) for m in result.scalars().all()]
|
||||
|
||||
return {"items": meetings, "total": total, "page": page, "per_page": per_page}
|
||||
|
||||
async def get_availability(
|
||||
self,
|
||||
tenant_id: str,
|
||||
agent_id: str,
|
||||
date: str,
|
||||
slot_duration_minutes: int = 30,
|
||||
) -> list:
|
||||
"""Get available time slots for an agent on a given date."""
|
||||
from app.models.ai_conversation import AutoBooking
|
||||
|
||||
target_date = datetime.fromisoformat(date).date()
|
||||
start = datetime.combine(target_date, datetime.min.time().replace(hour=8))
|
||||
end = datetime.combine(target_date, datetime.min.time().replace(hour=18))
|
||||
|
||||
# Get booked slots
|
||||
booked_q = select(AutoBooking.proposed_time, AutoBooking.confirmed_time).where(
|
||||
AutoBooking.tenant_id == uuid.UUID(tenant_id),
|
||||
AutoBooking.agent_id == uuid.UUID(agent_id),
|
||||
AutoBooking.status.in_(["proposed", "confirmed"]),
|
||||
AutoBooking.proposed_time >= start,
|
||||
AutoBooking.proposed_time < end,
|
||||
)
|
||||
booked = (await self.db.execute(booked_q)).all()
|
||||
booked_times = set()
|
||||
for b in booked:
|
||||
t = b.confirmed_time or b.proposed_time
|
||||
booked_times.add(t.replace(minute=(t.minute // slot_duration_minutes) * slot_duration_minutes, second=0))
|
||||
|
||||
# Generate slots
|
||||
slots = []
|
||||
current = start.replace(tzinfo=timezone.utc)
|
||||
end = end.replace(tzinfo=timezone.utc)
|
||||
while current < end:
|
||||
if current not in booked_times:
|
||||
slots.append({
|
||||
"time": current.isoformat(),
|
||||
"available": True,
|
||||
})
|
||||
current += timedelta(minutes=slot_duration_minutes)
|
||||
|
||||
return slots
|
||||
|
||||
async def prepare_meeting_package(
|
||||
self, tenant_id: str, meeting_id: str
|
||||
) -> dict:
|
||||
"""Generate a meeting preparation package (AI-powered)."""
|
||||
from app.models.ai_conversation import AutoBooking
|
||||
|
||||
result = await self.db.execute(
|
||||
select(AutoBooking).where(
|
||||
AutoBooking.id == uuid.UUID(meeting_id),
|
||||
AutoBooking.tenant_id == uuid.UUID(tenant_id),
|
||||
)
|
||||
)
|
||||
booking = result.scalar_one_or_none()
|
||||
if not booking:
|
||||
return {}
|
||||
|
||||
# Get lead info for context
|
||||
from app.services.lead_service import LeadService
|
||||
lead_svc = LeadService(self.db)
|
||||
lead = await lead_svc.get_lead(tenant_id, str(booking.lead_id))
|
||||
|
||||
return {
|
||||
"meeting_id": str(booking.id),
|
||||
"lead": lead,
|
||||
"prep_items": {
|
||||
"company_brief": f"Prepare brief for {lead.get('company_name', 'Unknown')}",
|
||||
"sector": lead.get("sector", ""),
|
||||
"talking_points": [], # AI will fill this
|
||||
"predicted_objections": [], # AI will fill this
|
||||
"recommended_presentation": None, # Will match to sector
|
||||
},
|
||||
"status": "pending_ai_enrichment",
|
||||
}
|
||||
|
||||
async def get_today_schedule(self, tenant_id: str, agent_id: str) -> list:
|
||||
today = datetime.now(timezone.utc).date()
|
||||
tomorrow = today + timedelta(days=1)
|
||||
data = await self.list_meetings(
|
||||
tenant_id,
|
||||
agent_id=agent_id,
|
||||
from_date=datetime.combine(today, datetime.min.time()).isoformat(),
|
||||
to_date=datetime.combine(tomorrow, datetime.min.time()).isoformat(),
|
||||
per_page=50,
|
||||
)
|
||||
return data["items"]
|
||||
|
||||
@staticmethod
|
||||
def _to_dict(booking) -> dict:
|
||||
if not booking:
|
||||
return {}
|
||||
return {
|
||||
"id": str(booking.id),
|
||||
"tenant_id": str(booking.tenant_id),
|
||||
"lead_id": str(booking.lead_id),
|
||||
"agent_id": str(booking.agent_id),
|
||||
"proposed_time": booking.proposed_time.isoformat() if booking.proposed_time else None,
|
||||
"confirmed_time": booking.confirmed_time.isoformat() if booking.confirmed_time else None,
|
||||
"status": booking.status,
|
||||
"channel": booking.channel,
|
||||
"calendar_event_id": booking.calendar_event_id,
|
||||
"created_at": booking.created_at.isoformat() if booking.created_at else None,
|
||||
}
|
||||
226
salesflow-saas/backend/app/services/notification_service.py
Normal file
226
salesflow-saas/backend/app/services/notification_service.py
Normal file
@ -0,0 +1,226 @@
|
||||
"""
|
||||
Notification Service — Multi-channel delivery (in-app, WhatsApp, email, SMS).
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import select, func, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
|
||||
class NotificationService:
|
||||
"""Manages notifications across all channels."""
|
||||
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
|
||||
async def send(
|
||||
self,
|
||||
tenant_id: str,
|
||||
user_id: str,
|
||||
title: str,
|
||||
body: str,
|
||||
notification_type: str = "info",
|
||||
channel: str = "in_app",
|
||||
data: dict = None,
|
||||
) -> dict:
|
||||
from app.models.notification import Notification
|
||||
|
||||
notif = Notification(
|
||||
id=uuid.uuid4(),
|
||||
tenant_id=uuid.UUID(tenant_id),
|
||||
user_id=uuid.UUID(user_id),
|
||||
type=notification_type,
|
||||
title=title,
|
||||
body=body,
|
||||
channel=channel,
|
||||
is_read=False,
|
||||
)
|
||||
self.db.add(notif)
|
||||
await self.db.flush()
|
||||
|
||||
# Dispatch to external channels
|
||||
if channel == "whatsapp":
|
||||
await self._send_whatsapp(user_id, body)
|
||||
elif channel == "email":
|
||||
await self._send_email(user_id, title, body)
|
||||
elif channel == "sms":
|
||||
await self._send_sms(user_id, body)
|
||||
|
||||
return {
|
||||
"id": str(notif.id),
|
||||
"channel": channel,
|
||||
"status": "sent",
|
||||
}
|
||||
|
||||
async def send_bulk(
|
||||
self,
|
||||
tenant_id: str,
|
||||
user_ids: list,
|
||||
title: str,
|
||||
body: str,
|
||||
notification_type: str = "info",
|
||||
channel: str = "in_app",
|
||||
) -> dict:
|
||||
results = []
|
||||
for uid in user_ids:
|
||||
result = await self.send(tenant_id, uid, title, body, notification_type, channel)
|
||||
results.append(result)
|
||||
return {"sent": len(results), "results": results}
|
||||
|
||||
async def get_unread(self, tenant_id: str, user_id: str) -> list:
|
||||
from app.models.notification import Notification
|
||||
|
||||
result = await self.db.execute(
|
||||
select(Notification)
|
||||
.where(
|
||||
Notification.tenant_id == uuid.UUID(tenant_id),
|
||||
Notification.user_id == uuid.UUID(user_id),
|
||||
Notification.is_read == False,
|
||||
)
|
||||
.order_by(Notification.created_at.desc())
|
||||
.limit(50)
|
||||
)
|
||||
return [self._to_dict(n) for n in result.scalars().all()]
|
||||
|
||||
async def get_all(
|
||||
self, tenant_id: str, user_id: str, page: int = 1, per_page: int = 20
|
||||
) -> dict:
|
||||
from app.models.notification import Notification
|
||||
|
||||
query = select(Notification).where(
|
||||
Notification.tenant_id == uuid.UUID(tenant_id),
|
||||
Notification.user_id == uuid.UUID(user_id),
|
||||
).order_by(Notification.created_at.desc())
|
||||
|
||||
count_q = select(func.count()).select_from(query.subquery())
|
||||
total = (await self.db.execute(count_q)).scalar() or 0
|
||||
|
||||
query = query.offset((page - 1) * per_page).limit(per_page)
|
||||
result = await self.db.execute(query)
|
||||
|
||||
return {
|
||||
"items": [self._to_dict(n) for n in result.scalars().all()],
|
||||
"total": total,
|
||||
"unread_count": await self._count_unread(tenant_id, user_id),
|
||||
}
|
||||
|
||||
async def mark_read(self, tenant_id: str, notification_id: str) -> bool:
|
||||
from app.models.notification import Notification
|
||||
|
||||
result = await self.db.execute(
|
||||
select(Notification).where(
|
||||
Notification.id == uuid.UUID(notification_id),
|
||||
Notification.tenant_id == uuid.UUID(tenant_id),
|
||||
)
|
||||
)
|
||||
notif = result.scalar_one_or_none()
|
||||
if not notif:
|
||||
return False
|
||||
|
||||
notif.is_read = True
|
||||
notif.read_at = datetime.now(timezone.utc)
|
||||
await self.db.flush()
|
||||
return True
|
||||
|
||||
async def mark_all_read(self, tenant_id: str, user_id: str) -> int:
|
||||
from app.models.notification import Notification
|
||||
|
||||
result = await self.db.execute(
|
||||
update(Notification)
|
||||
.where(
|
||||
Notification.tenant_id == uuid.UUID(tenant_id),
|
||||
Notification.user_id == uuid.UUID(user_id),
|
||||
Notification.is_read == False,
|
||||
)
|
||||
.values(is_read=True, read_at=datetime.now(timezone.utc))
|
||||
)
|
||||
return result.rowcount
|
||||
|
||||
# ── Alert Templates ───────────────────────────
|
||||
|
||||
async def notify_new_lead(self, tenant_id: str, agent_id: str, lead_name: str):
|
||||
await self.send(
|
||||
tenant_id, agent_id,
|
||||
title="عميل محتمل جديد 🔔",
|
||||
body=f"تم تعيين عميل جديد لك: {lead_name}",
|
||||
notification_type="lead",
|
||||
channel="in_app",
|
||||
)
|
||||
|
||||
async def notify_meeting_booked(self, tenant_id: str, agent_id: str, lead_name: str, time: str):
|
||||
await self.send(
|
||||
tenant_id, agent_id,
|
||||
title="موعد جديد مؤكد 📅",
|
||||
body=f"تم حجز موعد مع {lead_name} في {time}",
|
||||
notification_type="meeting",
|
||||
channel="in_app",
|
||||
)
|
||||
|
||||
async def notify_deal_won(self, tenant_id: str, agent_id: str, deal_title: str, value: float):
|
||||
await self.send(
|
||||
tenant_id, agent_id,
|
||||
title="صفقة ناجحة! 🎉",
|
||||
body=f"تم إغلاق صفقة {deal_title} بقيمة {value:,.0f} ريال",
|
||||
notification_type="deal",
|
||||
channel="in_app",
|
||||
)
|
||||
|
||||
async def notify_commission_earned(self, tenant_id: str, affiliate_id: str, amount: float):
|
||||
await self.send(
|
||||
tenant_id, affiliate_id,
|
||||
title="عمولة جديدة 💰",
|
||||
body=f"تم إضافة عمولة {amount:,.0f} ريال إلى حسابك",
|
||||
notification_type="commission",
|
||||
channel="in_app",
|
||||
)
|
||||
|
||||
async def notify_escalation(self, tenant_id: str, manager_id: str, reason: str):
|
||||
await self.send(
|
||||
tenant_id, manager_id,
|
||||
title="تصعيد يتطلب انتباهك ⚠️",
|
||||
body=reason,
|
||||
notification_type="escalation",
|
||||
channel="in_app",
|
||||
)
|
||||
|
||||
# ── Channel Dispatchers ───────────────────────
|
||||
|
||||
async def _send_whatsapp(self, user_id: str, message: str):
|
||||
# Will be implemented with WhatsApp integration
|
||||
pass
|
||||
|
||||
async def _send_email(self, user_id: str, subject: str, body: str):
|
||||
# Will be implemented with email integration
|
||||
pass
|
||||
|
||||
async def _send_sms(self, user_id: str, message: str):
|
||||
# Will be implemented with SMS integration
|
||||
pass
|
||||
|
||||
async def _count_unread(self, tenant_id: str, user_id: str) -> int:
|
||||
from app.models.notification import Notification
|
||||
|
||||
q = select(func.count()).where(
|
||||
Notification.tenant_id == uuid.UUID(tenant_id),
|
||||
Notification.user_id == uuid.UUID(user_id),
|
||||
Notification.is_read == False,
|
||||
)
|
||||
return (await self.db.execute(q)).scalar() or 0
|
||||
|
||||
@staticmethod
|
||||
def _to_dict(notif) -> dict:
|
||||
if not notif:
|
||||
return {}
|
||||
return {
|
||||
"id": str(notif.id),
|
||||
"type": notif.type,
|
||||
"title": notif.title,
|
||||
"body": notif.body,
|
||||
"channel": notif.channel,
|
||||
"is_read": notif.is_read,
|
||||
"read_at": notif.read_at.isoformat() if notif.read_at else None,
|
||||
"created_at": notif.created_at.isoformat() if notif.created_at else None,
|
||||
}
|
||||
247
salesflow-saas/backend/app/services/trust_score_service.py
Normal file
247
salesflow-saas/backend/app/services/trust_score_service.py
Normal file
@ -0,0 +1,247 @@
|
||||
"""
|
||||
Trust Score Service — AI-powered scoring for leads and affiliates.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
|
||||
class TrustScoreService:
|
||||
"""Calculates trust scores for leads and affiliates to prioritize high-quality opportunities."""
|
||||
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
|
||||
# ── Lead Trust Score (0-100) ──────────────────
|
||||
|
||||
async def calculate_lead_score(self, tenant_id: str, lead_id: str) -> dict:
|
||||
from app.models.lead import Lead
|
||||
from app.models.message import Message
|
||||
|
||||
result = await self.db.execute(
|
||||
select(Lead).where(
|
||||
Lead.id == uuid.UUID(lead_id),
|
||||
Lead.tenant_id == uuid.UUID(tenant_id),
|
||||
)
|
||||
)
|
||||
lead = result.scalar_one_or_none()
|
||||
if not lead:
|
||||
return {}
|
||||
|
||||
score = 0
|
||||
breakdown = {}
|
||||
|
||||
# 1. Contact info completeness (+20 max)
|
||||
contact_score = 0
|
||||
if lead.phone and len(lead.phone) >= 9:
|
||||
contact_score += 10
|
||||
if lead.email and "@" in lead.email:
|
||||
contact_score += 10
|
||||
breakdown["contact_info"] = contact_score
|
||||
score += contact_score
|
||||
|
||||
# 2. Company info (+25 max)
|
||||
company_score = 0
|
||||
if lead.company_name:
|
||||
company_score += 10
|
||||
if lead.sector:
|
||||
company_score += 5
|
||||
if lead.city:
|
||||
company_score += 5
|
||||
# CR number verification would add +5 more
|
||||
breakdown["company_info"] = company_score
|
||||
score += company_score
|
||||
|
||||
# 3. Engagement level (+25 max)
|
||||
msg_count_q = select(func.count()).where(
|
||||
Message.lead_id == uuid.UUID(lead_id),
|
||||
Message.direction == "inbound",
|
||||
)
|
||||
msg_count = (await self.db.execute(msg_count_q)).scalar() or 0
|
||||
engagement_score = min(25, msg_count * 5)
|
||||
breakdown["engagement"] = engagement_score
|
||||
score += engagement_score
|
||||
|
||||
# 4. Response speed (+15 max)
|
||||
if msg_count > 0:
|
||||
# Has responded = good sign
|
||||
response_score = 15
|
||||
else:
|
||||
response_score = 0
|
||||
breakdown["responsiveness"] = response_score
|
||||
score += response_score
|
||||
|
||||
# 5. Source quality (+15 max)
|
||||
source_scores = {
|
||||
"referral": 15, "affiliate": 12, "web": 10,
|
||||
"whatsapp": 8, "import": 5, "cold": 3,
|
||||
}
|
||||
source_score = source_scores.get(lead.source, 5)
|
||||
breakdown["source_quality"] = source_score
|
||||
score += source_score
|
||||
|
||||
# Normalize to 0-100
|
||||
score = min(100, score)
|
||||
|
||||
# Classification
|
||||
if score >= 70:
|
||||
classification = "hot"
|
||||
classification_ar = "ساخن 🔥"
|
||||
elif score >= 40:
|
||||
classification = "warm"
|
||||
classification_ar = "دافئ ☀️"
|
||||
else:
|
||||
classification = "cold"
|
||||
classification_ar = "بارد ❄️"
|
||||
|
||||
# Update lead score
|
||||
lead.score = score
|
||||
await self.db.flush()
|
||||
|
||||
return {
|
||||
"lead_id": str(lead_id),
|
||||
"trust_score": score,
|
||||
"classification": classification,
|
||||
"classification_ar": classification_ar,
|
||||
"breakdown": breakdown,
|
||||
"recommendation": self._get_lead_recommendation(classification),
|
||||
}
|
||||
|
||||
# ── Affiliate Trust Score (0-100) ─────────────
|
||||
|
||||
async def calculate_affiliate_score(self, tenant_id: str, affiliate_id: str) -> dict:
|
||||
from app.models.affiliate import Affiliate, AffiliatePerformance
|
||||
|
||||
result = await self.db.execute(
|
||||
select(Affiliate).where(
|
||||
Affiliate.id == uuid.UUID(affiliate_id),
|
||||
Affiliate.tenant_id == uuid.UUID(tenant_id),
|
||||
)
|
||||
)
|
||||
aff = result.scalar_one_or_none()
|
||||
if not aff:
|
||||
return {}
|
||||
|
||||
score = 0
|
||||
breakdown = {}
|
||||
|
||||
# 1. Lead Quality — conversion rate (40% weight)
|
||||
perf_q = select(
|
||||
func.coalesce(func.sum(AffiliatePerformance.leads_generated), 0),
|
||||
func.coalesce(func.sum(AffiliatePerformance.deals_closed), 0),
|
||||
).where(AffiliatePerformance.affiliate_id == uuid.UUID(affiliate_id))
|
||||
|
||||
perf = (await self.db.execute(perf_q)).first()
|
||||
total_leads = int(perf[0]) if perf else 0
|
||||
total_deals = int(perf[1]) if perf else 0
|
||||
|
||||
if total_leads > 0:
|
||||
conv_rate = total_deals / total_leads
|
||||
quality_score = min(40, int(conv_rate * 200))
|
||||
else:
|
||||
quality_score = 0
|
||||
breakdown["lead_quality"] = quality_score
|
||||
score += quality_score
|
||||
|
||||
# 2. Activity Consistency (20% weight)
|
||||
recent_q = select(func.count()).where(
|
||||
AffiliatePerformance.affiliate_id == uuid.UUID(affiliate_id),
|
||||
AffiliatePerformance.leads_generated > 0,
|
||||
)
|
||||
active_months = (await self.db.execute(recent_q)).scalar() or 0
|
||||
consistency_score = min(20, active_months * 4)
|
||||
breakdown["consistency"] = consistency_score
|
||||
score += consistency_score
|
||||
|
||||
# 3. Volume (20% weight)
|
||||
volume_score = min(20, total_deals * 2)
|
||||
breakdown["volume"] = volume_score
|
||||
score += volume_score
|
||||
|
||||
# 4. Tier bonus (10% weight)
|
||||
tier_scores = {"bronze": 2, "silver": 5, "gold": 8, "platinum": 10}
|
||||
tier_score = tier_scores.get(aff.tier, 0)
|
||||
breakdown["tier_bonus"] = tier_score
|
||||
score += tier_score
|
||||
|
||||
# 5. Longevity (10% weight)
|
||||
months_active = 0
|
||||
if aff.approved_at:
|
||||
delta = datetime.now(timezone.utc) - aff.approved_at.replace(tzinfo=timezone.utc)
|
||||
months_active = delta.days // 30
|
||||
longevity = min(10, months_active)
|
||||
breakdown["longevity"] = longevity
|
||||
score += longevity
|
||||
|
||||
score = min(100, score)
|
||||
|
||||
if score >= 75:
|
||||
tier_label = "Elite ⭐"
|
||||
elif score >= 50:
|
||||
tier_label = "Trusted ✅"
|
||||
elif score >= 25:
|
||||
tier_label = "Growing 📈"
|
||||
else:
|
||||
tier_label = "New 🆕"
|
||||
|
||||
return {
|
||||
"affiliate_id": str(affiliate_id),
|
||||
"trust_score": score,
|
||||
"label": tier_label,
|
||||
"breakdown": breakdown,
|
||||
"stats": {
|
||||
"total_leads": total_leads,
|
||||
"total_deals": total_deals,
|
||||
"months_active": months_active,
|
||||
"conversion_rate": round(total_deals / total_leads * 100, 1) if total_leads > 0 else 0,
|
||||
},
|
||||
}
|
||||
|
||||
# ── Batch Scoring ─────────────────────────────
|
||||
|
||||
async def score_all_leads(self, tenant_id: str) -> dict:
|
||||
from app.models.lead import Lead
|
||||
|
||||
result = await self.db.execute(
|
||||
select(Lead.id).where(
|
||||
Lead.tenant_id == uuid.UUID(tenant_id),
|
||||
Lead.status.in_(["new", "contacted"]),
|
||||
)
|
||||
)
|
||||
lead_ids = [str(lid) for lid in result.scalars().all()]
|
||||
|
||||
scored = 0
|
||||
for lid in lead_ids:
|
||||
await self.calculate_lead_score(tenant_id, lid)
|
||||
scored += 1
|
||||
|
||||
return {"scored": scored, "total": len(lead_ids)}
|
||||
|
||||
# ── Helpers ───────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def _get_lead_recommendation(classification: str) -> dict:
|
||||
recommendations = {
|
||||
"hot": {
|
||||
"action": "book_meeting",
|
||||
"action_ar": "احجز موعد فوراً",
|
||||
"priority": "critical",
|
||||
"message": "This lead shows strong buying signals. Book a meeting immediately.",
|
||||
},
|
||||
"warm": {
|
||||
"action": "nurture",
|
||||
"action_ar": "تابع التواصل",
|
||||
"priority": "high",
|
||||
"message": "Engage with targeted content and schedule a follow-up.",
|
||||
},
|
||||
"cold": {
|
||||
"action": "drip_campaign",
|
||||
"action_ar": "أضف لحملة المتابعة",
|
||||
"priority": "low",
|
||||
"message": "Add to drip campaign. Re-evaluate in 2 weeks.",
|
||||
},
|
||||
}
|
||||
return recommendations.get(classification, recommendations["cold"])
|
||||
80
salesflow-saas/backend/app/workers/agent_tasks.py
Normal file
80
salesflow-saas/backend/app/workers/agent_tasks.py
Normal file
@ -0,0 +1,80 @@
|
||||
"""
|
||||
AI Agent Async Tasks — Celery
|
||||
Executes agents asynchronously in the background.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from celery import shared_task
|
||||
from celery.utils.log import get_task_logger
|
||||
|
||||
logger = get_task_logger(__name__)
|
||||
|
||||
def execute_agent_sync(agent_type: str, input_data: dict, tenant_id: str = None,
|
||||
lead_id: str = None, conversation_id: str = None):
|
||||
"""Synchronous wrapper for async true agent executor."""
|
||||
from app.database import async_session
|
||||
from app.services.agents.executor import AgentExecutor
|
||||
import json
|
||||
|
||||
async def run():
|
||||
async with async_session() as db:
|
||||
executor = AgentExecutor(db)
|
||||
result = await executor.execute(
|
||||
agent_type=agent_type,
|
||||
input_data=input_data,
|
||||
tenant_id=tenant_id,
|
||||
lead_id=lead_id,
|
||||
conversation_id=conversation_id
|
||||
)
|
||||
# Ensure DB updates are committed
|
||||
await db.commit()
|
||||
return result.to_dict()
|
||||
|
||||
return asyncio.run(run())
|
||||
|
||||
|
||||
def execute_event_sync(event_type: str, input_data: dict, tenant_id: str = None,
|
||||
lead_id: str = None, conversation_id: str = None):
|
||||
"""Synchronous wrapper for async event executor."""
|
||||
from app.database import async_session
|
||||
from app.services.agents.executor import AgentExecutor
|
||||
|
||||
async def run():
|
||||
async with async_session() as db:
|
||||
executor = AgentExecutor(db)
|
||||
results = await executor.execute_event(
|
||||
event_type=event_type,
|
||||
input_data=input_data,
|
||||
tenant_id=tenant_id,
|
||||
lead_id=lead_id,
|
||||
conversation_id=conversation_id
|
||||
)
|
||||
await db.commit()
|
||||
return [r.to_dict() for r in results]
|
||||
|
||||
return asyncio.run(run())
|
||||
|
||||
|
||||
@shared_task(bind=True, max_retries=3, default_retry_delay=60)
|
||||
def run_ai_agent(self, agent_type: str, input_data: dict, tenant_id: str = None,
|
||||
lead_id: str = None, conversation_id: str = None):
|
||||
"""Run a specific AI agent in the background."""
|
||||
try:
|
||||
logger.info(f"Starting agent {agent_type} for tenant {tenant_id}")
|
||||
return execute_agent_sync(agent_type, input_data, tenant_id, lead_id, conversation_id)
|
||||
except Exception as exc:
|
||||
logger.error(f"Agent {agent_type} failed: {exc}")
|
||||
self.retry(exc=exc)
|
||||
|
||||
|
||||
@shared_task(bind=True, max_retries=3)
|
||||
def process_agent_event(self, event_type: str, input_data: dict, tenant_id: str = None,
|
||||
lead_id: str = None, conversation_id: str = None):
|
||||
"""Process an event by triggering the appropriate AI agent chain."""
|
||||
try:
|
||||
logger.info(f"Processing agent event {event_type} for tenant {tenant_id}")
|
||||
return execute_event_sync(event_type, input_data, tenant_id, lead_id, conversation_id)
|
||||
except Exception as exc:
|
||||
logger.error(f"Event {event_type} failed: {exc}")
|
||||
self.retry(exc=exc)
|
||||
@ -1,21 +1,47 @@
|
||||
# ── Core Framework ──────────────────────────────────────
|
||||
fastapi==0.115.6
|
||||
uvicorn[standard]==0.34.0
|
||||
python-multipart==0.0.19
|
||||
|
||||
# ── Database ────────────────────────────────────────────
|
||||
sqlalchemy[asyncio]==2.0.36
|
||||
asyncpg==0.30.0
|
||||
alembic==1.14.1
|
||||
pgvector==0.3.6
|
||||
|
||||
# ── Validation / Settings ──────────────────────────────
|
||||
pydantic==2.10.4
|
||||
pydantic-settings==2.7.1
|
||||
|
||||
# ── Authentication ──────────────────────────────────────
|
||||
python-jose[cryptography]==3.3.0
|
||||
passlib[bcrypt]==1.7.4
|
||||
python-multipart==0.0.19
|
||||
|
||||
# ── Task Queue ──────────────────────────────────────────
|
||||
celery[redis]==5.4.0
|
||||
redis==5.2.1
|
||||
|
||||
# ── HTTP Client ─────────────────────────────────────────
|
||||
httpx==0.28.1
|
||||
|
||||
# ── Templating & Emails ────────────────────────────────
|
||||
jinja2==3.1.5
|
||||
python-dateutil==2.9.0
|
||||
emails==0.6
|
||||
|
||||
# ── LLM Providers ──────────────────────────────────────
|
||||
openai==1.58.1
|
||||
groq==0.13.0
|
||||
|
||||
# ── Data Processing ────────────────────────────────────
|
||||
python-dateutil==2.9.0
|
||||
openpyxl==3.1.5
|
||||
aiofiles==24.1.0
|
||||
pillow==11.1.0
|
||||
openpyxl==3.1.5
|
||||
|
||||
# ── Utilities ──────────────────────────────────────────
|
||||
python-slugify==8.0.4
|
||||
phonenumbers==8.13.50
|
||||
|
||||
# ── Testing ────────────────────────────────────────────
|
||||
pytest==8.3.4
|
||||
pytest-asyncio==0.25.0
|
||||
|
||||
BIN
salesflow-saas/dealix-frontend.zip
Normal file
BIN
salesflow-saas/dealix-frontend.zip
Normal file
Binary file not shown.
@ -13,7 +13,11 @@
|
||||
"react": "19.0.0",
|
||||
"react-dom": "19.0.0",
|
||||
"lucide-react": "0.469.0",
|
||||
"clsx": "2.1.1"
|
||||
"clsx": "2.1.1",
|
||||
"tailwind-merge": "^2.5.5",
|
||||
"framer-motion": "^11.15.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"recharts": "^2.15.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "22.10.5",
|
||||
|
||||
@ -1,28 +1,117 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Sans+Arabic:wght@300;400;500;600;700&family=Inter:wght@300;400;500;600;700;800&family=Tajawal:wght@700;800;900&display=swap');
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 220 20% 97%;
|
||||
--foreground: 220 30% 15%;
|
||||
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 220 30% 15%;
|
||||
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 220 30% 15%;
|
||||
|
||||
--primary: 240 70% 50%;
|
||||
--primary-foreground: 0 0% 100%;
|
||||
|
||||
--secondary: 220 15% 90%;
|
||||
--secondary-foreground: 220 30% 15%;
|
||||
|
||||
--muted: 220 15% 90%;
|
||||
--muted-foreground: 220 10% 45%;
|
||||
|
||||
--accent: 260 80% 60%;
|
||||
--accent-foreground: 0 0% 100%;
|
||||
|
||||
--destructive: 0 85% 60%;
|
||||
--destructive-foreground: 0 0% 100%;
|
||||
|
||||
--success: 140 70% 40%;
|
||||
--success-foreground: 0 0% 100%;
|
||||
|
||||
--border: 220 15% 85%;
|
||||
--input: 220 15% 85%;
|
||||
--ring: 240 70% 50%;
|
||||
|
||||
--radius: 0.75rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 224 30% 8%; /* Extremely sleek dark blue/black */
|
||||
--foreground: 210 20% 90%;
|
||||
|
||||
--card: 224 30% 12%;
|
||||
--card-foreground: 210 20% 90%;
|
||||
|
||||
--popover: 224 30% 12%;
|
||||
--popover-foreground: 210 20% 90%;
|
||||
|
||||
--primary: 240 80% 65%; /* Vibrant glowing blue */
|
||||
--primary-foreground: 224 30% 8%;
|
||||
|
||||
--secondary: 224 30% 15%;
|
||||
--secondary-foreground: 210 20% 90%;
|
||||
|
||||
--muted: 224 30% 15%;
|
||||
--muted-foreground: 215 15% 65%;
|
||||
|
||||
--accent: 270 80% 65%; /* Vibrant purple accent */
|
||||
--accent-foreground: 0 0% 100%;
|
||||
|
||||
--destructive: 0 70% 50%;
|
||||
--destructive-foreground: 0 0% 100%;
|
||||
|
||||
--success: 140 60% 50%;
|
||||
--success-foreground: 0 0% 100%;
|
||||
|
||||
--border: 224 30% 20%;
|
||||
--input: 224 30% 20%;
|
||||
--ring: 240 80% 65%;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'IBM Plex Sans Arabic', 'Inter', sans-serif;
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground bg-gradient-to-tr from-background to-background/80 min-h-screen bg-fixed antialiased selection:bg-primary/20 selection:text-primary;
|
||||
}
|
||||
}
|
||||
|
||||
/* Glassmorphism Utilities */
|
||||
@layer utilities {
|
||||
.text-gradient {
|
||||
@apply bg-clip-text text-transparent bg-gradient-to-r from-primary to-secondary;
|
||||
.glass {
|
||||
@apply bg-white/10 dark:bg-black/20 backdrop-blur-md border border-white/20 dark:border-white/10 shadow-lg;
|
||||
}
|
||||
.bg-hero-gradient {
|
||||
background: linear-gradient(135deg, #0F4C81 0%, #1A1A2E 50%, #0F4C81 100%);
|
||||
}
|
||||
.bg-grid {
|
||||
background-image:
|
||||
linear-gradient(rgba(255,255,255,0.03) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(255,255,255,0.03) 1px, transparent 1px);
|
||||
background-size: 60px 60px;
|
||||
.glass-card {
|
||||
@apply bg-card/80 backdrop-blur-xl border border-border shadow-xl rounded-2xl transition-all duration-300 hover:shadow-primary/5 hover:border-primary/20;
|
||||
}
|
||||
}
|
||||
|
||||
/* Animations & Scrollbar */
|
||||
@layer utilities {
|
||||
.animate-float {
|
||||
animation: float 6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0% { transform: translateY(0px); }
|
||||
50% { transform: translateY(-10px); }
|
||||
100% { transform: translateY(0px); }
|
||||
}
|
||||
|
||||
/* Custom Scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
@apply bg-muted-foreground/30 rounded-full hover:bg-muted-foreground/50 transition-colors;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,23 +1,32 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Noto_Kufi_Arabic } from "next/font/google";
|
||||
import "./globals.css";
|
||||
|
||||
const kufi = Noto_Kufi_Arabic({
|
||||
subsets: ["arabic", "latin"],
|
||||
weight: ["300", "400", "500", "600", "700"],
|
||||
variable: "--font-kufi",
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Dealix - ديل اي اكس | منصة مبيعات ذكية للشركات",
|
||||
description: "منصة ذكاء اصطناعي لأتمتة المبيعات. تدير عملاءك، تتابعهم تلقائياً، وتغلق الصفقات. مصممة للسوق السعودي.",
|
||||
keywords: "مبيعات, CRM, SaaS, ذكاء اصطناعي, أتمتة, عيادات, عقارات, الرياض, السعودية",
|
||||
title: "ديل اي اكس - Dealix OS",
|
||||
description: "The autonomous AI sales engine for the Saudi market.",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
}>) {
|
||||
return (
|
||||
<html lang="ar" dir="rtl">
|
||||
<head>
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
</head>
|
||||
<body className="antialiased">{children}</body>
|
||||
<html lang="ar" dir="rtl" className="dark">
|
||||
<body className={`${kufi.variable} font-sans antialiased`}>
|
||||
{/* Background Gradients for depth */}
|
||||
<div className="fixed inset-0 z-[-1] bg-[radial-gradient(ellipse_at_top_right,_var(--tw-gradient-stops))] from-primary/10 via-background to-background pointer-events-none" />
|
||||
<div className="fixed top-20 left-10 w-96 h-96 bg-accent/10 rounded-full mix-blend-multiply filter blur-[100px] opacity-50 z-[-1]" />
|
||||
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,505 +1,137 @@
|
||||
import {
|
||||
Users, MessageSquare, BarChart3, Target, Zap, Phone,
|
||||
CheckCircle2, ArrowLeft, Star, ChevronDown, Building2,
|
||||
Stethoscope, Home, Clock, Shield, Globe, Award, Briefcase,
|
||||
Bot, UserPlus
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
BarChart3,
|
||||
Users,
|
||||
Target,
|
||||
MessageSquare,
|
||||
Zap,
|
||||
Bell,
|
||||
Search,
|
||||
BrainCircuit,
|
||||
Settings,
|
||||
BookOpen,
|
||||
MonitorPlay,
|
||||
FileSignature,
|
||||
ShieldCheck,
|
||||
Phone
|
||||
} from "lucide-react";
|
||||
|
||||
const features = [
|
||||
{ icon: Users, title: "إدارة العملاء المحتملين", titleEn: "Lead Management", desc: "التقط العملاء من واتساب، الموقع، ووسائل التواصل تلقائياً" },
|
||||
{ icon: MessageSquare, title: "المتابعة التلقائية", titleEn: "Auto Follow-up", desc: "الذكاء الاصطناعي يرسل رسائل وتذكيرات بدون تدخلك" },
|
||||
{ icon: Target, title: "خط أنابيب المبيعات", titleEn: "Sales Pipeline", desc: "تابع صفقاتك بصرياً وحرّك كل صفقة بين المراحل بسهولة" },
|
||||
{ icon: Zap, title: "عروض أسعار ذكية", titleEn: "Smart Proposals", desc: "أنشئ وأرسل عروض أسعار احترافية في دقائق" },
|
||||
{ icon: BarChart3, title: "تقارير وتحليلات", titleEn: "Reports & Analytics", desc: "لوحات بيانات فورية تتابع إيراداتك وأداء فريقك" },
|
||||
{ icon: Phone, title: "واتساب بزنس", titleEn: "WhatsApp Business", desc: "أرسل واستقبل الرسائل مباشرة من المنصة" },
|
||||
];
|
||||
import { DashboardView } from "../components/dealix/dashboard-view";
|
||||
import { AffiliatesView } from "../components/dealix/affiliates-view";
|
||||
import { ChatbotView } from "../components/dealix/chatbot-view";
|
||||
import { PresentationsView } from "../components/dealix/presentations-view";
|
||||
import { ScriptsView } from "../components/dealix/scripts-view";
|
||||
import { AgreementsView } from "../components/dealix/agreements-view";
|
||||
import { GuaranteesView } from "../components/dealix/guarantees-view";
|
||||
import { OnboardingView } from "../components/dealix/onboarding-view";
|
||||
|
||||
const painPoints = [
|
||||
{ emoji: "😰", text: "تضيع عملاء لأن المتابعة متأخرة؟" },
|
||||
{ emoji: "😵", text: "فريقك يشتغل بدون نظام واضح؟" },
|
||||
{ emoji: "💸", text: "ما تعرف وين فلوسك رايحة؟" },
|
||||
];
|
||||
export default function AppLayout() {
|
||||
const [activeTab, setActiveTab] = useState("overview");
|
||||
|
||||
const steps = [
|
||||
{ num: "01", title: "سجّل شركتك", desc: "في دقيقتين فقط", icon: Building2 },
|
||||
{ num: "02", title: "اختر قالب قطاعك", desc: "عيادات، عقارات، أو غيرها", icon: Globe },
|
||||
{ num: "03", title: "المنصة تبدأ تبيع لك", desc: "المتابعة التلقائية تبدأ فوراً", icon: Zap },
|
||||
];
|
||||
const NAV_ITEMS = [
|
||||
{ id: "overview", label: "نظرة عامة", icon: BarChart3 },
|
||||
{ id: "affiliates", label: "المسوقين والموظفين", icon: Users },
|
||||
{ id: "agents", label: "الوكلاء الأذكياء (Agents)", icon: BrainCircuit },
|
||||
{ id: "presentations", label: "البرزنتيشنات القطاعية", icon: MonitorPlay },
|
||||
{ id: "scripts", label: "سكربتات المبيعات", icon: Phone },
|
||||
{ id: "agreements", label: "الاتفاقيات واHR", icon: FileSignature },
|
||||
{ id: "guarantee", label: "الضمان الذهبي", icon: ShieldCheck },
|
||||
{ id: "onboarding", label: "ديل المسوق وتأهيله", icon: BookOpen },
|
||||
];
|
||||
|
||||
const plans = [
|
||||
{
|
||||
name: "أساسي", nameEn: "Basic", price: "299", popular: false,
|
||||
features: ["2 مستخدمين", "100 عميل محتمل/شهر", "500 رسالة واتساب", "3 أتمتة", "تقارير أساسية", "دعم بالإيميل"],
|
||||
},
|
||||
{
|
||||
name: "احترافي", nameEn: "Professional", price: "699", popular: true,
|
||||
features: ["10 مستخدمين", "1,000 عميل محتمل/شهر", "5,000 رسالة واتساب", "20 أتمتة", "تقارير متقدمة", "دعم أولوية", "قوالب قطاعية"],
|
||||
},
|
||||
{
|
||||
name: "مؤسسات", nameEn: "Enterprise", price: "1,499", popular: false,
|
||||
features: ["مستخدمين بلا حدود", "عملاء بلا حدود", "رسائل بلا حدود", "أتمتة بلا حدود", "تقارير مخصصة", "دعم مخصص", "API كامل", "مدير حساب خاص"],
|
||||
},
|
||||
];
|
||||
const renderContent = () => {
|
||||
switch (activeTab) {
|
||||
case "overview": return <DashboardView />;
|
||||
case "affiliates": return <AffiliatesView />;
|
||||
case "agents": return <ChatbotView />;
|
||||
case "presentations": return <PresentationsView />;
|
||||
case "scripts": return <ScriptsView />;
|
||||
case "agreements": return <AgreementsView />;
|
||||
case "guarantee": return <GuaranteesView />;
|
||||
case "onboarding": return <OnboardingView />;
|
||||
default: return <DashboardView />;
|
||||
}
|
||||
};
|
||||
|
||||
const faqs = [
|
||||
{ q: "هل يدعم الواتساب؟", a: "نعم، نربط مع واتساب بزنس API مباشرة. ترسل وتستقبل الرسائل من داخل المنصة." },
|
||||
{ q: "هل بياناتي آمنة؟", a: "تشفير كامل لكل البيانات. سيرفرات آمنة مع نسخ احتياطية يومية." },
|
||||
{ q: "كم يوم التجربة المجانية؟", a: "14 يوم كاملة بكل المميزات بدون بطاقة ائتمان." },
|
||||
{ q: "هل يدعم العربي؟", a: "المنصة كاملة بالعربي والإنجليزي. مصممة للسوق السعودي." },
|
||||
{ q: "أقدر ألغي أي وقت؟", a: "نعم، بدون أي التزام أو رسوم إلغاء." },
|
||||
];
|
||||
|
||||
const stats = [
|
||||
{ value: "+500", label: "شركة تثق بنا" },
|
||||
{ value: "+10,000", label: "صفقة تم إغلاقها" },
|
||||
{ value: "+2M", label: "رسالة تم إرسالها" },
|
||||
{ value: "24/7", label: "أتمتة مستمرة" },
|
||||
];
|
||||
|
||||
export default function LandingPage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-white text-gray-900">
|
||||
{/* Navigation */}
|
||||
<nav className="fixed top-0 w-full z-50 bg-white/80 backdrop-blur-md border-b border-gray-100">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex justify-between items-center h-16">
|
||||
<div className="flex items-center gap-2">
|
||||
<img src="/logo.svg" alt="Dealix" className="h-9 w-9" />
|
||||
<span className="text-xl font-bold text-primary">Dealix</span>
|
||||
<span className="text-sm text-gray-400 hidden sm:block">ديل اي اكس</span>
|
||||
</div>
|
||||
<div className="hidden md:flex items-center gap-8 text-sm">
|
||||
<a href="#features" className="text-gray-600 hover:text-primary transition">المميزات</a>
|
||||
<a href="#how-it-works" className="text-gray-600 hover:text-primary transition">كيف يعمل</a>
|
||||
<a href="#industries" className="text-gray-600 hover:text-primary transition">القطاعات</a>
|
||||
<a href="#pricing" className="text-gray-600 hover:text-primary transition">الأسعار</a>
|
||||
<a href="#affiliate" className="text-gray-600 hover:text-primary transition">انضم لنا</a>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<a href="/ar/login" className="text-sm text-gray-600 hover:text-primary transition hidden sm:block">تسجيل دخول</a>
|
||||
<a href="/ar/register" className="bg-accent hover:bg-accent-600 text-white px-5 py-2 rounded-lg text-sm font-medium transition shadow-lg shadow-accent/25">
|
||||
ابدأ مجاناً
|
||||
</a>
|
||||
<div className="min-h-screen flex w-full">
|
||||
{/* ── Sidebar ────────────────────────────────────────────────── */}
|
||||
<aside className="w-72 hidden lg:flex flex-col border-l border-border bg-card/50 backdrop-blur-xl">
|
||||
<div className="h-20 flex items-center px-8 border-b border-border">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-tr from-primary to-accent flex items-center justify-center shadow-lg shadow-primary/20">
|
||||
<Zap className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<span className="text-xl font-bold tracking-tight bg-clip-text text-transparent bg-gradient-to-r from-white to-white/70">
|
||||
Dealix OS
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Hero Section */}
|
||||
<section className="bg-hero-gradient bg-grid pt-32 pb-20 px-4 text-white overflow-hidden relative">
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-transparent to-primary/20"></div>
|
||||
<div className="max-w-7xl mx-auto relative z-10">
|
||||
<div className="max-w-3xl mx-auto text-center">
|
||||
<div className="inline-flex items-center gap-2 bg-white/10 rounded-full px-4 py-1.5 text-sm mb-6 backdrop-blur-sm">
|
||||
<span className="w-2 h-2 bg-secondary rounded-full animate-pulse"></span>
|
||||
صنع في السعودية للسوق السعودي
|
||||
</div>
|
||||
<h1 className="text-4xl sm:text-5xl lg:text-6xl font-bold font-arabic leading-tight mb-6">
|
||||
حوّل مبيعاتك إلى
|
||||
<br />
|
||||
<span className="text-transparent bg-clip-text bg-gradient-to-l from-secondary to-emerald-300">
|
||||
ماكينة أرباح تعمل 24/7
|
||||
</span>
|
||||
</h1>
|
||||
<p className="text-lg sm:text-xl text-gray-300 mb-8 max-w-2xl mx-auto leading-relaxed">
|
||||
منصة ذكاء اصطناعي تدير عملاءك، تتابعهم تلقائياً، وتغلق الصفقات بدون تدخل. مصممة للشركات الصغيرة والمتوسطة.
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row items-center justify-center gap-4">
|
||||
<a href="/ar/register" className="bg-accent hover:bg-accent-600 text-white px-8 py-4 rounded-xl text-lg font-bold transition shadow-2xl shadow-accent/30 flex items-center gap-2">
|
||||
ابدأ مجاناً لمدة 14 يوم
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
</a>
|
||||
<a href="#features" className="text-white/80 hover:text-white transition flex items-center gap-2">
|
||||
اكتشف المميزات
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
</a>
|
||||
</div>
|
||||
<p className="text-sm text-gray-400 mt-4">بدون بطاقة ائتمان • إلغاء أي وقت</p>
|
||||
</div>
|
||||
<nav className="flex-1 p-4 space-y-1 overflow-y-auto">
|
||||
{NAV_ITEMS.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => setActiveTab(item.id)}
|
||||
className={`w-full flex items-center gap-3 px-4 py-3 rounded-xl transition-all duration-200 ${
|
||||
activeTab === item.id
|
||||
? "bg-primary/10 text-primary font-bold border border-primary/20 shadow-sm"
|
||||
: "text-muted-foreground hover:bg-secondary/50 hover:text-foreground font-medium"
|
||||
}`}
|
||||
>
|
||||
<item.icon className={`w-5 h-5 ${activeTab === item.id ? "text-primary" : "opacity-70"}`} />
|
||||
<span>{item.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* Dashboard Mockup */}
|
||||
<div className="mt-16 max-w-4xl mx-auto">
|
||||
<div className="bg-white/5 backdrop-blur-xl rounded-2xl border border-white/10 p-6 shadow-2xl">
|
||||
<div className="flex gap-2 mb-4">
|
||||
<div className="w-3 h-3 rounded-full bg-red-400"></div>
|
||||
<div className="w-3 h-3 rounded-full bg-yellow-400"></div>
|
||||
<div className="w-3 h-3 rounded-full bg-green-400"></div>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-4 mb-4">
|
||||
{[
|
||||
{ label: "عملاء جدد اليوم", value: "23", color: "text-secondary" },
|
||||
{ label: "صفقات مفتوحة", value: "47", color: "text-accent" },
|
||||
{ label: "إيرادات الشهر", value: "185K", color: "text-emerald-400" },
|
||||
{ label: "معدل التحويل", value: "34%", color: "text-purple-400" },
|
||||
].map((stat, i) => (
|
||||
<div key={i} className="bg-white/5 rounded-lg p-3 text-center">
|
||||
<div className={`text-2xl font-bold ${stat.color}`}>{stat.value}</div>
|
||||
<div className="text-xs text-gray-400 mt-1">{stat.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="grid grid-cols-5 gap-3">
|
||||
{["جديد", "تم التواصل", "موعد محجوز", "عرض سعر", "تم الإغلاق"].map((stage, i) => (
|
||||
<div key={i} className="bg-white/5 rounded-lg p-2">
|
||||
<div className="text-xs text-gray-400 mb-2 text-center">{stage}</div>
|
||||
{Array.from({ length: 3 - Math.floor(i * 0.5) }).map((_, j) => (
|
||||
<div key={j} className="bg-white/10 rounded h-8 mb-1.5"></div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 mt-auto border-t border-border/50 bg-secondary/10">
|
||||
<button className="w-full flex items-center gap-3 px-4 py-3 rounded-xl text-muted-foreground hover:bg-secondary/50 transition-all font-medium">
|
||||
<Settings className="w-5 h-5" />
|
||||
<span>الإعدادات المتقدمة</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
|
||||
{/* Pain Points */}
|
||||
<section className="py-16 bg-gray-50">
|
||||
<div className="max-w-5xl mx-auto px-4">
|
||||
<div className="grid md:grid-cols-3 gap-6">
|
||||
{painPoints.map((p, i) => (
|
||||
<div key={i} className="bg-white rounded-xl p-6 text-center shadow-sm border border-gray-100 hover:shadow-md transition">
|
||||
<div className="text-4xl mb-3">{p.emoji}</div>
|
||||
<p className="text-lg font-medium text-gray-800">{p.text}</p>
|
||||
<p className="text-sm text-secondary mt-2 font-medium">Dealix يحل هذي المشكلة</p>
|
||||
{/* ── Main Content ────────────────────────────────────────────── */}
|
||||
<main className="flex-1 flex flex-col h-screen overflow-y-auto bg-background/50">
|
||||
{/* Header */}
|
||||
<header className="h-20 flex items-center justify-between px-8 border-b border-border bg-card/50 backdrop-blur-md sticky top-0 z-10 transition-all">
|
||||
<div className="relative w-96">
|
||||
<Search className="w-5 h-5 absolute right-4 top-1/2 -translate-y-1/2 text-muted-foreground" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="البحث الشامل في Dealix (عميل، مسوق، صفقة)..."
|
||||
className="w-full bg-secondary/50 border border-border rounded-full py-2.5 pr-12 pl-4 text-sm focus:outline-none focus:ring-2 focus:ring-primary/50 transition-all font-sans"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-6">
|
||||
<button className="relative p-2 text-muted-foreground hover:text-foreground transition-colors">
|
||||
<Bell className="w-5 h-5" />
|
||||
<span className="absolute top-1.5 right-1.5 w-2.5 h-2.5 bg-primary border-2 border-background rounded-full animate-pulse" />
|
||||
</button>
|
||||
<div className="flex items-center gap-3 pl-4 border-l border-border">
|
||||
<div className="text-left hidden md:block">
|
||||
<p className="text-sm font-bold">سالم الدوسري</p>
|
||||
<p className="text-xs text-muted-foreground">المدير العام (Founder)</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Features */}
|
||||
<section id="features" className="py-20 px-4">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="text-center mb-14">
|
||||
<h2 className="text-3xl sm:text-4xl font-bold mb-4">كل اللي تحتاجه لزيادة مبيعاتك</h2>
|
||||
<p className="text-gray-500 text-lg max-w-2xl mx-auto">منصة متكاملة تجمع كل أدوات المبيعات في مكان واحد</p>
|
||||
</div>
|
||||
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{features.map((f, i) => (
|
||||
<div key={i} className="group bg-white border border-gray-100 rounded-2xl p-6 hover:shadow-xl hover:border-primary/20 transition-all duration-300">
|
||||
<div className="w-12 h-12 bg-primary/10 rounded-xl flex items-center justify-center mb-4 group-hover:bg-primary group-hover:text-white transition-all">
|
||||
<f.icon className="w-6 h-6 text-primary group-hover:text-white" />
|
||||
<div className="w-10 h-10 rounded-full bg-gradient-to-tr from-blue-500 to-primary p-[2px]">
|
||||
<div className="w-full h-full rounded-full bg-card flex items-center justify-center border-2 border-background">
|
||||
<span className="text-sm font-bold text-foreground">SD</span>
|
||||
</div>
|
||||
<h3 className="text-lg font-bold mb-1">{f.title}</h3>
|
||||
<p className="text-xs text-gray-400 mb-2">{f.titleEn}</p>
|
||||
<p className="text-gray-500 text-sm leading-relaxed">{f.desc}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</header>
|
||||
|
||||
{/* How It Works */}
|
||||
<section id="how-it-works" className="py-20 bg-gray-50 px-4">
|
||||
<div className="max-w-5xl mx-auto">
|
||||
<div className="text-center mb-14">
|
||||
<h2 className="text-3xl sm:text-4xl font-bold mb-4">ابدأ في 3 خطوات</h2>
|
||||
<p className="text-gray-500 text-lg">من التسجيل إلى أول صفقة في دقائق</p>
|
||||
</div>
|
||||
<div className="grid md:grid-cols-3 gap-8">
|
||||
{steps.map((s, i) => (
|
||||
<div key={i} className="text-center relative">
|
||||
<div className="w-20 h-20 bg-primary rounded-2xl flex items-center justify-center mx-auto mb-4 shadow-lg shadow-primary/25">
|
||||
<s.icon className="w-10 h-10 text-white" />
|
||||
</div>
|
||||
<div className="text-xs text-primary font-bold mb-2">الخطوة {s.num}</div>
|
||||
<h3 className="text-xl font-bold mb-2">{s.title}</h3>
|
||||
<p className="text-gray-500 text-sm">{s.desc}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* Dynamic View Injection */}
|
||||
<div className="flex-1 w-full max-w-[1600px] mx-auto">
|
||||
{renderContent()}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Industry Templates */}
|
||||
<section id="industries" className="py-20 px-4">
|
||||
<div className="max-w-5xl mx-auto">
|
||||
<div className="text-center mb-14">
|
||||
<h2 className="text-3xl sm:text-4xl font-bold mb-4">قوالب جاهزة لقطاعك</h2>
|
||||
<p className="text-gray-500 text-lg">اختر قالب قطاعك وابدأ فوراً</p>
|
||||
</div>
|
||||
<div className="grid md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<div className="bg-gradient-to-br from-primary to-primary-700 rounded-2xl p-6 text-white shadow-xl">
|
||||
<Stethoscope className="w-10 h-10 mb-4" />
|
||||
<h3 className="text-lg font-bold mb-1">العيادات والصحة</h3>
|
||||
<p className="text-sm text-white/70 mb-4">إدارة المرضى والمواعيد والمتابعة</p>
|
||||
<div className="text-xs bg-white/20 rounded-full px-3 py-1 inline-block">متاح الآن</div>
|
||||
</div>
|
||||
<div className="bg-gradient-to-br from-secondary to-secondary-700 rounded-2xl p-6 text-white shadow-xl">
|
||||
<Home className="w-10 h-10 mb-4" />
|
||||
<h3 className="text-lg font-bold mb-1">عقارات الرياض</h3>
|
||||
<p className="text-sm text-white/70 mb-4">عقارات، جولات، عروض، أحياء الرياض</p>
|
||||
<div className="text-xs bg-white/20 rounded-full px-3 py-1 inline-block">متاح الآن</div>
|
||||
</div>
|
||||
<div className="bg-gray-100 rounded-2xl p-6 text-gray-400">
|
||||
<Building2 className="w-10 h-10 mb-4" />
|
||||
<h3 className="text-lg font-bold mb-1 text-gray-500">المقاولات</h3>
|
||||
<p className="text-sm mb-4">إدارة المشاريع والعملاء</p>
|
||||
<div className="text-xs bg-gray-200 rounded-full px-3 py-1 inline-block">قريباً</div>
|
||||
</div>
|
||||
<div className="bg-gray-100 rounded-2xl p-6 text-gray-400">
|
||||
<Star className="w-10 h-10 mb-4" />
|
||||
<h3 className="text-lg font-bold mb-1 text-gray-500">الصالونات</h3>
|
||||
<p className="text-sm mb-4">حجوزات ومتابعة العملاء</p>
|
||||
<div className="text-xs bg-gray-200 rounded-full px-3 py-1 inline-block">قريباً</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Stats */}
|
||||
<section className="py-16 bg-dark text-white">
|
||||
<div className="max-w-5xl mx-auto px-4">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-8">
|
||||
{stats.map((s, i) => (
|
||||
<div key={i} className="text-center">
|
||||
<div className="text-3xl sm:text-4xl font-bold text-secondary mb-1">{s.value}</div>
|
||||
<div className="text-sm text-gray-400">{s.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Pricing */}
|
||||
<section id="pricing" className="py-20 px-4">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="text-center mb-14">
|
||||
<h2 className="text-3xl sm:text-4xl font-bold mb-4">خطط تناسب حجم شركتك</h2>
|
||||
<p className="text-gray-500 text-lg">ابدأ مجاناً 14 يوم • بدون بطاقة ائتمان</p>
|
||||
</div>
|
||||
<div className="grid md:grid-cols-3 gap-6">
|
||||
{plans.map((plan, i) => (
|
||||
<div key={i} className={`rounded-2xl p-6 border-2 transition-all ${
|
||||
plan.popular
|
||||
? "border-primary bg-primary/5 shadow-xl scale-105 relative"
|
||||
: "border-gray-100 bg-white hover:border-gray-200"
|
||||
}`}>
|
||||
{plan.popular && (
|
||||
<div className="absolute -top-3 left-1/2 -translate-x-1/2 bg-primary text-white text-xs px-4 py-1 rounded-full font-medium">
|
||||
الأكثر شعبية
|
||||
</div>
|
||||
)}
|
||||
<div className="text-center mb-6">
|
||||
<h3 className="text-lg font-bold">{plan.name}</h3>
|
||||
<p className="text-xs text-gray-400">{plan.nameEn}</p>
|
||||
<div className="mt-4">
|
||||
<span className="text-4xl font-bold">{plan.price}</span>
|
||||
<span className="text-gray-500 text-sm"> ر.س/شهر</span>
|
||||
</div>
|
||||
</div>
|
||||
<ul className="space-y-3 mb-6">
|
||||
{plan.features.map((f, j) => (
|
||||
<li key={j} className="flex items-center gap-2 text-sm">
|
||||
<CheckCircle2 className="w-4 h-4 text-secondary flex-shrink-0" />
|
||||
{f}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<a href="/ar/register" className={`block text-center py-3 rounded-xl font-medium transition ${
|
||||
plan.popular
|
||||
? "bg-primary text-white hover:bg-primary-600 shadow-lg"
|
||||
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
|
||||
}`}>
|
||||
ابدأ تجربة مجانية
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* FAQ */}
|
||||
<section className="py-20 bg-gray-50 px-4">
|
||||
<div className="max-w-3xl mx-auto">
|
||||
<div className="text-center mb-14">
|
||||
<h2 className="text-3xl font-bold mb-4">أسئلة شائعة</h2>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{faqs.map((faq, i) => (
|
||||
<details key={i} className="bg-white rounded-xl border border-gray-100 group">
|
||||
<summary className="flex items-center justify-between p-5 cursor-pointer font-medium hover:text-primary transition">
|
||||
{faq.q}
|
||||
<ChevronDown className="w-5 h-5 text-gray-400 group-open:rotate-180 transition-transform" />
|
||||
</summary>
|
||||
<div className="px-5 pb-5 text-gray-500 text-sm leading-relaxed">{faq.a}</div>
|
||||
</details>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Gold Guarantee */}
|
||||
<section className="py-20 px-4 bg-gradient-to-br from-yellow-50 to-amber-50">
|
||||
<div className="max-w-4xl mx-auto text-center">
|
||||
<div className="inline-flex items-center justify-center w-20 h-20 bg-gradient-to-br from-yellow-400 to-amber-500 rounded-full mb-6 shadow-xl shadow-amber-200">
|
||||
<Award className="w-10 h-10 text-white" />
|
||||
</div>
|
||||
<h2 className="text-3xl sm:text-4xl font-bold mb-4">الضمان الذهبي</h2>
|
||||
<p className="text-xl text-gray-600 mb-6">ما تستفيد؟ نرجع لك فلوسك كاملة</p>
|
||||
<div className="bg-white rounded-2xl p-8 shadow-lg border border-amber-100 max-w-2xl mx-auto">
|
||||
<p className="text-gray-700 text-lg leading-relaxed mb-6">
|
||||
نحن واثقين من جودة Dealix. إذا استخدمت المنصة لمدة 30 يوم وما شفت نتائج حقيقية في مبيعاتك، نرجع لك المبلغ كامل بدون أي سؤال.
|
||||
</p>
|
||||
<div className="grid sm:grid-cols-3 gap-4 text-sm">
|
||||
<div className="bg-amber-50 rounded-xl p-4">
|
||||
<div className="font-bold text-amber-700 mb-1">30 يوم</div>
|
||||
<div className="text-gray-500">فترة الضمان</div>
|
||||
</div>
|
||||
<div className="bg-amber-50 rounded-xl p-4">
|
||||
<div className="font-bold text-amber-700 mb-1">100%</div>
|
||||
<div className="text-gray-500">استرجاع كامل</div>
|
||||
</div>
|
||||
<div className="bg-amber-50 rounded-xl p-4">
|
||||
<div className="font-bold text-amber-700 mb-1">بدون تعقيد</div>
|
||||
<div className="text-gray-500">عملية سهلة وسريعة</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Affiliate / Join Our Team */}
|
||||
<section className="py-20 px-4 bg-gradient-to-br from-primary/5 to-secondary/5">
|
||||
<div className="max-w-5xl mx-auto">
|
||||
<div className="text-center mb-12">
|
||||
<div className="inline-flex items-center gap-2 bg-primary/10 rounded-full px-4 py-1.5 text-sm mb-4 text-primary font-medium">
|
||||
<Briefcase className="w-4 h-4" />
|
||||
فرصة عمل حقيقية
|
||||
</div>
|
||||
<h2 className="text-3xl sm:text-4xl font-bold mb-4">انضم لفريق Dealix كمستشار مبيعات</h2>
|
||||
<p className="text-gray-500 text-lg max-w-2xl mx-auto">اشتغل من أي مكان، في أي وقت يناسبك، واكسب عمولات شهرية متكررة. حقق التارقت وتوظف رسمياً!</p>
|
||||
</div>
|
||||
<div className="grid md:grid-cols-3 gap-6 mb-10">
|
||||
<div className="bg-white rounded-2xl p-6 shadow-sm border border-gray-100 text-center">
|
||||
<div className="w-14 h-14 bg-green-100 rounded-xl flex items-center justify-center mx-auto mb-4">
|
||||
<UserPlus className="w-7 h-7 text-green-600" />
|
||||
</div>
|
||||
<h3 className="text-lg font-bold mb-2">سجّل كمسوّق</h3>
|
||||
<p className="text-gray-500 text-sm">تعبئة بياناتك واستلام حزمة التدريب الكاملة والأدوات الاحترافية</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-2xl p-6 shadow-sm border border-gray-100 text-center">
|
||||
<div className="w-14 h-14 bg-blue-100 rounded-xl flex items-center justify-center mx-auto mb-4">
|
||||
<Target className="w-7 h-7 text-blue-600" />
|
||||
</div>
|
||||
<h3 className="text-lg font-bold mb-2">استهدف واكسب</h3>
|
||||
<p className="text-gray-500 text-sm">استخدم السكربتات والبرزنتيشنات الجاهزة لجذب العملاء واكسب عمولة على كل صفقة</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-2xl p-6 shadow-sm border border-gray-100 text-center">
|
||||
<div className="w-14 h-14 bg-purple-100 rounded-xl flex items-center justify-center mx-auto mb-4">
|
||||
<Award className="w-7 h-7 text-purple-600" />
|
||||
</div>
|
||||
<h3 className="text-lg font-bold mb-2">حقق التارقت وتوظف</h3>
|
||||
<p className="text-gray-500 text-sm">10 شركات بالشهر = توظيف رسمي براتب ثابت + عمولات أعلى + تأمين صحي</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<a href="/ar/affiliate" className="bg-primary hover:bg-primary-600 text-white px-8 py-4 rounded-xl text-lg font-bold transition shadow-2xl shadow-primary/25 inline-flex items-center gap-2">
|
||||
انضم الآن كمستشار مبيعات
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
</a>
|
||||
<p className="text-sm text-gray-400 mt-3">بدون رسوم انضمام • تدريب مجاني • أدوات احترافية مجانية</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* AI Agents Section */}
|
||||
<section className="py-20 px-4 bg-dark text-white">
|
||||
<div className="max-w-5xl mx-auto">
|
||||
<div className="text-center mb-12">
|
||||
<div className="inline-flex items-center gap-2 bg-white/10 rounded-full px-4 py-1.5 text-sm mb-4">
|
||||
<Bot className="w-4 h-4 text-secondary" />
|
||||
<span className="text-secondary font-medium">ذكاء اصطناعي متقدم</span>
|
||||
</div>
|
||||
<h2 className="text-3xl sm:text-4xl font-bold mb-4">وكلاء الذكاء الاصطناعي يشتغلون لك 24/7</h2>
|
||||
<p className="text-gray-400 text-lg max-w-2xl mx-auto">ما تحتاج توظف فريق مبيعات ضخم. وكلاء Dealix الأذكياء يلقون العملاء، يتواصلون معهم، ويحجزون لك الاجتماعات تلقائياً.</p>
|
||||
</div>
|
||||
<div className="grid md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<div className="bg-white/5 rounded-2xl p-6 border border-white/10">
|
||||
<div className="text-3xl mb-3">🔍</div>
|
||||
<h3 className="text-lg font-bold mb-2">البحث الذكي</h3>
|
||||
<p className="text-sm text-gray-400">يبحث عن عملاء محتملين من قوقل، لينكدن، والأدلة التجارية تلقائياً</p>
|
||||
</div>
|
||||
<div className="bg-white/5 rounded-2xl p-6 border border-white/10">
|
||||
<div className="text-3xl mb-3">💬</div>
|
||||
<h3 className="text-lg font-bold mb-2">واتساب ذكي</h3>
|
||||
<p className="text-sm text-gray-400">بوت واتساب متقدم يتواصل مع العملاء، يجاوب أسئلتهم، ويتابع معهم</p>
|
||||
</div>
|
||||
<div className="bg-white/5 rounded-2xl p-6 border border-white/10">
|
||||
<div className="text-3xl mb-3">📞</div>
|
||||
<h3 className="text-lg font-bold mb-2">مكالمات صوتية</h3>
|
||||
<p className="text-sm text-gray-400">مكالمات آلية ذكية بالعربي تعرّف العميل بالمنصة وتحجز له موعد</p>
|
||||
</div>
|
||||
<div className="bg-white/5 rounded-2xl p-6 border border-white/10">
|
||||
<div className="text-3xl mb-3">📅</div>
|
||||
<h3 className="text-lg font-bold mb-2">حجز تلقائي</h3>
|
||||
<p className="text-sm text-gray-400">يحجز اجتماعات مع فريق المبيعات تلقائياً للعملاء المؤهلين</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Final CTA */}
|
||||
<section className="py-20 bg-hero-gradient text-white px-4">
|
||||
<div className="max-w-3xl mx-auto text-center">
|
||||
<h2 className="text-3xl sm:text-4xl font-bold mb-4">جاهز تزيد مبيعاتك؟</h2>
|
||||
<p className="text-lg text-gray-300 mb-8">انضم لمئات الشركات اللي زادت مبيعاتها مع Dealix</p>
|
||||
<div className="flex flex-col sm:flex-row items-center justify-center gap-4">
|
||||
<a href="/ar/register" className="bg-accent hover:bg-accent-600 text-white px-8 py-4 rounded-xl text-lg font-bold transition shadow-2xl">
|
||||
ابدأ مجاناً الآن
|
||||
</a>
|
||||
<a href="https://wa.me/966XXXXXXXXXX" className="bg-green-500 hover:bg-green-600 text-white px-8 py-4 rounded-xl text-lg font-bold transition shadow-2xl flex items-center gap-2">
|
||||
<Phone className="w-5 h-5" />
|
||||
تواصل عبر واتساب
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="bg-dark text-gray-400 py-12 px-4">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="grid md:grid-cols-4 gap-8 mb-8">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<img src="/logo.svg" alt="Dealix" className="h-8 w-8" />
|
||||
<span className="text-white font-bold text-lg">Dealix</span>
|
||||
</div>
|
||||
<p className="text-sm mb-4 leading-relaxed">مبيعاتك تشتغل وأنت ترتاح</p>
|
||||
<p className="text-sm">Sales on Autopilot</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-white font-medium mb-4">المنصة</h4>
|
||||
<ul className="space-y-2 text-sm">
|
||||
<li><a href="#features" className="hover:text-white transition">المميزات</a></li>
|
||||
<li><a href="#pricing" className="hover:text-white transition">الأسعار</a></li>
|
||||
<li><a href="#industries" className="hover:text-white transition">القطاعات</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-white font-medium mb-4">الشركة</h4>
|
||||
<ul className="space-y-2 text-sm">
|
||||
<li><a href="#" className="hover:text-white transition">من نحن</a></li>
|
||||
<li><a href="#" className="hover:text-white transition">تواصل معنا</a></li>
|
||||
<li><a href="#" className="hover:text-white transition">سياسة الخصوصية</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-white font-medium mb-4">تواصل معنا</h4>
|
||||
<ul className="space-y-2 text-sm">
|
||||
<li className="flex items-center gap-2"><Phone className="w-4 h-4" /> واتساب بزنس</li>
|
||||
<li className="flex items-center gap-2"><Shield className="w-4 h-4" /> دعم فني</li>
|
||||
<li className="flex items-center gap-2"><Clock className="w-4 h-4" /> 24/7 متاح</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t border-gray-800 pt-8 flex flex-col sm:flex-row justify-between items-center gap-4">
|
||||
<p className="text-sm">© 2024 Dealix. جميع الحقوق محفوظة</p>
|
||||
<p className="text-sm flex items-center gap-1">صنع بـ ❤️ في السعودية 🇸🇦</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -0,0 +1,125 @@
|
||||
import { Users, Award, TrendingUp, AlertCircle, Building2, UserPlus, Filter, Download } from "lucide-react";
|
||||
|
||||
export function AffiliatesView() {
|
||||
const affiliates = [
|
||||
{ id: "A-101", name: "أحمد عبدالله", status: "نشط", sales: 12, rev: "450K ر.س", comm: "45K ر.س", level: "Senior", eligibleForHire: true },
|
||||
{ id: "A-102", name: "سارة خالد", status: "نشط", sales: 4, rev: "120K ر.س", comm: "9.6K ر.س", level: "Mid", eligibleForHire: false },
|
||||
{ id: "A-103", name: "محمد ياسر", status: "إنذار", sales: 0, rev: "0 ر.س", comm: "0 ر.س", level: "New", eligibleForHire: false },
|
||||
{ id: "A-104", name: "فهد عبدالرحمن", status: "نشط", sales: 8, rev: "240K ر.س", comm: "24K ر.س", level: "Mid", eligibleForHire: false },
|
||||
{ id: "A-105", name: "لينا العتيبي", status: "مرشح للتعيين", sales: 15, rev: "600K ر.س", comm: "60K ر.س", level: "Senior", eligibleForHire: true },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="p-8 max-w-7xl mx-auto space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||
<div className="flex justify-between items-end mb-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight mb-2">👥 إدارة الشركاء والمسوقين (Affiliates)</h1>
|
||||
<p className="text-muted-foreground">مراقبة أداء المسوقين بالعمولة ومراحلة التوظيف الآلية (Auto-Hire).</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button className="flex items-center gap-2 px-5 py-2.5 rounded-xl border border-border bg-card hover:bg-secondary/50 transition-colors text-sm font-medium">
|
||||
<Download className="w-4 h-4" />
|
||||
تصدير
|
||||
</button>
|
||||
<button className="flex items-center gap-2 px-5 py-2.5 rounded-xl bg-primary hover:bg-primary/90 text-primary-foreground font-medium shadow-lg shadow-primary/25 transition-all">
|
||||
<UserPlus className="w-5 h-5" />
|
||||
إضافة مسوق
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
||||
<div className="glass-card p-6 border border-border/50">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<div className="p-3 rounded-xl bg-blue-500/10 text-blue-500">
|
||||
<Users className="w-6 h-6" />
|
||||
</div>
|
||||
<span className="text-sm font-medium text-emerald-500 bg-emerald-500/10 px-2 py-1 rounded">+12%</span>
|
||||
</div>
|
||||
<h3 className="text-2xl font-bold mb-1">124 مسوق</h3>
|
||||
<p className="text-sm text-muted-foreground font-medium">المسوقين النشطين</p>
|
||||
</div>
|
||||
|
||||
<div className="glass-card p-6 border border-border/50">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<div className="p-3 rounded-xl bg-emerald-500/10 text-emerald-500">
|
||||
<TrendingUp className="w-6 h-6" />
|
||||
</div>
|
||||
<span className="text-sm font-medium text-emerald-500 bg-emerald-500/10 px-2 py-1 rounded">+24%</span>
|
||||
</div>
|
||||
<h3 className="text-2xl font-bold mb-1">2.4M ر.س</h3>
|
||||
<p className="text-sm text-muted-foreground font-medium">إيرادات فريق التسويق (الشهر)</p>
|
||||
</div>
|
||||
|
||||
<div className="glass-card p-6 border border-primary/30 bg-primary/5">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<div className="p-3 rounded-xl bg-primary text-primary-foreground shadow-lg">
|
||||
<Building2 className="w-6 h-6" />
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="text-2xl font-bold mb-1 text-primary">3 مسوقين</h3>
|
||||
<p className="text-sm text-muted-foreground font-medium">استوفوا شروط التوظيف الفوري (10+ شركات)</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="glass-card overflow-hidden border border-border/50">
|
||||
<div className="flex justify-between items-center p-6 border-b border-border/50 bg-secondary/10">
|
||||
<h2 className="text-lg font-bold">قائمة المسوقين بالعمولة</h2>
|
||||
<button className="flex items-center gap-2 p-2 rounded-lg text-muted-foreground hover:bg-secondary/50 transition-colors">
|
||||
<Filter className="w-5 h-5" />
|
||||
<span className="text-sm">تصفية</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-right text-sm">
|
||||
<thead className="bg-secondary/30 text-muted-foreground">
|
||||
<tr>
|
||||
<th className="py-4 px-6 font-medium">الرقم</th>
|
||||
<th className="py-4 px-6 font-medium">الاسم</th>
|
||||
<th className="py-4 px-6 font-medium">المستوى</th>
|
||||
<th className="py-4 px-6 font-medium">الإغلاقات (الشهر)</th>
|
||||
<th className="py-4 px-6 font-medium">المبيعات المُدخلة</th>
|
||||
<th className="py-4 px-6 font-medium">العمولة المكتسبة</th>
|
||||
<th className="py-4 px-6 font-medium">الإجراء</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border/30">
|
||||
{affiliates.map((aff, i) => (
|
||||
<tr key={i} className="hover:bg-white/5 transition-colors group">
|
||||
<td className="py-4 px-6 font-mono text-muted-foreground">{aff.id}</td>
|
||||
<td className="py-4 px-6">
|
||||
<div className="font-bold text-foreground">{aff.name}</div>
|
||||
<div className="text-xs text-muted-foreground mt-0.5">{aff.status}</div>
|
||||
</td>
|
||||
<td className="py-4 px-6">
|
||||
<span className={`px-2.5 py-1 rounded text-xs font-bold ${
|
||||
aff.level === 'Senior' ? 'bg-primary/20 text-primary' :
|
||||
aff.level === 'Mid' ? 'bg-blue-500/20 text-blue-500' : 'bg-slate-500/20 text-slate-500'
|
||||
}`}>
|
||||
{aff.level}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-4 px-6 font-bold">{aff.sales}</td>
|
||||
<td className="py-4 px-6 font-mono">{aff.rev}</td>
|
||||
<td className="py-4 px-6 font-mono text-emerald-500">{aff.comm}</td>
|
||||
<td className="py-4 px-6">
|
||||
{aff.eligibleForHire ? (
|
||||
<button className="flex items-center gap-1.5 px-3 py-1.5 bg-emerald-500 text-white rounded-lg text-xs font-bold hover:bg-emerald-600 transition-colors shadow-lg shadow-emerald-500/20">
|
||||
<Award className="w-3.5 h-3.5" />
|
||||
ترقية (توظيف)
|
||||
</button>
|
||||
) : (
|
||||
<button className="flex items-center gap-1.5 px-3 py-1.5 bg-background border border-border rounded-lg text-xs font-bold hover:bg-secondary transition-colors text-muted-foreground group-hover:text-foreground">
|
||||
عرض التفاصيل
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,99 @@
|
||||
import { FileSignature, ShieldCheck, MailPlus, AlertCircle, Building2, Download } from "lucide-react";
|
||||
|
||||
export function AgreementsView() {
|
||||
return (
|
||||
<div className="p-8 max-w-7xl mx-auto space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||
<div className="flex justify-between items-end">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight mb-2">📋 الاتفاقيات والموارد البشرية (Legal & HR)</h1>
|
||||
<p className="text-muted-foreground">توليد وإدارة عقود المسوقين بالعمولة ومسار الترقية للتوظيف الرسمي.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
{/* Tier 1: Affiliate Agreement */}
|
||||
<div className="glass-card flex flex-col group overflow-hidden">
|
||||
<div className="p-6 border-b border-border/50 bg-blue-500/10 flex items-center gap-4">
|
||||
<div className="p-3 rounded-xl bg-background shadow-sm text-blue-500">
|
||||
<ShieldCheck className="w-8 h-8" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-bold">1. اتفاقية تسويق بالعمولة (عمل حر)</h2>
|
||||
<span className="text-sm font-medium text-emerald-500">للمسوقين الجدد</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-6 flex-1 flex flex-col gap-4">
|
||||
<p className="text-sm text-foreground/80 leading-relaxed font-sans">
|
||||
اتفاقية مبدئية تحفظ حقوق المسوق والشركة وتحدد نسب العمولة من (8% إلى 12%).
|
||||
</p>
|
||||
<ul className="space-y-3 mb-6">
|
||||
<li className="flex items-center gap-2 text-sm">
|
||||
<ShieldCheck className="w-4 h-4 text-emerald-500" />
|
||||
<span>حماية الخصوصية و NDA لعدم إفشاء أسرار العملاء.</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-2 text-sm">
|
||||
<ShieldCheck className="w-4 h-4 text-emerald-500" />
|
||||
<span>شروط دورة الدفع واستحقاق العمولة عند الإغلاق.</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-2 text-sm">
|
||||
<ShieldCheck className="w-4 h-4 text-emerald-500" />
|
||||
<span>قواعد تمثيل الهوية التجارية لـ Dealix بأمانة.</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<button className="mt-auto w-full flex items-center justify-center gap-2 py-3 rounded-xl bg-primary hover:bg-primary/90 text-primary-foreground font-medium shadow-lg shadow-primary/25 transition-all">
|
||||
<MailPlus className="w-4 h-4" />
|
||||
توليد وإرسال الاتفاقية (DocuSign)
|
||||
</button>
|
||||
<button className="w-full flex items-center justify-center gap-2 py-3 rounded-xl bg-secondary text-secondary-foreground hover:bg-secondary/80 border border-border font-medium transition-all">
|
||||
<Download className="w-4 h-4" />
|
||||
تحميل نموذج PDF
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tier 2: Formal Employment Workflow */}
|
||||
<div className="glass-card flex flex-col group overflow-hidden border-2 border-primary/20">
|
||||
<div className="p-6 border-b border-primary/20 bg-primary/10 flex items-center gap-4">
|
||||
<div className="p-3 rounded-xl bg-primary shadow-sm text-primary-foreground shadow-primary/50 relative overflow-hidden">
|
||||
<Building2 className="w-8 h-8 relative z-10" />
|
||||
<div className="absolute inset-0 bg-white/20 animate-pulse" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-bold">2. مسار التوظيف الرسمي (قوى Qiwa)</h2>
|
||||
<span className="text-sm font-medium text-primary">آلي بعد إغلاق 10 شركات / شهر</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-6 flex-1 flex flex-col gap-4">
|
||||
<div className="bg-amber-500/10 border border-amber-500/20 text-amber-500 rounded-xl p-4 flex items-start gap-3">
|
||||
<AlertCircle className="w-5 h-5 shrink-0 mt-0.5" />
|
||||
<p className="text-sm font-medium leading-relaxed">
|
||||
يتم تفعيل هذا المسار تلقائياً عند تحقيق مستهدفات المبيعات المستمرة. النظام يقوم بأتمتة رفع تذكرة لإدارة الموارد البشرية لإنشاء عرض وظيفي رسمي عبر "قوى".
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ul className="space-y-3 mb-6 mt-2">
|
||||
<li className="flex items-center gap-2 text-sm">
|
||||
<Building2 className="w-4 h-4 text-primary" />
|
||||
<span>تسجيل في التأمينات الاجتماعية وعقد رسمي (Qiwa).</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-2 text-sm">
|
||||
<Building2 className="w-4 h-4 text-primary" />
|
||||
<span>راتب ثابت يبدأ من 5,000 ر.س + عمولة 5%.</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-2 text-sm">
|
||||
<Building2 className="w-4 h-4 text-primary" />
|
||||
<span>ترقية صلاحيات في Dealix لمدير حسابات أقدم.</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<button className="mt-auto w-full flex items-center justify-center gap-2 py-3 rounded-xl bg-primary hover:bg-primary/90 text-primary-foreground font-medium shadow-lg shadow-primary/25 transition-all opacity-80 cursor-progress">
|
||||
<Building2 className="w-4 h-4" />
|
||||
بدء مسار توظيف لمرشح (HR Trigger)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,86 @@
|
||||
import { Bot, Mic, MessageSquare, Plus, Activity, AlertCircle, Phone } from "lucide-react";
|
||||
|
||||
export function ChatbotView() {
|
||||
const agents = [
|
||||
{ name: "وكيل التأهيل (WhatsApp)", type: "Qualification", lang: "العربية (السعودية)", status: "Active", volume: 1450 },
|
||||
{ name: "وكيل الاتصال الصوتي", type: "Voice Calls", lang: "العربية (السعودية)", status: "Training", volume: 320 },
|
||||
{ name: "الوكيل العقاري الخاص", type: "Sector Specific", lang: "Bilingual", status: "Active", volume: 890 },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="p-8 max-w-7xl mx-auto space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||
<div className="flex justify-between items-end">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight mb-2">🤖 مركز تحكم وكلاء الذكاء الاصطناعي</h1>
|
||||
<p className="text-muted-foreground">صناعة وتوجيه وكلاء المبيعات، المحادثة النصية (WhatsApp) والاتصال الصوتي (Voice Agents).</p>
|
||||
</div>
|
||||
<button className="flex items-center gap-2 px-5 py-2.5 rounded-xl bg-primary hover:bg-primary/90 text-primary-foreground font-medium shadow-lg shadow-primary/25 transition-all">
|
||||
<Plus className="w-5 h-5" />
|
||||
بناء وكيل جديد
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
{agents.map((agent, i) => (
|
||||
<div key={i} className="glass-card flex flex-col group overflow-hidden border border-border/50">
|
||||
<div className={`p-6 border-b border-border/50 flex justify-between items-center bg-secondary/20`}>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className={`p-3 rounded-xl bg-background shadow-sm ${agent.type.includes('Voice') ? 'text-blue-500' : 'text-emerald-500'}`}>
|
||||
{agent.type.includes('Voice') ? <Mic className="w-6 h-6" /> : <MessageSquare className="w-6 h-6" />}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-lg">{agent.name}</h3>
|
||||
<p className="text-xs text-muted-foreground">{agent.type}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className={`w-3 h-3 rounded-full ${agent.status === 'Active' ? 'bg-emerald-500 animate-pulse' : 'bg-amber-500'}`} />
|
||||
</div>
|
||||
|
||||
<div className="p-6 flex-1 flex flex-col gap-4">
|
||||
<div className="flex justify-between items-center border-b border-border/50 py-2">
|
||||
<span className="text-sm text-muted-foreground">اللغة المدعومة:</span>
|
||||
<span className="text-sm font-medium">{agent.lang}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center border-b border-border/50 py-2">
|
||||
<span className="text-sm text-muted-foreground">إجمالي المحادثات:</span>
|
||||
<span className="text-sm font-bold">{agent.volume}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center border-b border-border/50 py-2">
|
||||
<span className="text-sm text-muted-foreground">نسبة التسليم للبشر (Handoff):</span>
|
||||
<span className="text-sm font-bold text-amber-500">12%</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-auto grid grid-cols-2 gap-3 pt-4">
|
||||
<button className="flex items-center justify-center gap-2 py-2 rounded-lg bg-background border border-border hover:bg-secondary/50 transition-colors text-sm font-medium">
|
||||
<Bot className="w-4 h-4" />
|
||||
تعديل البرومبت
|
||||
</button>
|
||||
<button className="flex items-center justify-center gap-2 py-2 rounded-lg bg-background border border-border hover:bg-secondary/50 transition-colors text-sm font-medium">
|
||||
<Activity className="w-4 h-4" />
|
||||
سجل المحادثات
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Voice Demo Panel */}
|
||||
<div className="glass-card p-6 flex items-center justify-between border border-blue-500/20 bg-blue-500/5 mt-8">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-4 rounded-full bg-blue-500 text-white shadow-lg shadow-blue-500/30">
|
||||
<Phone className="w-6 h-6 animate-pulse" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-bold">تجربة الوكيل الصوتي المباشر (Realtime SA)</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1">تحدث مباشرة مع وكيلك الذكي لتختبر اللهجة السعودية وسرعة الرد.</p>
|
||||
</div>
|
||||
</div>
|
||||
<button className="px-6 py-3 rounded-xl bg-blue-500 hover:bg-blue-600 text-white font-bold transition-all shadow-lg flex gap-2 items-center">
|
||||
<Mic className="w-5 h-5" />
|
||||
بدء محاكاة المكالمة
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
131
salesflow-saas/frontend/src/components/dealix/dashboard-view.tsx
Normal file
131
salesflow-saas/frontend/src/components/dealix/dashboard-view.tsx
Normal file
@ -0,0 +1,131 @@
|
||||
import { BarChart3, Users, Target, TrendingUp, Calendar, ArrowUpRight, BrainCircuit, Zap } from "lucide-react";
|
||||
|
||||
export function DashboardView() {
|
||||
const stats = [
|
||||
{ label: "العملاء المحتملين", value: "2,450", trend: "+12.5%", icon: Users, color: "text-blue-500", bg: "bg-blue-500/10" },
|
||||
{ label: "الاجتماعات المجدولة", value: "145", trend: "+24.3%", icon: Calendar, color: "text-purple-500", bg: "bg-purple-500/10" },
|
||||
{ label: "المبيعات المغلقة", value: "89", trend: "+8.2%", icon: Target, color: "text-emerald-500", bg: "bg-emerald-500/10" },
|
||||
{ label: "إيرادات الشهر", value: "1.2M ر.س", trend: "+18.4%", icon: TrendingUp, color: "text-amber-500", bg: "bg-amber-500/10" },
|
||||
];
|
||||
|
||||
const pipeline = [
|
||||
{ name: "شركة الأفق التقنية", stage: "تفاوض", value: "125,000 ر.س", prob: "80%", agent: "وكيل الإغلاق" },
|
||||
{ name: "مجموعة الرواد", stage: "عرض سعر", value: "450,000 ر.س", prob: "60%", agent: "متدرب الذكاء الاصطناعي" },
|
||||
{ name: "مصنع الشرق الأوسط", stage: "اجتماع أولي", value: "85,000 ر.س", prob: "30%", agent: "مجدول المواعيد" },
|
||||
{ name: "مؤسسة النور", stage: "تأهيل", value: "غير محدد", prob: "10%", agent: "وكيل التأهيل" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="p-8 max-w-7xl mx-auto space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||
{/* Welcome Intro */}
|
||||
<div className="flex justify-between items-end">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight mb-2">أهلاً بك، سالم 👋</h1>
|
||||
<p className="text-muted-foreground">نظرة عامة على أداء نظام المبيعات الذكي اليوم.</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button className="px-5 py-2.5 rounded-xl border border-border bg-card hover:bg-secondary/50 transition-colors text-sm font-medium">
|
||||
تصدير التقرير
|
||||
</button>
|
||||
<button className="px-5 py-2.5 rounded-xl bg-primary hover:bg-primary/90 text-primary-foreground transition-colors shadow-lg shadow-primary/25 text-sm font-medium flex items-center gap-2">
|
||||
<Zap className="w-4 h-4" />
|
||||
تفعيل وكيل جديد
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{stats.map((stat, i) => (
|
||||
<div key={i} className="glass-card p-6 relative overflow-hidden group">
|
||||
<div className={`absolute -right-6 -top-6 w-24 h-24 rounded-full blur-2xl opacity-20 transition-all group-hover:opacity-40 group-hover:scale-150 ${stat.bg.replace('/10', '')}`} />
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div className={`p-3 rounded-2xl ${stat.bg}`}>
|
||||
<stat.icon className={`w-6 h-6 ${stat.color}`} />
|
||||
</div>
|
||||
<span className="flex items-center gap-1 text-sm font-medium text-emerald-500 bg-emerald-500/10 px-2.5 py-1 rounded-full">
|
||||
<ArrowUpRight className="w-3 h-3" />
|
||||
{stat.trend}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-3xl font-bold tracking-tight mb-1">{stat.value}</h3>
|
||||
<p className="text-sm text-muted-foreground font-medium">{stat.label}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
{/* Pipeline Table */}
|
||||
<div className="lg:col-span-2 glass-card p-6 border border-border/50">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h2 className="text-lg font-bold">أحدث الصفقات في المسار</h2>
|
||||
<button className="text-sm text-primary hover:underline">عرض الكل</button>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm text-right">
|
||||
<thead>
|
||||
<tr className="text-muted-foreground border-b border-border/50 bg-secondary/20">
|
||||
<th className="py-3 px-4 font-medium">العميل</th>
|
||||
<th className="py-3 px-4 font-medium">المرحلة</th>
|
||||
<th className="py-3 px-4 font-medium">القيمة</th>
|
||||
<th className="py-3 px-4 font-medium">احتمالية الإغلاق</th>
|
||||
<th className="py-3 px-4 font-medium">الوكيل المسؤول</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border/30">
|
||||
{pipeline.map((deal, i) => (
|
||||
<tr key={i} className="hover:bg-white/5 transition-colors group">
|
||||
<td className="py-4 px-4 font-medium text-foreground">{deal.name}</td>
|
||||
<td className="py-4 px-4">
|
||||
<span className="px-3 py-1 rounded-full text-xs font-medium bg-secondary/50 text-secondary-foreground border border-border/50">
|
||||
{deal.stage}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-4 px-4 font-mono text-foreground/80">{deal.value}</td>
|
||||
<td className="py-4 px-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-16 h-1.5 bg-secondary rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-primary rounded-full transition-all duration-1000"
|
||||
style={{ width: deal.prob }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">{deal.prob}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-4 px-4 text-muted-foreground flex items-center gap-2">
|
||||
<BrainCircuit className="w-4 h-4 opacity-50" />
|
||||
{deal.agent}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Panel */}
|
||||
<div className="glass-card p-6 flex flex-col border border-border/50">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h2 className="text-lg font-bold">تنبيهات الإدارة العُليا</h2>
|
||||
<span className="w-2 h-2 rounded-full bg-destructive animate-pulse" />
|
||||
</div>
|
||||
<div className="space-y-4 flex-1">
|
||||
<div className="p-4 rounded-xl bg-destructive/10 border border-destructive/20 flex flex-col gap-2">
|
||||
<span className="text-xs font-bold text-destructive">مراجعة شكوى</span>
|
||||
<p className="text-sm font-medium">شركة "التطوير الذكي" تطلب تفعيل الضمان الذهبي لعدم الوصول للمستهدف التفاعلي.</p>
|
||||
<button className="text-xs font-bold text-destructive underline mt-1 text-right w-full">مراجعة فحص الشرط الرابع</button>
|
||||
</div>
|
||||
<div className="p-4 rounded-xl bg-primary/10 border border-primary/20 flex flex-col gap-2">
|
||||
<span className="text-xs font-bold text-primary">تفعيل توظيف مسوق</span>
|
||||
<p className="text-sm font-medium">المسوق "أحمد عبدالله" أكمل 12 إغلاق، يحتاج لتحويل عقده إلى رسمي عبر Qiwa.</p>
|
||||
<button className="text-xs font-bold text-primary underline mt-1 text-right w-full">بدء عملية HR</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,96 @@
|
||||
import { ShieldAlert, Info, AlertTriangle, FileCheck, CheckCircle2, RotateCcw } from "lucide-react";
|
||||
|
||||
export function GuaranteesView() {
|
||||
return (
|
||||
<div className="p-8 max-w-7xl mx-auto space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||
<div className="flex justify-between items-end">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight mb-2">🛡️ الضمان الذهبي لـ Dealix (الاسترجاع)</h1>
|
||||
<p className="text-muted-foreground">سياسة الضمان والشروط وإدارة المطالبات لضمان حق الشركة والعميل معاً.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
{/* Policy Brief */}
|
||||
<div className="lg:col-span-2 glass-card p-6 flex flex-col gap-6">
|
||||
<div className="flex items-center gap-4 pb-4 border-b border-border/50">
|
||||
<div className="p-3 rounded-xl bg-amber-500/10 text-amber-500">
|
||||
<ShieldAlert className="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-bold">ملخص سياسة الضمان لمدة 30 يوماً</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">يُشترط للوفاء بالضمان الالتزام بخطة تشغيل الوكيل الذكي بالكامل.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h3 className="font-bold text-foreground/90">شروط الاستحقاق الرئيسية:</h3>
|
||||
<ul className="space-y-3 p-4 bg-secondary/30 rounded-xl border border-secondary">
|
||||
<li className="flex gap-3 text-sm">
|
||||
<CheckCircle2 className="w-5 h-5 text-emerald-500 shrink-0" />
|
||||
<span className="leading-relaxed">أن يكون العميل قد وفر بيانات التدريب اللازمة (منتجات، أسعار، PDF معرفي) خلال أول 3 أيام من الاشتراك.</span>
|
||||
</li>
|
||||
<li className="flex gap-3 text-sm">
|
||||
<CheckCircle2 className="w-5 h-5 text-emerald-500 shrink-0" />
|
||||
<span className="leading-relaxed">تفعيل الوكيل أو الشات بوت على قنوات حية (واتساب، انستقرام) وأن يكون قد استلم ما لا يقل عن 100 رسالة حقيقية من العملاء.</span>
|
||||
</li>
|
||||
<li className="flex gap-3 text-sm">
|
||||
<CheckCircle2 className="w-5 h-5 text-emerald-500 shrink-0" />
|
||||
<span className="leading-relaxed">عدم إيقاف تشغيل الوكيل لأكثر من 48 ساعة متواصلة خلال فترة الشهر الأولى.</span>
|
||||
</li>
|
||||
<li className="flex gap-3 text-sm">
|
||||
<CheckCircle2 className="w-5 h-5 text-emerald-500 shrink-0" />
|
||||
<span className="leading-relaxed">فشل تقني مثبت (أخطاء جسيمة في الرد، تسريب عملاء، ردود هلوسة) ولم يقم فريق الدعم بحلها.</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="bg-destructive/10 border border-destructive/20 text-destructive rounded-xl p-4 flex items-start gap-3 mt-auto">
|
||||
<AlertTriangle className="w-5 h-5 shrink-0 mt-0.5" />
|
||||
<p className="text-sm font-medium leading-relaxed">
|
||||
تحذير للمسوقين: لا تقدم ضماناً قطيعاً بدون عرض هذي الشروط الـ 4 للعميل. البيع التضليلي أو المبالغ فيه قد يوقف حسابك تلقائياً في Dealix.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Claim workflow & Status */}
|
||||
<div className="glass-card p-6 flex flex-col gap-6">
|
||||
<div className="pb-4 border-b border-border/50">
|
||||
<h2 className="text-xl font-bold flex flex-col">
|
||||
مركز المطالبات
|
||||
<span className="text-sm text-muted-foreground font-normal mt-1">Claims Management</span>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 space-y-4">
|
||||
<div className="p-4 rounded-xl bg-secondary/30 border border-border/50 flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-bold text-muted-foreground">حالة المراجعة (قيد الانتظار)</span>
|
||||
<span className="text-xs bg-amber-500/20 text-amber-500 px-2 py-1 rounded font-bold">مراجعة 3 مطالبات</span>
|
||||
</div>
|
||||
<p className="text-sm font-medium">شركة الأفق الطبي (رفض دفع)</p>
|
||||
<div className="flex gap-2 text-xs">
|
||||
<button className="flex-1 bg-background border border-border py-2 rounded-lg hover:bg-emerald-500/10 hover:border-emerald-500/50 hover:text-emerald-500 transition-colors">مقبول</button>
|
||||
<button className="flex-1 bg-background border border-border py-2 rounded-lg hover:bg-destructive/10 hover:border-destructive/50 hover:text-destructive transition-colors">مرفوض</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 rounded-xl bg-emerald-500/10 border border-emerald-500/20 flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-bold text-muted-foreground">حالة السداد (مدفوعة ومسترجعة)</span>
|
||||
<span className="text-xs bg-emerald-500/20 text-emerald-500 px-2 py-1 rounded font-bold">1 مطالبة</span>
|
||||
</div>
|
||||
<p className="text-sm font-medium">مصنع التمور العصرية</p>
|
||||
<span className="text-xs text-emerald-500 font-bold">تم إرجاع 21,500 ر.س</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button className="w-full flex items-center justify-center gap-2 py-3 rounded-xl bg-primary hover:bg-primary/90 text-primary-foreground font-medium shadow-lg shadow-primary/25 transition-all mt-auto">
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
فتح تذكرة استرجاع (Refund Ticket)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,88 @@
|
||||
import { BookOpen, Map, Target, Award, Rocket, FileText, Smartphone, Megaphone } from "lucide-react";
|
||||
|
||||
export function OnboardingView() {
|
||||
const steps = [
|
||||
{ num: 1, title: "فهم المنتج (Dealix)", desc: "شركة سعودية للذكاء الاصطناعي موجهة لقطاع الأعمال؛ تصنع موظفين AI للمبيعات والدعم الفني." },
|
||||
{ num: 2, title: "تحديد الفئة", desc: "شركات B2B/B2C اللي تعاني من نقص في الرد السريع، أو تسرب المبيعات." },
|
||||
{ num: 3, title: "اختيار القطاع", desc: "اختر قطاع تفهمه جيداً (العقارات، العيادات، أو المتاجر) واستخدم الترسانة القطاعية." },
|
||||
{ num: 4, title: "الاستهداف", desc: "ابحث في LinkedIn لمعرفة صناع القرار، أو خرائط جوجل (Google Maps) للأنشطة المحلية." },
|
||||
{ num: 5, title: "التواصل الأولي", desc: "استخدم سكربت 'المكالمة الباردة' أو 'الواتساب البارد' المتوفر في قسم السكربتات." },
|
||||
{ num: 6, title: "حجز الديمو", desc: "هدفك الوحيد هو إقناع العميل بتجربة ديمو مجاني للـ AI عن طريق الواتساب." },
|
||||
{ num: 7, title: "الإغلاق", desc: "يقوم فريقنا وخبرائنا (أو أنت إذا كنت محترفاً) بإغلاق الصفقة وتوقيع العقود، لتستلم عمولتك." },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="p-8 max-w-7xl mx-auto space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||
<div className="flex justify-between items-end">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight mb-2">📖 دليل المسوق الشامل (Onboarding)</h1>
|
||||
<p className="text-muted-foreground">خطوتك الأولى لفهم Dealix وكيف تبدأ بتحقيق المبيعات والعمولات من اليوم الأول.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
{/* Core Steps */}
|
||||
<div className="lg:col-span-2 glass-card p-6 flex flex-col gap-6">
|
||||
<div className="flex items-center gap-4 pb-4 border-b border-border/50">
|
||||
<div className="p-3 rounded-xl bg-primary/10 text-primary">
|
||||
<Map className="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-bold">خارطة الطريق (7 خطوات نجاح)</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{steps.map((step, i) => (
|
||||
<div key={i} className="p-4 bg-secondary/30 rounded-xl border border-border/50 flex gap-4 hover:bg-secondary/50 transition-colors">
|
||||
<div className="w-8 h-8 rounded-full bg-primary text-primary-foreground font-bold flex items-center justify-center shrink-0">
|
||||
{step.num}
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-bold text-sm mb-1">{step.title}</h4>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">{step.desc}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Side Panel: Targets & Strategies */}
|
||||
<div className="space-y-6">
|
||||
<div className="glass-card p-6 flex flex-col gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Target className="w-5 h-5 text-emerald-500" />
|
||||
<h3 className="font-bold">استراتيجيات البحث</h3>
|
||||
</div>
|
||||
<ul className="space-y-3">
|
||||
<li className="flex items-center gap-3 text-sm p-3 bg-card border border-border rounded-lg shadow-sm">
|
||||
<Smartphone className="w-4 h-4 text-blue-500" /> Google Maps للشركات والعيادات
|
||||
</li>
|
||||
<li className="flex items-center gap-3 text-sm p-3 bg-card border border-border rounded-lg shadow-sm">
|
||||
<FileText className="w-4 h-4 text-blue-500" /> LinkedIn Sales Navigator
|
||||
</li>
|
||||
<li className="flex items-center gap-3 text-sm p-3 bg-card border border-border rounded-lg shadow-sm">
|
||||
<Megaphone className="w-4 h-4 text-blue-500" /> إعلانات إنستغرام الممولة كمخابئ للعملاء
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="glass-card p-6 flex flex-col gap-4 bg-gradient-to-br from-primary/10 to-transparent border-primary/20">
|
||||
<div className="flex items-center gap-3">
|
||||
<Award className="w-5 h-5 text-primary" />
|
||||
<h3 className="font-bold">الترقية التلقائية (Qiwa)</h3>
|
||||
</div>
|
||||
<p className="text-sm text-foreground/80 leading-relaxed font-sans mt-2">
|
||||
إذا حققت <span className="font-bold text-primary">10 إغلاقات بمبالغ أعلى من 5,000 ريال للشهر الواحد</span>،
|
||||
يتم ترقيتك فوراً لمسار "المبيعات التنفيذية" بعقد رسمي وراتب ثابت + عمولة 5%.
|
||||
</p>
|
||||
<div className="mt-4 p-4 rounded-xl bg-background border border-border/50 text-center">
|
||||
<Rocket className="w-8 h-8 text-primary mx-auto mb-2 opacity-80" />
|
||||
<span className="text-sm font-bold block">متبقي لك 10 شركات للتأهل!</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,117 @@
|
||||
import { FileBarChart, MonitorPlay, Activity, Stethoscope, Car, Home, ShoppingBag, BookOpen } from "lucide-react";
|
||||
|
||||
const SECTORS = [
|
||||
{
|
||||
icon: Stethoscope,
|
||||
name: "العيادات الطبية",
|
||||
color: "text-rose-500",
|
||||
bg: "bg-rose-500/10",
|
||||
pain: "ضياع حجوزات بسبب التأخر في الرد على الواتساب وعدم التذكير بالمواعيد.",
|
||||
solution: "حجز تلقائي وتأكيد مواعيد، إجابة عن أسئلة القسم والعيادات 24/7.",
|
||||
stats: "٣٠٪ معدل فشل حضور المرضى بسبب سوء المتابعة اليدوية.",
|
||||
deckUrl: "#deck-clinics"
|
||||
},
|
||||
{
|
||||
icon: Home,
|
||||
name: "العقارات وإدارة الأملاك",
|
||||
color: "text-blue-500",
|
||||
bg: "bg-blue-500/10",
|
||||
pain: "مئات الاستفسارات عن الأسعار والمواقع والفلترة تضيع وقت الوكلاء.",
|
||||
solution: "وكيل عقاري ذكي يفلتر العملاء، يسأل عن الميزانية، ويرسل عروض.",
|
||||
stats: "٧٠٪ من الاستفسارات العقارية غير جادة وتضيع وقت المبيعات.",
|
||||
deckUrl: "#deck-realestate"
|
||||
},
|
||||
{
|
||||
icon: Car,
|
||||
name: "قطاع السيارات وصيانتها",
|
||||
color: "text-slate-500",
|
||||
bg: "bg-slate-500/10",
|
||||
pain: "صعوبة في جدولة مواعيد الصيانة واستفسارات قطع الغيار المملة.",
|
||||
solution: "حجز مواعيد الصيانة فورياً عبر الواتساب وتذكير العميل عند الانتهاء.",
|
||||
stats: "السوق يحتاج ٥٠٪ سرعة أكبر في المبيعات بعد طلب تجربة القيادة.",
|
||||
deckUrl: "#deck-auto"
|
||||
},
|
||||
{
|
||||
icon: ShoppingBag,
|
||||
name: "المتاجر الإلكترونية",
|
||||
color: "text-purple-500",
|
||||
bg: "bg-purple-500/10",
|
||||
pain: "استفسارات تتبع الطلب متكررة والسلال المتروكة تكلف أموال.",
|
||||
solution: "تتبع آلي، إرسال تذكيرات ذكية للسلال المتروكة، دعم ما بعد البيع.",
|
||||
stats: "٦٨٪ معدل ترك السلال الشرائية حول العالم.",
|
||||
deckUrl: "#deck-ecommerce"
|
||||
},
|
||||
{
|
||||
icon: BookOpen,
|
||||
name: "التعليم والتدريب",
|
||||
color: "text-emerald-500",
|
||||
bg: "bg-emerald-500/10",
|
||||
pain: "استفسارات عن جداول الدورات والأسعار تأخذ وقت طويل من خدمة العملاء.",
|
||||
solution: "مستشار تعليمي آلي يجيب على شروط التسجيل، ويسجل الطلاب.",
|
||||
stats: "الطلاب يتوقعون ردود فورية للتسجيل وإلا يذهبون لمعاهد أخرى.",
|
||||
deckUrl: "#deck-education"
|
||||
},
|
||||
{
|
||||
icon: Activity,
|
||||
name: "شركات التقنية والخدمات B2B",
|
||||
color: "text-amber-500",
|
||||
bg: "bg-amber-500/10",
|
||||
pain: "دورة المبيعات طويلة جداً واجتماعات مع أشخاص غير مؤهلين.",
|
||||
solution: "تأهيل صارم للعميل (BANT) قبل حجز أي الديمو.",
|
||||
stats: "٥٠٪ من اجتماعات B2B تكون مع عملاء خارج نطاق الخدمة.",
|
||||
deckUrl: "#deck-b2b"
|
||||
}
|
||||
];
|
||||
|
||||
export function PresentationsView() {
|
||||
return (
|
||||
<div className="p-8 max-w-7xl mx-auto space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||
<div className="flex justify-between items-end">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight mb-2">📊 الترسانة القطاعية (Sector Sales Arsenal)</h1>
|
||||
<p className="text-muted-foreground">عروض تقديمية وملفات ROI مخصصة لكل قطاع تستخدمها للإغلاق السريع.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{SECTORS.map((sector, idx) => (
|
||||
<div key={idx} className="glass-card flex flex-col group overflow-hidden">
|
||||
<div className={`p-6 border-b border-border/50 ${sector.bg} flex items-center gap-4`}>
|
||||
<div className={`p-3 rounded-xl bg-background shadow-sm ${sector.color}`}>
|
||||
<sector.icon className="w-8 h-8" />
|
||||
</div>
|
||||
<h2 className="text-xl font-bold">{sector.name}</h2>
|
||||
</div>
|
||||
|
||||
<div className="p-6 flex-1 flex flex-col gap-4">
|
||||
<div>
|
||||
<h4 className="text-sm font-bold text-destructive mb-1">نقاط الألم (Pain Points):</h4>
|
||||
<p className="text-sm text-foreground/80 leading-relaxed font-sans">{sector.pain}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-sm font-bold text-emerald-500 mb-1">كيف نحل المشكلة (Dealix Solution):</h4>
|
||||
<p className="text-sm text-foreground/80 leading-relaxed font-sans">{sector.solution}</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-secondary/30 rounded-xl p-3 border border-border/50 mb-4 mt-auto">
|
||||
<span className="text-xs font-bold text-muted-foreground">إحصائية للإغلاق: </span>
|
||||
<span className="text-sm font-medium">{sector.stats}</span>
|
||||
</div>
|
||||
|
||||
<button className="w-full flex items-center justify-center gap-2 py-3 rounded-xl bg-primary hover:bg-primary/90 text-primary-foreground font-medium shadow-lg shadow-primary/25 transition-all">
|
||||
<FileBarChart className="w-4 h-4" />
|
||||
تحميل العرض التقديمي (Deck)
|
||||
</button>
|
||||
|
||||
<button className="w-full flex items-center justify-center gap-2 py-3 rounded-xl bg-secondary text-secondary-foreground hover:bg-secondary/80 border border-border font-medium transition-all">
|
||||
<MonitorPlay className="w-4 h-4" />
|
||||
استخراج حاسبة العائد ROI
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
127
salesflow-saas/frontend/src/components/dealix/scripts-view.tsx
Normal file
127
salesflow-saas/frontend/src/components/dealix/scripts-view.tsx
Normal file
@ -0,0 +1,127 @@
|
||||
import { useState } from "react";
|
||||
import { Copy, CheckCircle2, ChevronDown, MessageCircle, Phone, FileText } from "lucide-react";
|
||||
|
||||
const SCRIPTS = {
|
||||
"cold-call": {
|
||||
title: "مكالمة باردة (Cold Call)",
|
||||
icon: Phone,
|
||||
color: "text-blue-500",
|
||||
bg: "bg-blue-500/10",
|
||||
script: `مرحباً [اسم العميل]، معك [اسمك] من منصة Dealix للذكاء الاصطناعي.
|
||||
|
||||
أنا أتابع قطاع [قطاع العميل] وملاحظ إن التحدي الأكبر حالياً هو تسرب العملاء المحتملين وصعوبة الرد الفوري على كل الاستفسارات.
|
||||
|
||||
نحن في Dealix طورنا "موظف ذكاء اصطناعي" بلهجة سعودية يتحدث مع عملائك 24/7، يفلترهم، ويحجز المواعيد لك مباشرة.
|
||||
|
||||
هل عندك 3 دقائق الأسبوع القادم أوريك ديمو حي كيف ممكن نضاعف مبيعاتك؟`
|
||||
},
|
||||
"whatsapp-intro": {
|
||||
title: "تواصل واتساب (WhatsApp Intro)",
|
||||
icon: MessageCircle,
|
||||
color: "text-emerald-500",
|
||||
bg: "bg-emerald-500/10",
|
||||
script: `أهلاً [اسم العميل] 👋
|
||||
معك [اسمك] من شركة Dealix للذكاء الاصطناعي.
|
||||
|
||||
بصفتك مدير في [قطاع العميل]، أكيد تعرف إن سرعة الرد تصنع فارق كبير في المبيعات. 🚀
|
||||
صممنا لك وكيل ذكاء اصطناعي بلهجتنا السعودية 🇸🇦 يرد، يقنع، ويحجز المواعيد 24/7.
|
||||
|
||||
متى يناسبك أرسل لك رابط لتجربة النظام فعلياً؟ (التجربة مجانية)`
|
||||
},
|
||||
"follow-up": {
|
||||
title: "متابعة (Follow-up)",
|
||||
icon: FileText,
|
||||
color: "text-purple-500",
|
||||
bg: "bg-purple-500/10",
|
||||
script: `أهلاً [اسم العميل]، مساك الله بالخير.
|
||||
|
||||
أتمنى تكون بخير. حبيت أذكرك بخصوص وكيل المبيعات الذكي من Dealix.
|
||||
أرفقت لك ملف سريع يوضح كيف قدرنا نرفع مبيعات شركات في نفس مجالكم بنسبة 40% خلال أول شهر.
|
||||
|
||||
هل تحب نحدد موعد سريع 10 دقائق نتناقش فيه؟`
|
||||
},
|
||||
"objections": {
|
||||
title: "الرد على الاعتراضات (Objections)",
|
||||
icon: FileText,
|
||||
color: "text-amber-500",
|
||||
bg: "bg-amber-500/10",
|
||||
script: `الاعتراض: "السعر غالي"
|
||||
الرد: "أتفهم وجهة نظرك [اسم العميل]. لكن لو حسبناها، الموظف البشري يكلف راتب، تأمين، ومكتب، وإجازات، ولا يقدر يشتغل 24/7. نظام Dealix يشتغل بدون توقف وبجزء بسيط من هذي التكلفة. والأهم، عندنا (الضمان الذهبي)، إذا ما حققنا لك نتائج خلال 30 يوم نرجع فلوسك كاملة."
|
||||
|
||||
الاعتراض: "الذكاء الاصطناعي يخوف/مو دقيق"
|
||||
الرد: "صحيح البدايات كانت كذا، لكن وكلاء Dealix مدربين على منتجاتك فقط ولا يجاوبون من راسهم مطلقاً. والأهم إنهم مبرمجين يحولون المحادثة لموظف بشري فوراً إذا السؤال كان معقد."`
|
||||
},
|
||||
"closing": {
|
||||
title: "إغلاق البيعة (Closing)",
|
||||
icon: CheckCircle2,
|
||||
color: "text-rose-500",
|
||||
bg: "bg-rose-500/10",
|
||||
script: `ممتاز جداً [اسم العميل].
|
||||
|
||||
بما إن النظام ناسبك، كل اللي نحتاجه منك أرقام التواصل وروابط منتجاتكم عشان ندرب الوكيل عليها، وخلال 48 ساعة بيكون جاهز يشتغل لصالحك.
|
||||
|
||||
أرسلت لك رابط الدفع مع عقد التشغيل اللي يضمن حقك الكامل بالاسترجاع خلال 30 يوم في حال ما شفت القيمة المضافة اللي وعدتك فيها.
|
||||
مبروك مقدماً انضمامك لـ Dealix!`
|
||||
}
|
||||
};
|
||||
|
||||
export function ScriptsView() {
|
||||
const [copied, setCopied] = useState<string | null>(null);
|
||||
|
||||
const copyToClipboard = (id: string, text: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
setCopied(id);
|
||||
setTimeout(() => setCopied(null), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-8 max-w-7xl mx-auto space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||
<div className="flex justify-between items-end">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight mb-2">📞 ترسانة السكربتات (Sales Scripts)</h1>
|
||||
<p className="text-muted-foreground">نماذج ومسودات بيعية مثبتة الفعالية للمسوقين لضمان أعلى نسبة تحويل.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{Object.entries(SCRIPTS).map(([id, data]) => (
|
||||
<div key={id} className="glass-card flex flex-col h-full overflow-hidden group">
|
||||
<div className={`p-6 border-b border-border/50 ${data.bg} transition-colors group-hover:bg-opacity-20`}>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className={`p-3 rounded-xl bg-background shadow-sm ${data.color}`}>
|
||||
<data.icon className="w-6 h-6" />
|
||||
</div>
|
||||
<h3 className="text-lg font-bold">{data.title}</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6 flex-1 flex flex-col bg-card/40">
|
||||
<div className="bg-background rounded-xl p-4 text-sm leading-relaxed whitespace-pre-wrap flex-1 border border-border/50 font-sans text-foreground/90">
|
||||
{data.script}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => copyToClipboard(id, data.script)}
|
||||
className={`w-full mt-4 flex items-center justify-center gap-2 py-3 rounded-xl text-sm font-medium transition-all ${
|
||||
copied === id
|
||||
? "bg-emerald-500/10 text-emerald-500 border border-emerald-500/20"
|
||||
: "bg-secondary text-secondary-foreground hover:bg-secondary/80 border border-border/50"
|
||||
}`}
|
||||
>
|
||||
{copied === id ? (
|
||||
<>
|
||||
<CheckCircle2 className="w-4 h-4" /> تم النسخ بنجاح
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="w-4 h-4" /> نسخ النص
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user