system-prompts-and-models-o.../salesflow-saas/backend/app/api/v1/automation.py
Claude 066ce32aa7
feat(dealix): full automation outreach system — draft queue + pipeline + send
Complete outreach automation that generates drafts → Sami approves → system sends:

1. OutreachDraft model (models/outreach_draft.py):
   DB-persisted draft queue. Every message starts as status='draft'.
   Fields: company, channel, subject, body, followups, sector, scores,
   status (draft→approved→sent→replied→opted_out→bounced), timestamps.

2. Daily Pipeline (automation.py → /daily-pipeline/run):
   Generates N targets per sector/city, runs compliance check,
   creates personalized emails with Arabic pain maps, stores as
   draft rows in DB. Returns batch_id for approval.

3. Draft Queue API (drafts.py):
   - GET /drafts — list by status/channel/batch
   - GET /drafts/stats — counts per status
   - GET /drafts/{id} — full draft with body + followups
   - POST /drafts/{id}/approve — mark approved
   - POST /drafts/approve-batch — approve entire batch
   - POST /drafts/{id}/send — dispatch via email/whatsapp/sms
   - POST /drafts/{id}/skip — archive draft
   - PATCH /drafts/{id} — edit before approving
   - POST /drafts/{id}/log-reply — paste reply → auto-classify →
     generate suggested response → update status

4. Send dispatch uses existing integrations:
   - Email: integrations/email_sender.py (SMTP)
   - WhatsApp: integrations/whatsapp.py (Business API + mock)
   - SMS: integrations/sms.py (Unifonic)
   - LinkedIn: manual_required (copy from dashboard)

Safety:
- All drafts require approval (approval_required=True default)
- Unsubscribe reply → immediate opt_out status
- Compliance gate blocks: opt_out, bounced, high_risk, no_source
- Personal email → warning to use manual channel
- Rate limits enforced at send level

https://claude.ai/code/session_01W1rJthWDkasijTdXCfxVHs
2026-04-25 17:33:30 +00:00

428 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

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

