system-prompts-and-models-o.../dealix/auto_client_acquisition/growth_operator/message_planner.py
Dealix Builder 8942c6e84c feat(growth-operator): Arabic Growth Operator — 10 modules + 16 endpoints + 50 tests
Builds the full Saudi Autonomous Revenue OS surface as 10 deterministic
modules + a 16-endpoint router under /api/v1/growth-operator/.
Approval-first: every outbound is draft. No live send / charge / calendar
insert from this layer.

MODULES (auto_client_acquisition/growth_operator/)

1. client_profile.py — ClientGrowthProfile + Saudi-default approval
   + compliance rules (no cold WhatsApp, blocked keywords, weekly cap,
   quiet_hours_riyadh)
2. contact_importer.py — normalize_phone (Saudi E.164),
   dedupe_contacts (richer-record-wins), classify_contact_source
   (existing/inbound/event/referral/old_lead/cold/unknown), detect_opt_out
   (Arabic + English markers), summarize_import (dashboard report)
3. contactability.py — score_contactability returns
   safe/needs_review/blocked with Arabic reasons; default policy:
   no cold WhatsApp without lawful basis (PDPL Art.5)
4. targeting.py — segment_contacts, rank_targets (filters unsafe),
   recommend_top_10, why_now_stub (deterministic, sector-aware)
5. message_planner.py — draft_arabic_message (Saudi tone, 4-sector
   opener bank, no overhyped phrases, always pending_approval),
   draft_followup (4 outcome modes), draft_objection_response
   (6 indexed Saudi B2B objections with score_delta + next_action)
6. partnership_planner.py — 6 partner types catalog
   (agency / consultant / integrator / crm / community / influencer)
   + suggest_partner_types (size/sector aware) + draft_partner_outreach
   + partner_scorecard (platinum/gold/silver/bronze)
7. meeting_planner.py — build_meeting_agenda (15/20-30/45+ min slot
   plans), build_calendar_draft (Google Calendar shape, live_inserted=False,
   conferenceData for Meet, Asia/Riyadh timezone), build_post_meeting_followup
8. payment_offer.py — sar_to_halalas, build_moyasar_payment_link_draft
   (full payload + in-chat message + 4-plan catalog, live_charged=False)
9. proof_pack.py — build_weekly_proof_pack with grade A+/A/B/C/D,
   activity/money/quality/best-of sections, dynamic next_week_plan_ar,
   markdown export
10. mission_planner.py — 6 GROWTH_MISSIONS (first_10_opportunities 
    kill feature, recover_stalled_deals, partnership_sprint,
    safe_whatsapp_campaign, meeting_booking_sprint, list_cleanup);
    list_missions() + run_mission()

ROUTER (api/routers/growth_operator.py) — 16 endpoints

POST /contacts/import-preview · POST /contactability/score
POST /targets/top-10 · POST /messages/draft · POST /messages/followup
POST /messages/objection-response · POST /partners/suggest
POST /partners/outreach · POST /partners/scorecard
POST /meetings/draft · POST /meetings/post-followup
POST /payment-offer/draft · GET /missions · POST /missions/{id}/run
GET /proof-pack/demo · POST /profile

WIRING: api/main.py adds growth_operator import + router include
(positioned after personal_operator, before public).

DOCS

- docs/ARABIC_GROWTH_OPERATOR_FULL_SPEC.md (NEW): 20-section vision +
  customer-type table + upload flow + contactability rules +
  WhatsApp/Gmail/Calendar/Moyasar drafts + 6 missions + 16-endpoint
  catalog + competitive comparison + beta readiness checklist

TESTS — 50 passing on Python 3.10 venv

tests/unit/test_growth_operator.py covers:
- Phone normalization across 5 input formats including invalid
- Dedupe richer-record invariant
- Source classification (existing/inbound/event/cold/unknown)
- Opt-out detection (Arabic + English notes + status)
- Import summary aggregation
- Contactability: opt-out blocked, cold WhatsApp blocked,
  unknown→needs_review, existing→safe, inbound→safe
- Bulk contactability summary
- Top-10 filtering (unsafe excluded), max-cap enforcement
- Segment buckets
- Arabic message: pending_approval invariant + Arabic content
  + no overhyped phrases (banned list)
- Followup approval invariant
- Objection response: known + unknown→diagnostic
- Partner suggestions size-aware (SMB→agency/consultant/community)
- Partner outreach approval invariant
- Partner unknown type returns error
- Partner scorecard tier ordering
- Meeting agenda + calendar draft (live_inserted=False) +
  Asia/Riyadh timezone + post-followup pending
- Payment: halalas conversion (1 SAR=100), negative raises,
  draft NEVER charges (live_charged=False), unknown plan→error
- Proof pack: grade range + structure + markdown export
- Missions: first_10_opportunities present + kill feature ID
  + run mission known/unknown
- Profile: demo specialized + partial not specialized
  + default compliance blocks 'ضمان 100' + no_cold_whatsapp_without_lawful_basis

VERIFICATION
- 527 unit tests pass (was 477; +50 growth_operator)
- 2 skipped (provider smoke needs API keys)
- AST green on all 13 new files
- Approval invariant holds across every drafting function

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 15:33:11 +03:00

266 lines
11 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.

"""
Saudi Message Engine — Arabic drafts that don't sound like spam.
Style rules (encoded in templates):
- short (≤4 sentences for first message)
- non-exaggerated (no "ضمان 100%", no "نتائج مضمونة")
- explicit reason for outreach (not generic)
- simple ask (one CTA, low-commitment)
- sector-aware tone
- approval_required = True ALWAYS
"""
from __future__ import annotations
import hashlib
from typing import Any
# ── Saudi B2B opening line bank — sector-aware ──────────────────
_OPENERS_BY_SECTOR_AR: dict[str, list[str]] = {
"real_estate": [
"السلام عليكم أستاذ {name}،\nلاحظت أنكم تتوسعون في {city}.",
"مرحباً أستاذ {name}،\nمتابع نشاطكم في تطوير العقار في {city}.",
],
"clinics": [
"السلام عليكم دكتور {name}،\nشاهدت تطور خدمات العيادة في {city}.",
"مرحباً دكتور {name}،\nأقدر اهتمامكم بتجربة المرضى في {city}.",
],
"logistics": [
"السلام عليكم أستاذ {name}،\nلاحظت توسعكم في خدمات الشحن في {city}.",
"مرحباً أستاذ {name}،\nقطاع اللوجستيات في {city} يتحرك بسرعة.",
],
"training": [
"السلام عليكم أستاذ {name}،\nمتابع أثر برامجكم التدريبية في {city}.",
"مرحباً أستاذ {name}،\nالطلب على التدريب الـ B2B يتزايد في {city}.",
],
"default": [
"السلام عليكم أستاذ {name}،\nمتابع نشاطكم في {city}.",
"مرحباً أستاذ {name}،\nلاحظت تطوركم في {city}.",
],
}
# A single short reason + ask combo. Keep under 4 sentences total.
_REASON_TEMPLATES_AR: dict[str, str] = {
"existing_customer": "باعتبار العلاقة القائمة معكم، عندي اقتراح سريع يخدم {goal}.",
"inbound_lead": "بناءً على اهتمامكم الأخير، عندي خطوة واضحة لتسريع {goal}.",
"referral": "وصلتني توصية مهنية للتواصل معكم بخصوص {goal}.",
"event_lead": "بعد لقائنا الأخير، حضّرت اقتراح صغير يخدم {goal}.",
"old_lead": "بمناسبة الموسم الجديد، عندي تحديث يهم {goal}.",
"unknown": "بعد البحث في خدماتكم، عندي فرضية صغيرة تخدم {goal}.",
"cold_list": "بعد البحث في خدماتكم، عندي فرضية صغيرة تخدم {goal}.",
}
_ASK_TEMPLATES_AR: list[str] = [
"يناسبك أرسل لك مثال سريع؟",
"هل ١٥ دقيقة الأسبوع الجاي مناسبة لمشاركة الفكرة؟",
"تفضّل أرسل ملخص بصفحة واحدة أو نتفق على مكالمة قصيرة؟",
]
def _pick(seq: list[str], seed: str) -> str:
"""Deterministic choice — same seed → same pick."""
if not seq:
return ""
h = hashlib.md5(seed.encode("utf-8")).digest()
return seq[h[0] % len(seq)]
def _resolve_name(contact: dict[str, Any]) -> str:
n = (contact.get("name") or "").strip()
if not n:
return "الفاضل"
parts = n.split()
return parts[0] if parts else n
def _resolve_city(contact: dict[str, Any], default: str = "السعودية") -> str:
return (contact.get("city") or default).strip()
def _resolve_sector(contact: dict[str, Any], default: str = "default") -> str:
s = (contact.get("sector") or default).lower().strip()
return s if s in _OPENERS_BY_SECTOR_AR else "default"
# ── Public API ──────────────────────────────────────────────────
def draft_arabic_message(
contact: dict[str, Any],
*,
profile: dict[str, Any] | None = None,
source: str | None = None,
goal_ar: str = "تشغيل نمو B2B بلا إرسال عشوائي",
) -> dict[str, Any]:
"""
Build a Saudi-tone Arabic outreach draft.
- profile: optional ClientGrowthProfile.to_dict() for offer context
- source: classify_contact_source override; auto-derived if None
"""
from auto_client_acquisition.growth_operator.contact_importer import (
classify_contact_source,
)
src = source or classify_contact_source(contact)
name = _resolve_name(contact)
city = _resolve_city(contact)
sector = _resolve_sector(contact)
seed = f"{contact.get('phone','')}{contact.get('name','')}{src}"
opener = _pick(_OPENERS_BY_SECTOR_AR[sector], seed).format(name=name, city=city)
reason = _REASON_TEMPLATES_AR.get(src, _REASON_TEMPLATES_AR["unknown"]).format(goal=goal_ar)
ask = _pick(_ASK_TEMPLATES_AR, seed + "ask")
offer_line = ""
if profile and profile.get("offer_one_liner"):
offer_line = f"\n\nنحن: {profile['offer_one_liner']}."
body_ar = f"{opener}\n\n{reason}{offer_line}\n\n{ask}"
return {
"channel_recommendation": "whatsapp" if contact.get("phone") else "email",
"subject_ar": None,
"body_ar": body_ar,
"source_classification": src,
"approval_required": True,
"approval_status": "pending_approval",
"guardrails_ar": [
"لا تُرسل قبل موافقة المشغّل.",
"لا تستخدم في WhatsApp البارد بدون lawful basis.",
"احذف أي مبالغة قبل الإرسال.",
],
"estimated_length_chars": len(body_ar),
}
def draft_followup(
contact: dict[str, Any],
*,
days_since_last: int,
last_outcome: str = "no_reply",
) -> dict[str, Any]:
"""Short follow-up draft based on last outcome."""
name = _resolve_name(contact)
seed = f"f{contact.get('phone','')}{last_outcome}{days_since_last}"
if last_outcome == "no_reply" and days_since_last <= 3:
body = (
f"السلام عليكم أستاذ {name}،\n\n"
"أعرف أن جدولكم مزدحم. لو الفكرة لا تناسب الآن، أقدر أرسل ملخص "
"بصفحة واحدة تراجعونه على راحتكم. هل أرسل؟"
)
elif last_outcome == "no_reply":
body = (
f"السلام عليكم أستاذ {name}،\n\n"
f"مر {days_since_last} يوم على رسالتي السابقة. لو لا يناسب الآن، "
"أقدر أعود في التوقيت الأنسب لكم — متى يناسب؟"
)
elif last_outcome == "objection":
body = (
f"شكراً أستاذ {name} على وضوحكم. "
"بناءً على ما ذكرتم، حضّرت توضيح مختصر يجاوب على نقطتكم تحديداً. "
"هل أرسل؟"
)
elif last_outcome == "positive":
body = (
f"شكراً أستاذ {name}. "
"أحجز ١٥ دقيقة هذا الأسبوع لمناقشة الخطوة التالية — متى يناسبك؟"
)
else:
body = (
f"السلام عليكم أستاذ {name}،\n\n"
f"تابعت معكم سابقاً. لو فيه تحديث، يسعدني أعرف."
)
return {
"body_ar": body,
"purpose": f"followup_{last_outcome}_d{days_since_last}",
"approval_required": True,
"approval_status": "pending_approval",
}
# ── Objection-to-Action library ─────────────────────────────────
_OBJECTION_RESPONSES_AR: dict[str, dict[str, Any]] = {
"send_offer_whatsapp": {
"interpretation_ar": "اهتمام متوسط — ليس إغلاق، لكن مفتوح للمعلومات.",
"response_ar": (
"تمام، أرسل خلال دقائق ملف صفحتين بالعربي + voice note قصير "
"يشرح أهم ٣ نقاط. ثم نتفق على متابعة بعد يومين."
),
"next_action": "send_pdf_then_followup_in_2d",
"score_delta": +5,
},
"after_eid": {
"interpretation_ar": "تأجيل ثقافي مفهوم — احترم التوقيت السعودي.",
"response_ar": (
"إن شاء الله. أسجل تذكير لـ بعد العيد بأسبوع، وأرسل لكم Pulse "
"الشهري حتى ذلك الحين. كل عام وأنتم بخير."
),
"next_action": "schedule_post_eid_followup",
"score_delta": +1,
},
"talk_to_partner": {
"interpretation_ar": "stakeholder جديد — يحتاج intro + ملف موجز.",
"response_ar": (
"محترم — أحضّر لكم ملف من صفحتين بالعربي مهيأ للعرض على الشريك. "
"هل أرسله مباشرة لكم أو نعمل اجتماع ثلاثي قصير؟"
),
"next_action": "arm_champion_with_2page_brief",
"score_delta": +3,
},
"price_high": {
"interpretation_ar": "اعتراض قيمة — يحتاج ROI breakdown، ليس خصم.",
"response_ar": (
"حقكم تركّزون على القيمة. أرسل ROI breakdown يوضح تكلفة الـ lead "
"المؤهل لدينا مقارنة بالبدائل. توافقون؟"
),
"next_action": "send_roi_breakdown",
"score_delta": +5,
},
"have_vendor": {
"interpretation_ar": "منافس قائم — اسأل عن الفجوة الفعلية.",
"response_ar": (
"ممتاز — مع مَن؟ والسؤال المهم: هل الـ leads مؤهلة فعلاً أم form fills؟ "
"إن فيه فجوة، نقدر نكمل وليس نستبدل. مجاناً نعمل audit."
),
"next_action": "offer_free_audit_position_as_complement",
"score_delta": +2,
},
"no_need": {
"interpretation_ar": "رفض/توقيت — الأنسب nurture بدون ضغط.",
"response_ar": (
"متفهم تماماً. نسجلكم في Pulse الشهري المجاني، ونعود حين تتغير "
"الأولويات. شاكرين وقتكم."
),
"next_action": "nurture_via_monthly_pulse",
"score_delta": -2,
},
}
def draft_objection_response(
objection_id: str,
*,
contact: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Look up an objection and return a Saudi-toned response draft."""
obj = _OBJECTION_RESPONSES_AR.get(objection_id)
if obj is None:
return {
"objection_id": objection_id,
"interpretation_ar": "اعتراض غير مصنّف — يحتاج تشخيص يدوي.",
"response_ar": (
"شكراً على وضوحكم. ممكن تشاركوني السبب الرئيسي حتى أعطيكم "
"إجابة مناسبة؟"
),
"next_action": "diagnostic_question",
"score_delta": 0,
"approval_required": True,
"approval_status": "pending_approval",
}
return {
"objection_id": objection_id,
**obj,
"approval_required": True,
"approval_status": "pending_approval",
}