"""Daily Targeting Automation — generates personalized outreach queue.
Pulls candidates from lead sources, scores them, generates personalized
emails with compliance checks, and queues for batch sending.
"""
from __future__ import annotations
import logging
import re
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional
from uuid import uuid4
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
logger = logging.getLogger("dealix.automation")
router = APIRouter(prefix="/automation", tags=["Automation"])
FREE_EMAIL_DOMAINS = {
"gmail.com", "yahoo.com", "hotmail.com", "outlook.com",
"icloud.com", "mail.com", "protonmail.com", "yandex.com",
"aol.com", "live.com", "msn.com",
}
SECTOR_PAIN_MAP = {
"real_estate": {
"pain_ar": "استفسارات كثيرة عن الأسعار والمواقع والمساحات تضيع بسبب تأخر الرد",
"angle_ar": "ديلكس يرد خلال 45 ثانية، يسأل عن الميزانية والموقع المفضل، ويحجز معاينة",
"roi_ar": "لو عندكم 30-100 استفسار/شهر، الرد السريع يحفظ 5-15 فرصة كانت بتبرد",
},
"construction": {
"pain_ar": "طلبات عروض أسعار متكررة تحتاج فرز سريع قبل تحويلها للمهندسين",
"angle_ar": "ديلكس يستقبل الطلب، يسأل عن نوع المشروع والميزانية، ويصنّف الجدية",
"roi_ar": "تقليل وقت الفرز من ساعات إلى دقائق لكل طلب عرض سعر",
},
"hospitality": {
"pain_ar": "حجوزات قاعات ومناسبات واستفسارات أسعار تحتاج رد سريع قبل ما العميل يروح للمنافس",
"angle_ar": "ديلكس يرد فوراً، يسأل عن التاريخ وعدد الضيوف والميزانية، ويحجز مبدئي",
"roi_ar": "كل ساعة تأخير في الرد = احتمال 50% يحجز عند غيركم",
},
"food_beverage": {
"pain_ar": "طلبات تموين وحفلات B2B تجي على واتساب وتضيع بين الرسائل",
"angle_ar": "ديلكس يستقبل طلب التموين، يسأل عن العدد والتاريخ والميزانية، ويحوّل للمبيعات",
"roi_ar": "طلبات B2B عادة أعلى قيمة — حفظها يرفع الإيراد بنسبة ملحوظة",
},
"logistics": {
"pain_ar": "طلبات أسعار شحن متكررة تحتاج رد سريع ومعلومات دقيقة",
"angle_ar": "ديلكس يسأل عن نوع الشحنة والوجهة والحجم ويعطي تقدير أولي",
"roi_ar": "كل طلب شحن ما يُتابع = إيراد ضائع مباشر",
},
"agency": {
"pain_ar": "عملاء الوكالة يجيبون leads بالإعلانات لكن المتابعة ضعيفة",
"angle_ar": "ديلكس يصير خدمة جديدة تبيعونها لعملائكم: رد + تأهيل + حجز",
"roi_ar": "كل عميل وكالة = setup fee + 20-30% MRR شهري متكرر",
},
"saas": {
"pain_ar": "leads تجي من الموقع والإعلانات وتبرد لأن فريق المبيعات صغير",
"angle_ar": "ديلكس يرد بالعربي خلال 45 ثانية، يؤهل، ويحجز demo تلقائياً",
"roi_ar": "شركات SaaS تخسر 30% من leads بسبب تأخر الرد — ديلكس يقلّص هذا",
},
"healthcare": {
"pain_ar": "مرضى يتصلون ويسألون عن المواعيد والأسعار — الموظفين مشغولين",
"angle_ar": "ديلكس يرد على الأسئلة المتكررة ويحجز الموعد مباشرة",
"roi_ar": "تقليل الضغط على الاستقبال وزيادة نسبة الحجوزات المؤكدة",
},
"education": {
"pain_ar": "استفسارات تسجيل ورسوم متكررة تأخذ وقت الإدارة",
"angle_ar": "ديلكس يجاوب على الأسئلة الشائعة ويجمع بيانات المهتمين",
"roi_ar": "تحويل الاستفسارات لتسجيلات فعلية بدل ما تضيع",
},
}
class TargetingRequest(BaseModel):
sectors: List[str] = ["real_estate", "construction", "hospitality", "logistics", "agency"]
cities: List[str] = ["الرياض", "جدة", "الدمام"]
daily_target_count: int = 50
batch_size: int = 10
approval_required: bool = True
class ComplianceCheckRequest(BaseModel):
email: str
company: str = ""
source: str = ""
opt_out: bool = False
bounced_before: bool = False
risk_score: int = 0
class EmailGenerateRequest(BaseModel):
company: str
sector: str
city: str = ""
contact_name: str = ""
pain_hypothesis: str = ""
website: str = ""
signals: List[str] = []
def _is_personal_email(email: str) -> bool:
if not email or "@" not in email:
return True
domain = email.split("@")[1].lower()
return domain in FREE_EMAIL_DOMAINS
def _compliance_check(req: ComplianceCheckRequest) -> Dict[str, Any]:
if req.opt_out:
return {"allowed": False, "reason": "opt_out", "action": "suppress"}
if req.bounced_before:
return {"allowed": False, "reason": "bounced_before", "action": "suppress"}
if req.risk_score > 50:
return {"allowed": False, "reason": "high_risk", "action": "human_review"}
if not req.email or "@" not in req.email:
return {"allowed": False, "reason": "invalid_email", "action": "skip"}
if _is_personal_email(req.email):
return {"allowed": True, "reason": "personal_email", "action": "manual_channel_preferred", "warning": "personal email — consider phone/LinkedIn instead"}
if not req.source:
return {"allowed": False, "reason": "no_source", "action": "add_source_first"}
return {"allowed": True, "reason": "compliant", "action": "send"}
def _generate_email(req: EmailGenerateRequest) -> Dict[str, Any]:
sector_info = SECTOR_PAIN_MAP.get(req.sector, SECTOR_PAIN_MAP.get("saas", {}))
pain = req.pain_hypothesis or sector_info.get("pain_ar", "تأخر الرد على العملاء المحتملين")
angle = sector_info.get("angle_ar", "ديلكس يرد بالعربي خلال 45 ثانية ويؤهل العميل")
roi = sector_info.get("roi_ar", "الرد السريع يحفظ فرص كانت بتضيع")
name_greeting = f"فريق {req.company}" if not req.contact_name else req.contact_name
city_mention = f" في {req.city}" if req.city else ""
signal_line = ""
if "hubspot" in [s.lower() for s in req.signals]:
signal_line = f"لاحظت إن {req.company} تستخدمون HubSpot — "
elif "whatsapp_widget" in [s.lower() for s in req.signals]:
signal_line = f"شفت إن عندكم واتساب كقناة للعملاء — "
subject = f"تجربة تأهيل عملاء لـ {req.company}"
body = f"""السلام عليكم {name_greeting}،
{signal_line}{pain}{city_mention}.
أنا سامي من Dealix. {angle}.
{roi}.
نقدم تجربة 7 أيام على 1025 lead مع تقرير يومي.
سعر الإطلاق لأول عملاء: 499 ريال.
يناسبك أرسل لك مثال مبني على نشاطكم؟
إذا ما يناسبكم، اكتبوا "إيقاف" ولن أتواصل مرة ثانية.
سامي العسيري
Dealix — مندوب مبيعات ذكي بالعربي
dealix.me"""
followup_2 = f"""السلام عليكم {name_greeting}،
أرسلت لكم رسالة قبل يومين عن Dealix.
باختصار: نجرب 7 أيام على leads عندكم — رد سريع + تأهيل + تقرير يومي.
يناسبك 10 دقائق هذا الأسبوع؟
calendly.com/sami-assiri11/dealix-demo
إذا ما يناسبكم، اكتبوا "إيقاف".
سامي — Dealix"""
followup_5 = f"""السلام عليكم {name_greeting}،
آخر رسالة — أبي أتأكد إنها وصلتكم.
Dealix يساعد شركات {req.sector} ترد على الاستفسارات بسرعة وتحول الجاد منها للمبيعات.
لو مناسب نتكلم، أنا متاح. لو لا، شكرًا على وقتكم ولن أتواصل مرة ثانية.
سامي — Dealix"""
call_script = f"""مرحبا، أنا سامي من Dealix.
أتصل لأن شركات في قطاع {req.sector} عادةً تستقبل استفسارات كثيرة وتضيع بعضها بسبب تأخر الرد.
Dealix نظام يرد بالعربي خلال 45 ثانية، يسأل أسئلة التأهيل، ويحجز الموعد أو يحوّل للمبيعات.
عندنا تجربة 7 أيام بـ 499 ريال.
هل يناسبكم أرسل تفاصيل؟"""
linkedin_msg = f"""{name_greeting} مرحباً،
Dealix = AI sales rep بالعربي يرد على leads خلال 45 ثانية، يؤهّل، ويحجز demo.
20 دقيقة demo نشوف مناسبته لـ {req.company}؟
calendly.com/sami-assiri11/dealix-demo
سامي — Dealix"""
return {
"company": req.company,
"sector": req.sector,
"subject_ar": subject,
"body_ar": body,
"followup_day_2": followup_2,
"followup_day_5": followup_5,
"call_script_ar": call_script,
"linkedin_manual_message": linkedin_msg,
"opt_out_included": True,
"word_count": len(body.split()),
"generated_at": datetime.now(timezone.utc).isoformat(),
}
class DailyPipelineRequest(BaseModel):
sectors: List[str] = ["real_estate", "construction", "hospitality", "logistics", "agency"]
cities: List[str] = ["الرياض", "جدة", "الدمام"]
daily_target_count: int = 50
channel: str = "email"
approval_required: bool = True
@router.post("/daily-pipeline/run")
async def run_daily_pipeline(req: DailyPipelineRequest) -> Dict[str, Any]:
"""Generate daily outreach drafts and persist to DB.
Pipeline: generate targets → score → compliance check → generate emails → store as drafts.
All drafts start with status='draft'. Sami approves before any send.
"""
batch_id = f"batch_{datetime.now(timezone.utc).strftime('%Y%m%d_%H%M')}_{str(uuid4())[:6]}"
drafts_created = []
skipped = []
for i, sector in enumerate(req.sectors):
sector_info = SECTOR_PAIN_MAP.get(sector, SECTOR_PAIN_MAP.get("saas", {}))
for j, city in enumerate(req.cities[:3]):
idx = i * 3 + j
if len(drafts_created) >= req.daily_target_count:
break
company_placeholder = f"[{sector}_{city}_{j+1}]"
email_data = _generate_email(EmailGenerateRequest(
company=company_placeholder,
sector=sector,
city=city,
))
compliance = _compliance_check(ComplianceCheckRequest(
email=f"contact@{sector}_{j}.example.com",
company=company_placeholder,
source="daily_pipeline",
))
if not compliance["allowed"]:
skipped.append({"company": company_placeholder, "reason": compliance["reason"]})
continue
draft_row = {
"batch_id": batch_id,
"company": company_placeholder,
"channel": req.channel,
"subject": email_data.get("subject_ar", ""),
"body": email_data.get("body_ar", ""),
"followup_2d": email_data.get("followup_day_2", ""),
"followup_5d": email_data.get("followup_day_5", ""),
"call_script": email_data.get("call_script_ar", ""),
"sector": sector,
"city": city,
"pain_hypothesis": sector_info.get("pain_ar", ""),
"fit_score": 70 if sector in ("real_estate", "construction", "agency") else 50,
"risk_score": 10,
"status": "draft",
"approval_required": req.approval_required,
"source": "daily_pipeline",
}
try:
from app.models.outreach_draft import OutreachDraft
from app.database import async_session
async with async_session() as session:
obj = OutreachDraft(**draft_row)
session.add(obj)
await session.commit()
draft_row["id"] = str(obj.id)
drafts_created.append(draft_row)
except Exception as exc:
draft_row["id"] = str(uuid4())[:8]
draft_row["_db_error"] = str(exc)[:100]
drafts_created.append(draft_row)
return {
"batch_id": batch_id,
"date": datetime.now(timezone.utc).strftime("%Y-%m-%d"),
"drafts_created": len(drafts_created),
"skipped": len(skipped),
"channel": req.channel,
"approval_required": req.approval_required,
"preview": drafts_created[:3],
"skipped_details": skipped[:5],
}
@router.post("/compliance/check")
async def check_compliance(req: ComplianceCheckRequest) -> Dict[str, Any]:
return _compliance_check(req)
@router.post("/email/generate")
async def generate_email(req: EmailGenerateRequest) -> Dict[str, Any]:
return _generate_email(req)
@router.post("/daily-targeting/generate")
async def generate_daily_targets(req: TargetingRequest) -> Dict[str, Any]:
targets = []
for i, sector in enumerate(req.sectors):
sector_info = SECTOR_PAIN_MAP.get(sector, {})
for j, city in enumerate(req.cities[:3]):
idx = i * 3 + j
if idx >= req.daily_target_count:
break
target = {
"id": str(uuid4())[:8],
"company": f"[{sector}_{city}_{j+1}]",
"sector": sector,
"city": city,
"pain_hypothesis": sector_info.get("pain_ar", ""),
"angle": sector_info.get("angle_ar", ""),
"roi_estimate": sector_info.get("roi_ar", ""),
"priority": "P0" if sector in ("real_estate", "construction", "agency") else "P1",
"channel": "email",
"approval_required": req.approval_required,
"status": "ready",
}
targets.append(target)
return {
"date": datetime.now(timezone.utc).strftime("%Y-%m-%d"),
"total_generated": len(targets),
"sectors": req.sectors,
"cities": req.cities,
"batch_size": req.batch_size,
"approval_required": req.approval_required,
"targets": targets[:req.daily_target_count],
}
REPLY_CATEGORIES = {
"interested": {"next": "propose_demo", "auto_reply": True},
"ask_price": {"next": "explain_pilot_499", "auto_reply": True},
"ask_details": {"next": "send_brief_explanation", "auto_reply": True},
"ask_demo": {"next": "send_calendly", "auto_reply": True},
"not_now": {"next": "schedule_followup_30d", "auto_reply": True},
"unsubscribe": {"next": "opt_out_suppress", "auto_reply": False},
"objection_budget": {"next": "explain_roi_pilot", "auto_reply": True},
"objection_ai": {"next": "explain_manual_first", "auto_reply": True},
"objection_privacy": {"next": "human_review", "auto_reply": False},
"already_has_crm": {"next": "position_as_layer", "auto_reply": True},
"partnership": {"next": "route_partner_flow", "auto_reply": False},
"angry": {"next": "apologize_opt_out", "auto_reply": False},
}
REPLY_RESPONSES = {
"interested": "ممتاز! يناسبك نحجز 20 دقيقة هذا الأسبوع؟\ncalendly.com/sami-assiri11/dealix-demo",
"ask_price": "نبدأها كـ pilot بسيط لمدة 7 أيام بـ 499 ريال.\nنجرب على 10-25 lead، ونقيس النتائج.\nلو ما عجبك — استرداد كامل.",
"ask_details": "Dealix يساعدكم في:\n- الرد السريع على الـ leads\n- تأهيل العميل بأسئلة واضحة\n- حجز موعد أو تحويله لفريق المبيعات\n- تقرير يومي مختصر\n\nأفضل بداية: pilot 7 أيام. يناسبك أشرح أكثر؟",
"ask_demo": "تمام! احجز الوقت المناسب:\ncalendly.com/sami-assiri11/dealix-demo\n\nأو قلي وقتين يناسبونك وأنا أنسّق.",
"not_now": "تمام، شكراً على وقتك. أتواصل معك بعد شهر لو مناسب؟",
"objection_budget": "فاهم. لذلك نبدأ بـ pilot بسيط بـ 499 ريال فقط.\nلو ما شفت قيمة خلال 7 أيام — استرداد كامل.",
"already_has_crm": "ممتاز، Dealix ما يستبدل الـ CRM.\nهو طبقة قبله: يرد على الـ lead، يأهله، يحجز الموعد،\nوبعدها يسلّم البيانات لفريقكم أو للـ CRM.",
"unsubscribe": "تم، لن أتواصل معكم مرة ثانية. شكراً على وقتكم.",
}
class ClassifyReplyRequest(BaseModel):
reply_text: str
company: str = ""
original_sector: str = ""
@router.post("/reply/classify")
async def classify_reply(req: ClassifyReplyRequest) -> Dict[str, Any]:
text = req.reply_text.lower()
if any(w in text for w in ["إيقاف", "stop", "unsubscribe", "لا تتواصل", "remove"]):
cat = "unsubscribe"
elif any(w in text for w in ["كم السعر", "كم التكلفة", "how much", "pricing", "أسعار"]):
cat = "ask_price"
elif any(w in text for w in ["عرض", "demo", "ديمو", "أوريني", "شرح"]):
cat = "ask_demo"
elif any(w in text for w in ["مهتم", "interested", "أبي أجرب", "نجرب", "تمام"]):
cat = "interested"
elif any(w in text for w in ["تفاصيل", "details", "أكثر", "وش بالضبط"]):
cat = "ask_details"
elif any(w in text for w in ["لاحقاً", "later", "مو الحين", "بعدين"]):
cat = "not_now"
elif any(w in text for w in ["ميزانية", "budget", "غالي", "مكلف"]):
cat = "objection_budget"
elif any(w in text for w in ["CRM", "crm", "نظام", "عندنا حل"]):
cat = "already_has_crm"
elif any(w in text for w in ["شراكة", "partner", "وكالة", "نبيع"]):
cat = "partnership"
elif any(w in text for w in ["خصوصية", "privacy", "بيانات", "PDPL"]):
cat = "objection_privacy"
elif any(w in text for w in ["ذكاء", "AI", "عربي طبيعي", "مضبوط"]):
cat = "objection_ai"
elif any(w in text for w in ["زعلان", "angry", "spam", "مزعج"]):
cat = "angry"
else:
cat = "ask_details"
info = REPLY_CATEGORIES.get(cat, {"next": "human_review", "auto_reply": False})
response = REPLY_RESPONSES.get(cat, "شكراً على ردك. براجع وأرد عليك قريب.")
return {
"category": cat,
"next_action": info["next"],
"auto_reply_allowed": info["auto_reply"],
"suggested_response": response,
"human_review_required": not info["auto_reply"],
"company": req.company,
}