diff --git a/dealix/api/main.py b/dealix/api/main.py index 38a0cd5a..bced2114 100644 --- a/dealix/api/main.py +++ b/dealix/api/main.py @@ -27,6 +27,7 @@ from api.routers import ( ecosystem, email_send, full_os, + growth_operator, health, innovation, leads, @@ -146,6 +147,7 @@ def create_app() -> FastAPI: app.include_router(innovation.router) app.include_router(business.router) app.include_router(personal_operator.router) + app.include_router(growth_operator.router) app.include_router(public.router) app.include_router(admin.router) diff --git a/dealix/api/routers/growth_operator.py b/dealix/api/routers/growth_operator.py new file mode 100644 index 00000000..605bbc95 --- /dev/null +++ b/dealix/api/routers/growth_operator.py @@ -0,0 +1,260 @@ +""" +Growth Operator router — Arabic Growth Operator endpoints. + +Approval-first: every outbound is draft. Nothing is sent / charged / +scheduled live from this router; that happens in dedicated send / billing +/ calendar services after explicit user approval. +""" + +from __future__ import annotations + +import logging +from typing import Any + +from fastapi import APIRouter, Body, Query + +from auto_client_acquisition.growth_operator import ( + build_calendar_draft, + build_meeting_agenda, + build_moyasar_payment_link_draft, + build_post_meeting_followup, + build_weekly_proof_pack, + contactability_summary, + dedupe_contacts, + draft_arabic_message, + draft_followup, + draft_objection_response, + draft_partner_outreach, + list_missions, + partner_scorecard, + profile_from_dict, + recommend_top_10, + run_mission, + score_contactability, + suggest_partner_types, + summarize_import, +) + +router = APIRouter(prefix="/api/v1/growth-operator", tags=["growth-operator"]) +log = logging.getLogger(__name__) + + +# ── 1. Contacts: import preview ───────────────────────────────── +@router.post("/contacts/import-preview") +async def contacts_import_preview( + contacts: list[dict[str, Any]] = Body(default_factory=list, embed=True), + channel: str = Body(default="whatsapp", embed=True), +) -> dict[str, Any]: + """Preview import: dedupe + source classify + contactability summary.""" + deduped = dedupe_contacts(contacts) + return { + "import_summary": summarize_import(contacts), + "contactability": contactability_summary(deduped, channel=channel), + "policy_note_ar": ( + "العميل يرفع أرقام مملوكة/مصرح بها. لا cold WhatsApp بدون lawful basis." + ), + "approval_required": True, + "approval_status": "pending_approval", + } + + +# ── 2. Targeting: top-10 ──────────────────────────────────────── +@router.post("/targets/top-10") +async def targets_top_10( + contacts: list[dict[str, Any]] = Body(default_factory=list, embed=True), + sector_hint: str = Body(default="", embed=True), + channel: str = Body(default="whatsapp", embed=True), +) -> dict[str, Any]: + """Rank uploaded contacts → top-10 safe + Why-Now.""" + return recommend_top_10(contacts, sector_hint=sector_hint, channel=channel) + + +# ── 3. Messages: draft / followup / objection ────────────────── +@router.post("/messages/draft") +async def messages_draft( + contact: dict[str, Any] = Body(..., embed=True), + profile: dict[str, Any] | None = Body(default=None, embed=True), + goal_ar: str = Body(default="تشغيل نمو B2B بلا إرسال عشوائي", embed=True), +) -> dict[str, Any]: + """Saudi-tone Arabic outreach draft (always pending_approval).""" + return draft_arabic_message(contact, profile=profile, goal_ar=goal_ar) + + +@router.post("/messages/followup") +async def messages_followup( + contact: dict[str, Any] = Body(..., embed=True), + days_since_last: int = Body(default=3, embed=True), + last_outcome: str = Body(default="no_reply", embed=True), +) -> dict[str, Any]: + return draft_followup( + contact, days_since_last=days_since_last, last_outcome=last_outcome, + ) + + +@router.post("/messages/objection-response") +async def messages_objection_response( + objection_id: str = Body(..., embed=True), + contact: dict[str, Any] | None = Body(default=None, embed=True), +) -> dict[str, Any]: + return draft_objection_response(objection_id, contact=contact) + + +# ── 4. Partners: suggest / outreach / scorecard ──────────────── +@router.post("/partners/suggest") +async def partners_suggest( + sector: str = Body(default="", embed=True), + customer_size: str = Body(default="smb", embed=True), +) -> dict[str, Any]: + return suggest_partner_types(sector=sector, customer_size=customer_size) + + +@router.post("/partners/outreach") +async def partners_outreach( + partner_type_key: str = Body(..., embed=True), + partner_name: str = Body(default="", embed=True), + customer_name: str = Body(default="Dealix", embed=True), +) -> dict[str, Any]: + return draft_partner_outreach( + partner_type_key=partner_type_key, + partner_name=partner_name, + customer_name=customer_name, + ) + + +@router.post("/partners/scorecard") +async def partners_scorecard(payload: dict[str, Any] = Body(...)) -> dict[str, Any]: + return partner_scorecard( + partner_id=payload.get("partner_id", "unknown"), + intros_made=int(payload.get("intros_made", 0)), + deals_influenced=int(payload.get("deals_influenced", 0)), + revenue_share_paid_sar=float(payload.get("revenue_share_paid_sar", 0)), + relationship_age_months=int(payload.get("relationship_age_months", 0)), + ) + + +# ── 5. Meetings: agenda / calendar draft / followup ──────────── +@router.post("/meetings/draft") +async def meetings_draft( + contact_name: str = Body(..., embed=True), + company: str = Body(..., embed=True), + contact_email: str | None = Body(default=None, embed=True), + purpose_ar: str = Body(default="اكتشاف وتأهيل أولي", embed=True), + duration_minutes: int = Body(default=20, embed=True), + proposed_start_iso: str | None = Body(default=None, embed=True), +) -> dict[str, Any]: + """Build agenda + calendar draft (NOT created live).""" + agenda = build_meeting_agenda( + contact_name=contact_name, + company=company, + purpose_ar=purpose_ar, + duration_minutes=duration_minutes, + ) + cal_draft = build_calendar_draft( + contact_email=contact_email, + contact_name=contact_name, + company=company, + proposed_start_iso=proposed_start_iso, + duration_minutes=duration_minutes, + ) + return {"agenda": agenda, "calendar_draft": cal_draft} + + +@router.post("/meetings/post-followup") +async def meetings_post_followup( + contact_name: str = Body(..., embed=True), + company: str = Body(..., embed=True), + summary_ar: str = Body(..., embed=True), + next_step_ar: str = Body(default="أرسل recap + pilot offer", embed=True), +) -> dict[str, Any]: + return build_post_meeting_followup( + contact_name=contact_name, + company=company, + summary_ar=summary_ar, + next_step_ar=next_step_ar, + ) + + +# ── 6. Payment offer (Moyasar payment-link draft) ───────────── +@router.post("/payment-offer/draft") +async def payment_offer_draft( + plan_key: str = Body(..., embed=True), + customer_id: str = Body(..., embed=True), + contact_email: str | None = Body(default=None, embed=True), + custom_amount_sar: float | None = Body(default=None, embed=True), +) -> dict[str, Any]: + return build_moyasar_payment_link_draft( + plan_key=plan_key, + customer_id=customer_id, + contact_email=contact_email, + custom_amount_sar=custom_amount_sar, + ) + + +# ── 7. Missions ──────────────────────────────────────────────── +@router.get("/missions") +async def missions_list() -> dict[str, Any]: + return list_missions() + + +@router.post("/missions/{mission_id}/run") +async def missions_run( + mission_id: str, + payload: dict[str, Any] = Body(default_factory=dict), +) -> dict[str, Any]: + return run_mission(mission_id, payload=payload) + + +# ── 8. Proof Pack demo ───────────────────────────────────────── +@router.get("/proof-pack/demo") +async def proof_pack_demo( + customer_id: str = Query(default="demo"), + customer_name: str = Query(default="Demo Saudi B2B Co."), +) -> dict[str, Any]: + return build_weekly_proof_pack( + customer_id=customer_id, + customer_name=customer_name, + week_label="W18-2026", + plan_cost_weekly_sar=750, + opportunities_discovered=42, + messages_drafted=38, + messages_approved=33, + messages_sent=33, + replies_received=11, + positive_replies=4, + meetings_booked=3, + meetings_held=2, + proposals_sent=1, + deals_won=0, + pipeline_added_sar=185_000, + revenue_won_sar=0, + risky_drafts_blocked=5, + revenue_leaks_recovered=2, + avg_response_minutes=42, + best_message_subject="ملاحظة على توسعكم في الرياض", + best_message_reply_rate=0.18, + ) + + +# ── 9. Single-contact contactability ───────────────────────── +@router.post("/contactability/score") +async def contactability_score_single( + contact: dict[str, Any] = Body(..., embed=True), + channel: str = Body(default="whatsapp", embed=True), +) -> dict[str, Any]: + return score_contactability(contact, channel=channel) + + +# ── 10. Profile ──────────────────────────────────────────────── +@router.post("/profile") +async def profile_set( + profile: dict[str, Any] = Body(..., embed=True), +) -> dict[str, Any]: + p = profile_from_dict(profile) + return { + "profile": p.to_dict(), + "is_specialized": p.is_specialized(), + "missing_fields_ar": ( + [] if p.is_specialized() else + ["sector", "city", "offer_one_liner", "ideal_customer"] + ), + } diff --git a/dealix/auto_client_acquisition/growth_operator/__init__.py b/dealix/auto_client_acquisition/growth_operator/__init__.py new file mode 100644 index 00000000..5632fe0c --- /dev/null +++ b/dealix/auto_client_acquisition/growth_operator/__init__.py @@ -0,0 +1,96 @@ +""" +Arabic Growth Operator — Dealix's customer-facing growth-execution layer. + +This package bundles the building blocks for the operator experience: + - client_profile : Saudi B2B Growth Profile per customer + - contact_importer : safe upload + normalize + classify uploaded numbers + - contactability : per-contact "can we contact?" decision + - targeting : segmenting + ranking + Top-10 with Why-Now stub + - message_planner : Arabic drafts + follow-ups + objection responses + - partnership_planner : partner suggestions + outreach drafts + scorecard + - meeting_planner : agenda + calendar draft + post-meeting follow-up + - payment_offer : Moyasar payment-link draft (no charge) + - proof_pack : weekly evidence pack with revenue + risk metrics + - mission_planner : Growth Missions (10-in-10, recover-stalled, etc.) + +DESIGN INVARIANTS + - draft-only by default; nothing is sent / charged / scheduled live + - every outbound has approval_required=True + - PDPL: no cold WhatsApp without lawful basis; uploads classified safely + - deterministic: same input → same output (testable without external APIs) +""" + +from auto_client_acquisition.growth_operator.client_profile import ( + ClientGrowthProfile, + build_demo_profile, + profile_from_dict, +) +from auto_client_acquisition.growth_operator.contact_importer import ( + classify_contact_source, + dedupe_contacts, + detect_opt_out, + normalize_phone, + summarize_import, +) +from auto_client_acquisition.growth_operator.contactability import ( + CONTACTABILITY_LABELS, + contactability_summary, + score_contactability, +) +from auto_client_acquisition.growth_operator.message_planner import ( + draft_arabic_message, + draft_followup, + draft_objection_response, +) +from auto_client_acquisition.growth_operator.meeting_planner import ( + build_calendar_draft, + build_meeting_agenda, + build_post_meeting_followup, +) +from auto_client_acquisition.growth_operator.mission_planner import ( + GROWTH_MISSIONS, + list_missions, + run_mission, +) +from auto_client_acquisition.growth_operator.partnership_planner import ( + draft_partner_outreach, + partner_scorecard, + suggest_partner_types, +) +from auto_client_acquisition.growth_operator.payment_offer import ( + build_moyasar_payment_link_draft, + sar_to_halalas, +) +from auto_client_acquisition.growth_operator.proof_pack import ( + build_weekly_proof_pack, +) +from auto_client_acquisition.growth_operator.targeting import ( + rank_targets, + recommend_top_10, + segment_contacts, + why_now_stub, +) + +__all__ = [ + # client_profile + "ClientGrowthProfile", "build_demo_profile", "profile_from_dict", + # contact_importer + "normalize_phone", "dedupe_contacts", "classify_contact_source", + "detect_opt_out", "summarize_import", + # contactability + "CONTACTABILITY_LABELS", "score_contactability", "contactability_summary", + # targeting + "segment_contacts", "rank_targets", "recommend_top_10", "why_now_stub", + # message_planner + "draft_arabic_message", "draft_followup", "draft_objection_response", + # partnership_planner + "suggest_partner_types", "draft_partner_outreach", "partner_scorecard", + # meeting_planner + "build_meeting_agenda", "build_calendar_draft", "build_post_meeting_followup", + # payment_offer + "build_moyasar_payment_link_draft", "sar_to_halalas", + # proof_pack + "build_weekly_proof_pack", + # mission_planner + "GROWTH_MISSIONS", "list_missions", "run_mission", +] diff --git a/dealix/auto_client_acquisition/growth_operator/client_profile.py b/dealix/auto_client_acquisition/growth_operator/client_profile.py new file mode 100644 index 00000000..e3cf386e --- /dev/null +++ b/dealix/auto_client_acquisition/growth_operator/client_profile.py @@ -0,0 +1,116 @@ +""" +Client Growth Profile — the per-customer config that turns Dealix from a +generic operator into a specialized one. + +Without this profile every agent works on a generic prompt; with it, +every draft, every Why-Now, and every recommendation is grounded in: +the customer's offer, ICP, sales cycle, channels, objection history, +approval rules, and compliance constraints. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + + +@dataclass +class ClientGrowthProfile: + """Per-customer growth context fed to every agent decision.""" + + customer_id: str + company_name: str + sector: str + city: str + offer_one_liner: str + ideal_customer: str + average_deal_size_sar: float = 0.0 + current_channels: tuple[str, ...] = () # e.g. ("whatsapp", "email") + sales_cycle_days: int = 30 + common_objections: tuple[str, ...] = () + approval_rules: dict[str, Any] = field(default_factory=dict) + compliance_rules: dict[str, Any] = field(default_factory=dict) + + def to_dict(self) -> dict[str, Any]: + return { + "customer_id": self.customer_id, + "company_name": self.company_name, + "sector": self.sector, + "city": self.city, + "offer_one_liner": self.offer_one_liner, + "ideal_customer": self.ideal_customer, + "average_deal_size_sar": self.average_deal_size_sar, + "current_channels": list(self.current_channels), + "sales_cycle_days": self.sales_cycle_days, + "common_objections": list(self.common_objections), + "approval_rules": self.approval_rules, + "compliance_rules": self.compliance_rules, + } + + def is_specialized(self) -> bool: + """A profile becomes 'specialized' once the minimum context is set.""" + return all([ + self.sector, + self.city, + self.offer_one_liner, + self.ideal_customer, + ]) + + +# Sane defaults reflecting Saudi B2B norms — used until customer overrides. +_DEFAULT_APPROVAL_RULES: dict[str, Any] = { + "require_human_for_first_send": True, + "require_human_for_high_value_deals_above_sar": 100_000, + "max_consecutive_followups": 3, + "quiet_hours_riyadh": [21, 8], # no outbound 9pm-8am Riyadh + "blocked_dates": [], +} +_DEFAULT_COMPLIANCE_RULES: dict[str, Any] = { + "no_cold_whatsapp_without_lawful_basis": True, + "require_unsubscribe_in_email": True, + "blocked_keywords": ["ضمان 100", "نتائج مضمونة", "رقم الهوية", "iban"], + "weekly_message_cap_per_contact": 2, + "min_cohort_for_benchmarks": 5, +} + + +def profile_from_dict(data: dict[str, Any]) -> ClientGrowthProfile: + """Build a profile from a dict; missing optional fields fall back to defaults.""" + return ClientGrowthProfile( + customer_id=str(data.get("customer_id") or ""), + company_name=str(data.get("company_name") or ""), + sector=str(data.get("sector") or "").lower().strip(), + city=str(data.get("city") or "").strip(), + offer_one_liner=str(data.get("offer_one_liner") or "").strip(), + ideal_customer=str(data.get("ideal_customer") or "").strip(), + average_deal_size_sar=float(data.get("average_deal_size_sar") or 0), + current_channels=tuple(data.get("current_channels") or ()), + sales_cycle_days=int(data.get("sales_cycle_days") or 30), + common_objections=tuple(data.get("common_objections") or ()), + approval_rules=data.get("approval_rules") or dict(_DEFAULT_APPROVAL_RULES), + compliance_rules=data.get("compliance_rules") or dict(_DEFAULT_COMPLIANCE_RULES), + ) + + +def build_demo_profile(*, customer_id: str = "demo") -> ClientGrowthProfile: + """Deterministic demo profile — used in /docs and test fixtures.""" + return ClientGrowthProfile( + customer_id=customer_id, + company_name="Demo Saudi B2B Co.", + sector="real_estate", + city="الرياض", + offer_one_liner="منصة سعودية لتشغيل الإيرادات + اكتشاف فرص B2B", + ideal_customer="شركات تطوير عقاري متوسطة، 50-200 موظف، مهتمة بـ pre-sales pipeline", + average_deal_size_sar=85_000, + current_channels=("whatsapp", "email"), + sales_cycle_days=45, + common_objections=( + "السعر عالي", + "كلم الشريك", + "بعد العيد", + "وش يضمن النتائج؟", + "أرسل العرض واتساب", + ), + approval_rules=dict(_DEFAULT_APPROVAL_RULES), + compliance_rules=dict(_DEFAULT_COMPLIANCE_RULES), + ) diff --git a/dealix/auto_client_acquisition/growth_operator/contact_importer.py b/dealix/auto_client_acquisition/growth_operator/contact_importer.py new file mode 100644 index 00000000..5d400053 --- /dev/null +++ b/dealix/auto_client_acquisition/growth_operator/contact_importer.py @@ -0,0 +1,162 @@ +""" +Contact Importer — safely intake uploaded customer phone/email lists. + +Steps: + 1. normalize_phone — Saudi-friendly E.164 normalizer + 2. dedupe_contacts — drop exact phone duplicates (keep richest record) + 3. classify_contact_source — existing / lead / inbound / event / cold / unknown + 4. detect_opt_out — flags contacts marked as opted-out / blocked + 5. summarize_import — top-level report ready for the dashboard +""" + +from __future__ import annotations + +import re +from typing import Any + +# ── Phone normalization ────────────────────────────────────────── +_DIGITS_RE = re.compile(r"\D+") + + +def normalize_phone(raw: str | None) -> str: + """ + Normalize Saudi phone numbers to E.164-like form starting with 966. + + Accepts: +966500000001, 0500000001, 500000001, 00966500000001, + +966 (50) 000-0001, etc. + Returns: bare digits (e.g. "966500000001") or "" if invalid. + """ + if not raw: + return "" + s = _DIGITS_RE.sub("", str(raw)) + if not s: + return "" + # Strip leading 00 (international prefix) + if s.startswith("00"): + s = s[2:] + # Already starts with 966 + if s.startswith("966") and len(s) == 12: + return s + # Local 0-prefixed (e.g. 0512345678) + if s.startswith("0") and len(s) == 10: + return "966" + s[1:] + # Bare 9-digit local mobile (e.g. 512345678) + if len(s) == 9 and s.startswith("5"): + return "966" + s + # Already bare with country code but no leading + + if len(s) == 12 and s.startswith("966"): + return s + return s if 10 <= len(s) <= 15 else "" + + +# ── Dedup ──────────────────────────────────────────────────────── +def dedupe_contacts(contacts: list[dict[str, Any]]) -> list[dict[str, Any]]: + """ + Drop exact phone duplicates. When two records share a phone, keep the one + with more non-empty fields (richer record). + """ + seen: dict[str, dict[str, Any]] = {} + for c in contacts: + phone = normalize_phone(c.get("phone", "")) + if not phone: + # Records with no phone are kept as-is, keyed by name+email + key = f"name:{c.get('name','').strip().lower()}|email:{c.get('email','').strip().lower()}" + if key not in seen: + seen[key] = c + continue + c_norm = dict(c) + c_norm["phone"] = phone + existing = seen.get(phone) + if existing is None: + seen[phone] = c_norm + else: + existing_filled = sum(1 for v in existing.values() if v) + new_filled = sum(1 for v in c_norm.values() if v) + if new_filled > existing_filled: + seen[phone] = c_norm + return list(seen.values()) + + +# ── Source classification ──────────────────────────────────────── +SOURCE_LABELS: tuple[str, ...] = ( + "existing_customer", + "old_lead", + "inbound_lead", + "event_lead", + "cold_list", + "referral", + "unknown", +) + + +def classify_contact_source(contact: dict[str, Any]) -> str: + """Classify a contact's source. Conservative: unknown by default.""" + src = str(contact.get("source", "")).lower().strip() + rel = str(contact.get("relationship_status", "")).lower().strip() + last = contact.get("last_contacted_at") + + if rel in ("existing", "customer", "client", "active") or src in ( + "existing_customer", "customer", "active_customer", + ): + return "existing_customer" + if rel in ("inbound", "form_submission") or src in ( + "inbound", "website_form", "form_submission", + ): + return "inbound_lead" + if src in ("event", "exhibition", "conference", "trade_show"): + return "event_lead" + if src in ("referral", "introduction"): + return "referral" + if rel in ("lead", "prospect") or last: + return "old_lead" + if src in ("cold", "scraped", "purchased_list"): + return "cold_list" + return "unknown" + + +# ── Opt-out detection ──────────────────────────────────────────── +_OPT_OUT_TOKENS = { + "opt_out", "opted_out", "unsubscribed", "blocked", "do_not_contact", + "stop", "remove", "إلغاء", "اشتراك", "ايقاف", "إيقاف", +} + + +def detect_opt_out(contact: dict[str, Any]) -> bool: + """Return True if the record is flagged as opted-out / blocked.""" + flag = str(contact.get("opt_in_status", "")).lower().strip() + if flag in _OPT_OUT_TOKENS: + return True + if str(contact.get("status", "")).lower() in _OPT_OUT_TOKENS: + return True + notes = str(contact.get("notes", "")).lower() + if any(tok in notes for tok in _OPT_OUT_TOKENS): + return True + return False + + +# ── Summary ────────────────────────────────────────────────────── +def summarize_import(contacts: list[dict[str, Any]]) -> dict[str, Any]: + """Top-level report for the upload dashboard. Pure function.""" + total = len(contacts) + deduped = dedupe_contacts(contacts) + by_source: dict[str, int] = {label: 0 for label in SOURCE_LABELS} + opt_out_count = 0 + invalid_phone = 0 + + for c in deduped: + if detect_opt_out(c): + opt_out_count += 1 + if not c.get("phone") or len(str(c.get("phone"))) < 9: + invalid_phone += 1 + src = classify_contact_source(c) + by_source[src] = by_source.get(src, 0) + 1 + + return { + "raw_total": total, + "after_dedupe": len(deduped), + "duplicates_removed": total - len(deduped), + "invalid_phone": invalid_phone, + "opt_out_count": opt_out_count, + "by_source": by_source, + "ready_to_review": len(deduped) - opt_out_count - invalid_phone, + } diff --git a/dealix/auto_client_acquisition/growth_operator/contactability.py b/dealix/auto_client_acquisition/growth_operator/contactability.py new file mode 100644 index 00000000..16585c83 --- /dev/null +++ b/dealix/auto_client_acquisition/growth_operator/contactability.py @@ -0,0 +1,186 @@ +""" +Contactability — per-contact "can we contact?" decision with PDPL reasons. + +Default policy: **no cold WhatsApp** without lawful basis. +PDPL Art.5 emphasizes lawful basis, consent, and purpose limitation. +""" + +from __future__ import annotations + +from typing import Any + +from auto_client_acquisition.growth_operator.contact_importer import ( + classify_contact_source, + detect_opt_out, + normalize_phone, +) + +# ── Decision labels ────────────────────────────────────────────── +CONTACTABILITY_LABELS: tuple[str, ...] = ( + "safe", # consent + lawful basis verified + "needs_review", # source unclear; pending operator confirmation + "blocked", # opt-out / banned / invalid / breaches policy +) + + +def score_contactability( + contact: dict[str, Any], + *, + channel: str = "whatsapp", + require_consent_for_cold_whatsapp: bool = True, +) -> dict[str, Any]: + """ + Decide whether this contact can be approached on this channel today. + + Returns: + { + "label": "safe"|"needs_review"|"blocked", + "channel": "...", + "reasons": [...], # human-readable Arabic reasons + "next_action": "...", # what the operator should do + } + """ + reasons: list[str] = [] + label: str = "safe" + + # 1) Opt-out / banned wins everything + if detect_opt_out(contact): + return { + "label": "blocked", + "channel": channel, + "reasons": ["العميل سجل opt-out أو محظور — لا تواصل بأي شكل."], + "next_action": "remove_from_lists", + } + + # 2) Phone validity + phone = normalize_phone(contact.get("phone")) + if channel == "whatsapp" and not phone: + return { + "label": "blocked", + "channel": channel, + "reasons": ["لا يوجد رقم صالح — WhatsApp مستحيل."], + "next_action": "remove_or_collect_phone", + } + + # 3) Source classification + src = classify_contact_source(contact) + + # Cold WhatsApp without consent → blocked + if channel == "whatsapp" and require_consent_for_cold_whatsapp: + if src == "cold_list": + return { + "label": "blocked", + "channel": channel, + "reasons": [ + "WhatsApp البارد ممنوع بدون lawful basis (PDPL م.5).", + "السياسة: لا cold WhatsApp افتراضياً.", + ], + "next_action": "switch_to_email_or_get_consent", + } + if src == "unknown": + return { + "label": "needs_review", + "channel": channel, + "reasons": [ + "مصدر الرقم غير محدد — يحتاج توثيق lawful basis.", + "ارجع للمشغّل لإقرار العلاقة قبل الإرسال.", + ], + "next_action": "operator_confirms_source", + } + + # 4) Healthy paths + if src in ("existing_customer", "inbound_lead", "referral"): + return { + "label": "safe", + "channel": channel, + "reasons": [ + f"علاقة قائمة ({src}) — أساس قانوني قائم لـ business contact.", + ], + "next_action": "draft_message_with_approval", + } + if src == "old_lead": + last = contact.get("last_contacted_at") + if last: + reasons.append("lead سابق — تواصل ضمن نافذة شهور قابلة للتبرير.") + label = "safe" + else: + reasons.append("lead سابق بدون تاريخ تواصل — يحتاج warm-up قصير.") + label = "needs_review" + return { + "label": label, + "channel": channel, + "reasons": reasons, + "next_action": ( + "draft_short_followup_with_approval" if label == "safe" + else "operator_confirms_continuity" + ), + } + if src == "event_lead": + return { + "label": "safe", + "channel": channel, + "reasons": ["lead من فعالية مع موافقة ضمنية على المتابعة بـ 30 يوم."], + "next_action": "draft_event_followup_with_approval", + } + + # 5) Email channel — more permissive (List-Unsubscribe header makes it safer) + if channel == "email": + if src == "unknown": + return { + "label": "needs_review", + "channel": "email", + "reasons": ["مصدر غير محدد — أرسل عبر إيميل مع List-Unsubscribe إن قبلت."], + "next_action": "operator_confirms_source", + } + return { + "label": "safe", + "channel": "email", + "reasons": [f"مصدر مقبول للإيميل B2B ({src})."], + "next_action": "draft_email_with_approval", + } + + # Fallback (defensive) + return { + "label": "needs_review", + "channel": channel, + "reasons": ["لا تطابق سياسة معروفة — يحتاج مراجعة المشغّل."], + "next_action": "operator_review_required", + } + + +def contactability_summary( + contacts: list[dict[str, Any]], + *, + channel: str = "whatsapp", +) -> dict[str, Any]: + """Bulk classification report for the upload dashboard.""" + counts: dict[str, int] = {label: 0 for label in CONTACTABILITY_LABELS} + next_actions: dict[str, int] = {} + sample_blocked: list[dict[str, Any]] = [] + sample_review: list[dict[str, Any]] = [] + sample_safe: list[dict[str, Any]] = [] + + for c in contacts: + decision = score_contactability(c, channel=channel) + counts[decision["label"]] += 1 + next_actions[decision["next_action"]] = next_actions.get(decision["next_action"], 0) + 1 + if decision["label"] == "blocked" and len(sample_blocked) < 5: + sample_blocked.append({**c, **decision}) + elif decision["label"] == "needs_review" and len(sample_review) < 5: + sample_review.append({**c, **decision}) + elif decision["label"] == "safe" and len(sample_safe) < 5: + sample_safe.append({**c, **decision}) + + return { + "channel": channel, + "total": len(contacts), + "by_label": counts, + "by_next_action": next_actions, + "sample_safe": sample_safe, + "sample_review": sample_review, + "sample_blocked": sample_blocked, + "policy_note": ( + "لا cold WhatsApp بدون lawful basis — السياسة الافتراضية. " + "العميل يقدر يعدل القاعدة لكل قائمة بعد توثيق المصدر." + ), + } diff --git a/dealix/auto_client_acquisition/growth_operator/meeting_planner.py b/dealix/auto_client_acquisition/growth_operator/meeting_planner.py new file mode 100644 index 00000000..5e088d14 --- /dev/null +++ b/dealix/auto_client_acquisition/growth_operator/meeting_planner.py @@ -0,0 +1,149 @@ +""" +Meeting Operator — agenda + calendar draft + post-meeting follow-up. + +Pure drafting only. No live Google Calendar event creation here — +the actual `events.insert` happens elsewhere (and only after explicit +user authorization via OAuth). +""" + +from __future__ import annotations + +from datetime import datetime, timedelta, timezone +from typing import Any + + +def build_meeting_agenda( + *, + contact_name: str, + company: str, + purpose_ar: str = "اكتشاف وتأهيل أولي", + duration_minutes: int = 20, +) -> dict[str, Any]: + """Generate a deterministic Saudi-friendly agenda.""" + if duration_minutes <= 15: + slots_ar = [ + "تعارف سريع (٢ دقائق)", + "فهم وضع الشركة الحالي (٥ دقائق)", + "عرض موجز لـ Dealix (٥ دقائق)", + "تحديد الخطوة التالية (٣ دقائق)", + ] + elif duration_minutes <= 30: + slots_ar = [ + "تعارف وأهداف الاجتماع (٣ دقائق)", + f"الوضع الحالي لدى {company} (٧ دقائق)", + "كيف يدعم Dealix هدفكم (١٠ دقائق)", + "أسئلة مفتوحة (٥ دقائق)", + "الخطوات التالية + توقيت المتابعة (٥ دقائق)", + ] + else: + slots_ar = [ + "تعارف وأهداف الاجتماع (٥ دقائق)", + f"التشخيص العميق لـ {company} (١٥ دقيقة)", + "عرض demo حي مع سيناريو فعلي (١٥ دقيقة)", + "ROI breakdown (٥ دقائق)", + "أسئلة + تحديات تنفيذية (١٠ دقائق)", + "الخطة المقترحة + الموافقات المطلوبة (١٠ دقائق)", + ] + return { + "title_ar": f"اجتماع Dealix × {company}", + "purpose_ar": purpose_ar, + "duration_minutes": duration_minutes, + "agenda_ar": slots_ar, + "attendees_suggested_ar": [contact_name, "مؤسس / مدير مبيعات Dealix"], + "approval_required": True, + "approval_status": "pending_approval", + } + + +def build_calendar_draft( + *, + contact_email: str | None, + contact_name: str, + company: str, + proposed_start_iso: str | None = None, + duration_minutes: int = 20, +) -> dict[str, Any]: + """ + Build a Google-Calendar-shaped draft (NOT inserted live). + + Suggests the next business hour slot if no start is provided. + Real `events.insert` happens only after the operator approves AND + has authorized Calendar OAuth. + """ + if proposed_start_iso: + try: + start_dt = datetime.fromisoformat(proposed_start_iso.replace("Z", "+00:00")).replace(tzinfo=None) + except ValueError: + start_dt = _next_business_hour() + else: + start_dt = _next_business_hour() + end_dt = start_dt + timedelta(minutes=duration_minutes) + + summary_ar = f"اجتماع Dealix × {company}" + description_ar = ( + f"اجتماع مع {contact_name} من {company} لاستكشاف فرصة استخدام " + f"Dealix لتشغيل النمو. مدة الاجتماع: {duration_minutes} دقيقة." + ) + return { + "summary": summary_ar, + "description": description_ar, + "start": { + "dateTime": start_dt.isoformat(), + "timeZone": "Asia/Riyadh", + }, + "end": { + "dateTime": end_dt.isoformat(), + "timeZone": "Asia/Riyadh", + }, + "attendees": [ + {"email": contact_email} for contact_email in [contact_email] if contact_email + ], + "conference_data_request": { + "createRequest": { + "requestId": f"dealix-meet-{int(start_dt.timestamp())}", + "conferenceSolutionKey": {"type": "hangoutsMeet"}, + } + }, + "live_inserted": False, + "approval_required": True, + "approval_status": "pending_approval", + "compliance_note_ar": ( + "draft فقط — لا يُنشأ event حي في Google Calendar حتى موافقة " + "OAuth صريحة + ضغطة المستخدم 'أنشئ الاجتماع'." + ), + } + + +def build_post_meeting_followup( + *, + contact_name: str, + company: str, + summary_ar: str, + next_step_ar: str = "أرسل recap + pilot offer", +) -> dict[str, Any]: + """Generate the post-meeting follow-up draft.""" + body_ar = ( + f"شكراً أستاذ {contact_name} على وقتكم الصباحي.\n\n" + f"خلاصة الاجتماع:\n{summary_ar}\n\n" + f"الخطوة التالية: {next_step_ar}\n\n" + f"نسعد بمتابعة الموضوع متى ناسبكم." + ) + return { + "channel_recommendation": "email", + "subject_ar": f"شكراً {contact_name} — متابعة اجتماع {company}", + "body_ar": body_ar, + "approval_required": True, + "approval_status": "pending_approval", + } + + +# ── Internal helpers ──────────────────────────────────────────── +def _next_business_hour(*, now: datetime | None = None) -> datetime: + """Next 09:00-17:00 Riyadh slot (demo helper; not timezone-perfect).""" + n = now or datetime.now(timezone.utc).replace(tzinfo=None) + # Push to next day 10am UTC ~ 1pm Riyadh — safe demo slot + candidate = (n + timedelta(days=1)).replace(hour=10, minute=0, second=0, microsecond=0) + # Skip Friday (Saudi weekend = Fri-Sat) + while candidate.weekday() in (4, 5): + candidate += timedelta(days=1) + return candidate diff --git a/dealix/auto_client_acquisition/growth_operator/message_planner.py b/dealix/auto_client_acquisition/growth_operator/message_planner.py new file mode 100644 index 00000000..0e18cf26 --- /dev/null +++ b/dealix/auto_client_acquisition/growth_operator/message_planner.py @@ -0,0 +1,265 @@ +""" +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", + } diff --git a/dealix/auto_client_acquisition/growth_operator/mission_planner.py b/dealix/auto_client_acquisition/growth_operator/mission_planner.py new file mode 100644 index 00000000..1cdb18c7 --- /dev/null +++ b/dealix/auto_client_acquisition/growth_operator/mission_planner.py @@ -0,0 +1,154 @@ +""" +Growth Missions — outcome-shaped tasks instead of features. + +Each mission has: id, title_ar, goal_ar, steps (ordered, approval-gated), +expected_duration_days, kill_metric (the ONE number that proves success). + +Pure deterministic. Production wires each step to the relevant agent. +""" + +from __future__ import annotations + +from typing import Any + + +GROWTH_MISSIONS: tuple[dict[str, Any], ...] = ( + { + "id": "first_10_opportunities", + "title_ar": "اطلع لي 10 فرص", + "goal_ar": "اكتشاف 10 شركات سعودية مناسبة + رسائل عربية + موافقة + متابعة أسبوع.", + "expected_duration_days": 7, + "kill_metric": "ten_drafts_approved", + "steps_ar": [ + "تحديد القطاع والمدينة + المعايير الأساسية.", + "اكتشاف 30 شركة مرشحة من المصادر المسموحة.", + "فلترة لـ 10 بأعلى Why-Now score.", + "كتابة 10 رسائل عربية بحالة pending_approval.", + "موافقة المشغّل على عينة → إرسال آمن.", + "تصنيف الردود + اقتراح متابعة لكل واحدة.", + "Proof Pack أسبوعي عند الإغلاق.", + ], + "primary_endpoint": "/api/v1/innovation/opportunities/ten-in-ten", + "approval_required": True, + }, + { + "id": "recover_stalled_deals", + "title_ar": "أنقذ الصفقات المتوقفة", + "goal_ar": "اكتشف الصفقات بدون نشاط 14+ يوم + اقترح متابعات multi-thread.", + "expected_duration_days": 5, + "kill_metric": "stalled_deals_revived", + "steps_ar": [ + "قراءة pipeline الحالي + revenue_graph.leak_detector.", + "تصنيف الصفقات: stalled / single-threaded / no-proposal.", + "اقتراح multi-thread (DM إضافي داخل الحساب).", + "كتابة drafts متابعة pending_approval لكل صفقة.", + "موافقة المشغّل → إرسال + جدولة re-check بعد 7 أيام.", + ], + "primary_endpoint": "/api/v1/revenue-os/leaks", + "approval_required": True, + }, + { + "id": "partnership_sprint", + "title_ar": "ابدأ شراكات", + "goal_ar": "تحديد + التواصل مع 5 شركاء محتملين خلال أسبوعين.", + "expected_duration_days": 14, + "kill_metric": "partner_intros_replied", + "steps_ar": [ + "تحديد قطاع العميل + حجمه → اقتراح أنواع شركاء مناسبة.", + "ترشيح 5 شركاء محتملين بأعلى strategic_value.", + "كتابة outreach warm لكل واحد.", + "موافقة المشغّل → إرسال على email.", + "متابعة الردود + جدولة 20 دقيقة لكل ردّ إيجابي.", + "Partner scorecard أولي بعد المكالمات.", + ], + "primary_endpoint": "/api/v1/growth-operator/partners/suggest", + "approval_required": True, + }, + { + "id": "safe_whatsapp_campaign", + "title_ar": "جهز حملة واتساب آمنة", + "goal_ar": "تحويل قائمة العميل إلى حملة WhatsApp يحترم PDPL + opt-in.", + "expected_duration_days": 3, + "kill_metric": "safe_messages_drafted", + "steps_ar": [ + "رفع قائمة الأرقام عبر contact_importer.", + "تنظيف + dedupe + classify_source.", + "فحص contactability — إخراج blocked/needs_review.", + "كتابة رسائل عربية لكل segment آمن.", + "موافقة المشغّل لكل segment على حدة.", + "إرسال آمن مع opt-out في كل رسالة.", + ], + "primary_endpoint": "/api/v1/growth-operator/contacts/import-preview", + "approval_required": True, + }, + { + "id": "meeting_booking_sprint", + "title_ar": "احجز لي 3 اجتماعات", + "goal_ar": "حجز 3 اجتماعات مع leads أعلى Why-Now خلال 5 أيام عمل.", + "expected_duration_days": 5, + "kill_metric": "meetings_confirmed", + "steps_ar": [ + "اختيار أعلى 10 leads من Top-10 السابق.", + "إعداد agenda + calendar draft لكل واحد.", + "كتابة intro + ask مكالمة 15 دقيقة.", + "موافقة + إرسال WhatsApp/email.", + "تأكيد الحضور قبل الاجتماع بـ 24 ساعة.", + "post-meeting follow-up draft.", + ], + "primary_endpoint": "/api/v1/growth-operator/meetings/draft", + "approval_required": True, + }, + { + "id": "list_cleanup", + "title_ar": "ارفع قائمتي ونظفها", + "goal_ar": "تحويل ملف غير منظم إلى قائمة contactability-classified جاهزة.", + "expected_duration_days": 1, + "kill_metric": "safe_contacts_extracted", + "steps_ar": [ + "رفع CSV/Excel.", + "normalize_phone + dedupe.", + "classify_contact_source لكل سطر.", + "score_contactability لكل سطر.", + "تقرير: safe / needs_review / blocked + عدد + عينة.", + "اقتراح: ابدأ بالـ safe فقط، مع plan لـ needs_review.", + ], + "primary_endpoint": "/api/v1/growth-operator/contacts/import-preview", + "approval_required": True, + }, +) + + +def list_missions() -> dict[str, Any]: + """Return the canonical mission catalog.""" + return { + "count": len(GROWTH_MISSIONS), + "missions": list(GROWTH_MISSIONS), + "kill_feature_id": "first_10_opportunities", + } + + +def run_mission(mission_id: str, *, payload: dict[str, Any] | None = None) -> dict[str, Any]: + """ + Plan a mission run — returns the execution outline + first-step + prompt for the operator. Does NOT actually execute steps; that's + done by routing each step to its primary endpoint. + """ + mission = next((m for m in GROWTH_MISSIONS if m["id"] == mission_id), None) + if mission is None: + return { + "error": f"unknown_mission: {mission_id}", + "available_ids": [m["id"] for m in GROWTH_MISSIONS], + } + return { + "mission_id": mission_id, + "title_ar": mission["title_ar"], + "goal_ar": mission["goal_ar"], + "kill_metric": mission["kill_metric"], + "expected_duration_days": mission["expected_duration_days"], + "current_step_index": 0, + "next_step_ar": mission["steps_ar"][0], + "primary_endpoint": mission["primary_endpoint"], + "payload_received": payload or {}, + "approval_required": True, + "approval_status": "pending_approval", + } diff --git a/dealix/auto_client_acquisition/growth_operator/partnership_planner.py b/dealix/auto_client_acquisition/growth_operator/partnership_planner.py new file mode 100644 index 00000000..62309824 --- /dev/null +++ b/dealix/auto_client_acquisition/growth_operator/partnership_planner.py @@ -0,0 +1,163 @@ +""" +Partnership Operator — propose partner types + outreach drafts + scorecard. + +Keep deterministic; partner suggestions come from a curated catalog +tuned for Saudi B2B (agencies, consultants, integrators, CRM vendors, +founder communities, sector influencers). +""" + +from __future__ import annotations + +import hashlib +from typing import Any + + +PARTNER_TYPES: tuple[dict[str, Any], ...] = ( + { + "key": "marketing_agency", + "label_ar": "وكالة تسويق B2B", + "rationale_ar": "لديها عملاء يحتاجون lead-gen — Dealix يكمل خدماتها (لا يستبدلها).", + "model_ar": "Reseller / Revenue share 20-30%", + "ideal_size": "10-50 موظف", + }, + { + "key": "sales_consultant", + "label_ar": "مستشار مبيعات / مدرب", + "rationale_ar": "يحتاج أداة عملية تثبت توصياته للعملاء.", + "model_ar": "Affiliate fixed fee + ongoing commission", + "ideal_size": "1-5 موظف", + }, + { + "key": "tech_integrator", + "label_ar": "تكامل تقني / شريك Supabase أو Make.com", + "rationale_ar": "ينفّذ التكاملات للعملاء الكبار.", + "model_ar": "Implementation revenue share", + "ideal_size": "5-20 موظف", + }, + { + "key": "crm_vendor", + "label_ar": "مزود CRM (Zoho/Salla/Odoo سعودي)", + "rationale_ar": "Dealix طبقة نمو فوق الـ CRM، لا منافس مباشر.", + "model_ar": "Co-sell + technical alliance", + "ideal_size": "30+ موظف", + }, + { + "key": "founder_community", + "label_ar": "مجتمع مؤسسين سعوديين", + "rationale_ar": "الوصول لـ early adopters السعوديين عبر referrals.", + "model_ar": "Community partnership + free seats", + "ideal_size": "50+ عضو", + }, + { + "key": "sector_influencer", + "label_ar": "خبير قطاعي (عقار / صحة / لوجستيات)", + "rationale_ar": "ثقة جاهزة في القطاع تختصر دورة البيع.", + "model_ar": "Equity / advisory + revenue referral", + "ideal_size": "1-3 موظف", + }, +) + + +def suggest_partner_types( + *, + sector: str = "", + customer_size: str = "smb", +) -> dict[str, Any]: + """Recommend ranked partner types for the given customer profile.""" + suggestions = [] + for p in PARTNER_TYPES: + priority = 50 + if customer_size == "smb" and p["key"] in ("marketing_agency", "sales_consultant", "founder_community"): + priority += 25 + if customer_size == "enterprise" and p["key"] in ("crm_vendor", "tech_integrator"): + priority += 25 + if sector and sector.lower() in ("real_estate", "clinics", "logistics"): + if p["key"] == "sector_influencer": + priority += 20 + suggestions.append({**p, "priority": priority}) + + suggestions.sort(key=lambda x: x["priority"], reverse=True) + return { + "sector": sector, + "customer_size": customer_size, + "suggestions": suggestions[:5], + "next_action": "draft_outreach_for_top_3", + } + + +def draft_partner_outreach( + *, + partner_type_key: str, + partner_name: str = "", + customer_name: str = "Dealix", +) -> dict[str, Any]: + """Generate a warm partnership outreach draft.""" + pt = next((p for p in PARTNER_TYPES if p["key"] == partner_type_key), None) + if pt is None: + return { + "error": "unknown_partner_type", + "approval_required": True, + "approval_status": "pending_approval", + } + + target = partner_name or pt["label_ar"] + seed = hashlib.md5(f"{partner_type_key}{partner_name}".encode()).digest() + angle_idx = seed[0] % 2 + angles_ar = [ + "تكامل خدماتنا يخدم نفس عملائكم بأقل احتكاك.", + "نموذج revenue share واضح + pilot على عميل واحد قبل الالتزام.", + ] + body_ar = ( + f"السلام عليكم،\n\n" + f"أنا من فريق {customer_name}. تابعنا عملكم ووجدناه قريب جداً من جمهورنا.\n\n" + f"الفكرة باختصار: {angles_ar[angle_idx]}\n\n" + f"هل ١٥-٢٠ دقيقة الأسبوع الجاي مناسبة لاستكشاف فرصة شراكة؟" + ) + return { + "partner_type": pt, + "channel_recommendation": "email", + "body_ar": body_ar, + "approval_required": True, + "approval_status": "pending_approval", + "suggested_next_steps": [ + "1. رسالة warm", + "2. مكالمة 20 دقيقة", + "3. عرض partner revenue share", + "4. pilot على عميل واحد", + ], + } + + +def partner_scorecard( + *, + partner_id: str, + intros_made: int = 0, + deals_influenced: int = 0, + revenue_share_paid_sar: float = 0.0, + relationship_age_months: int = 0, +) -> dict[str, Any]: + """Compute a simple partner-health scorecard.""" + activity_score = min(100, intros_made * 8 + deals_influenced * 15) + longevity_bonus = min(20, relationship_age_months * 2) + overall = min(100, activity_score + longevity_bonus) + if overall >= 75: + tier = "platinum" + elif overall >= 50: + tier = "gold" + elif overall >= 25: + tier = "silver" + else: + tier = "bronze" + return { + "partner_id": partner_id, + "overall_score": overall, + "tier": tier, + "intros_made": intros_made, + "deals_influenced": deals_influenced, + "revenue_share_paid_sar": round(revenue_share_paid_sar, 2), + "relationship_age_months": relationship_age_months, + "next_action_ar": ( + "احتفظ بالعلاقة بنشاط ثابت — ربع سنوي." if tier in ("platinum", "gold") + else "حفّز التفاعل — اقتراح pilot جديد أو إحالة محتملة." + ), + } diff --git a/dealix/auto_client_acquisition/growth_operator/payment_offer.py b/dealix/auto_client_acquisition/growth_operator/payment_offer.py new file mode 100644 index 00000000..e38f6566 --- /dev/null +++ b/dealix/auto_client_acquisition/growth_operator/payment_offer.py @@ -0,0 +1,97 @@ +""" +Payment-in-Chat — Moyasar payment-link drafts (NO live charge). + +In production: link goes to a Moyasar hosted checkout. The user enters +their card on Moyasar's domain (PCI-safe), not inside WhatsApp. + +This module produces a STRUCTURED draft only — the actual +`POST /v1/payments` call to Moyasar happens elsewhere with the +customer's secret key. +""" + +from __future__ import annotations + +import uuid +from typing import Any + + +# ── Pricing (mirrors landing/pricing.html + business/pricing_strategy.py) ── +PLAN_CATALOG_SAR: dict[str, dict[str, Any]] = { + "founder_operator": {"label_ar": "مشغّل المؤسس", "amount_sar": 499.0}, + "growth_os": {"label_ar": "نظام النمو (Growth OS)", "amount_sar": 2999.0}, + "scale_os": {"label_ar": "نظام التوسّع (Scale OS)", "amount_sar": 7999.0}, + "performance_pilot": {"label_ar": "Pay-per-Result pilot 30 يوم", "amount_sar": 1.0}, # placeholder +} + + +def sar_to_halalas(amount_sar: float) -> int: + """Convert SAR to halalas (Moyasar's smallest unit). 1 SAR = 100 halalas.""" + if amount_sar < 0: + raise ValueError("amount_sar must be non-negative") + return int(round(amount_sar * 100)) + + +def build_moyasar_payment_link_draft( + *, + plan_key: str, + customer_id: str, + contact_email: str | None = None, + locale: str = "ar", + callback_url: str = "https://dealix.sa/payment-success.html", + cancel_url: str = "https://dealix.sa/payment-cancelled.html", + custom_amount_sar: float | None = None, +) -> dict[str, Any]: + """ + Build a Moyasar payment payload (NOT yet sent to Moyasar API). + + Returns a dict the operator can review + approve. The actual + `POST /v1/payments` is fired elsewhere by the billing service. + """ + plan = PLAN_CATALOG_SAR.get(plan_key) + if plan is None and custom_amount_sar is None: + return { + "error": f"unknown_plan: {plan_key}", + "approval_required": True, + "approval_status": "pending_approval", + "live_charged": False, + } + amount_sar = custom_amount_sar if custom_amount_sar is not None else plan["amount_sar"] + label_ar = (plan["label_ar"] if plan else "خطة مخصصة") + + description_ar = ( + f"اشتراك Dealix — {label_ar}. " + f"المبلغ {amount_sar:,.2f} ريال شامل ضريبة القيمة المضافة 15%." + ) + + return { + "moyasar_request_draft": { + "amount": sar_to_halalas(amount_sar), + "currency": "SAR", + "description": description_ar, + "callback_url": callback_url, + "cancel_url": cancel_url, + "metadata": { + "customer_id": customer_id, + "plan_key": plan_key, + "locale": locale, + "draft_id": f"draft_pay_{uuid.uuid4().hex[:16]}", + }, + }, + "amount_sar": amount_sar, + "amount_halalas": sar_to_halalas(amount_sar), + "label_ar": label_ar, + "channel_recommendation": "whatsapp_with_link", + "in_chat_message_ar": ( + f"الباقة المقترحة:\n{label_ar} — {amount_sar:,.0f} ريال\n\n" + "[ادفع الآن] [أرسل فاتورة] [كلم المبيعات]\n\n" + "ملاحظة: الدفع آمن عبر Moyasar (سعودي مرخّص). فاتورة ZATCA " + "تصلكم تلقائياً بعد التأكيد." + ), + "approval_required": True, + "approval_status": "pending_approval", + "live_charged": False, + "compliance_note_ar": ( + "draft فقط — لا يتم خصم أي مبلغ حتى يضغط العميل 'ادفع' " + "على Moyasar وتصلنا webhook 'paid'." + ), + } diff --git a/dealix/auto_client_acquisition/growth_operator/proof_pack.py b/dealix/auto_client_acquisition/growth_operator/proof_pack.py new file mode 100644 index 00000000..0c536cdc --- /dev/null +++ b/dealix/auto_client_acquisition/growth_operator/proof_pack.py @@ -0,0 +1,162 @@ +""" +Weekly Proof Pack — evidence the customer can show their CEO/board. + +Tracks: opportunities discovered, messages approved, replies, meetings, +deals, pipeline, blocked risks (PDPL gates that fired), revenue leaks +recovered, next week plan. + +Pure function. No I/O. +""" + +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Any + + +def _grade_week(*, pipeline_sar: float, plan_cost_sar: float, deals_won: int) -> str: + """Quick A+/A/B/C/D grade for the week.""" + if plan_cost_sar <= 0: + return "B" + multiple = pipeline_sar / plan_cost_sar + if multiple >= 5 and deals_won >= 1: + return "A+" + if multiple >= 3: + return "A" + if multiple >= 1.5: + return "B" + if multiple >= 0.5: + return "C" + return "D" + + +def build_weekly_proof_pack( + *, + customer_id: str, + customer_name: str, + week_label: str, + plan_cost_weekly_sar: float = 750, + # Activity + opportunities_discovered: int = 0, + messages_drafted: int = 0, + messages_approved: int = 0, + messages_sent: int = 0, + replies_received: int = 0, + positive_replies: int = 0, + meetings_booked: int = 0, + meetings_held: int = 0, + proposals_sent: int = 0, + deals_won: int = 0, + # Money + pipeline_added_sar: float = 0.0, + revenue_won_sar: float = 0.0, + # Risk / quality + risky_drafts_blocked: int = 0, + revenue_leaks_recovered: int = 0, + avg_response_minutes: int = 0, + # Best of + best_message_subject: str | None = None, + best_message_reply_rate: float | None = None, +) -> dict[str, Any]: + """Build the weekly proof pack (Markdown + structured).""" + approve_rate = ( + round(messages_approved / messages_drafted, 4) if messages_drafted else 0.0 + ) + reply_rate = ( + round(replies_received / messages_sent, 4) if messages_sent else 0.0 + ) + grade = _grade_week( + pipeline_sar=pipeline_added_sar, + plan_cost_sar=plan_cost_weekly_sar, + deals_won=deals_won, + ) + multiple = round(pipeline_added_sar / plan_cost_weekly_sar, 2) if plan_cost_weekly_sar else 0.0 + headline_ar = ( + f"{pipeline_added_sar:,.0f} ريال pipeline + " + f"{meetings_booked} اجتماع + {risky_drafts_blocked} مخاطرة محبوطة " + f"خلال {week_label}" + ) + + activity = { + "فرص مكتشفة": opportunities_discovered, + "مسودات": messages_drafted, + f"موافقات ({approve_rate*100:.0f}%)": messages_approved, + "مُرسلة": messages_sent, + f"ردود ({reply_rate*100:.1f}%)": replies_received, + "ردود إيجابية": positive_replies, + "اجتماعات محجوزة": meetings_booked, + "اجتماعات منعقدة": meetings_held, + "عروض مرسلة": proposals_sent, + "صفقات مكسوبة": deals_won, + } + money = { + "Pipeline مضاف": f"{pipeline_added_sar:,.0f} ريال", + "إيراد محسوم": f"{revenue_won_sar:,.0f} ريال", + "Multiple على تكلفة الأسبوع": f"{multiple}×", + } + quality = { + "drafts خطرة محبوطة (PDPL gates)": risky_drafts_blocked, + "تسريبات إيراد منقذة": revenue_leaks_recovered, + "متوسط زمن الرد (دقيقة)": avg_response_minutes, + } + + next_week_plan_ar = [] + if reply_rate < 0.05: + next_week_plan_ar.append("اختبر صياغة مختلفة للسطر الأول — معدل الرد منخفض.") + if avg_response_minutes > 60: + next_week_plan_ar.append(f"قلل زمن الرد من {avg_response_minutes} إلى أقل من 60 دقيقة.") + if meetings_booked == 0 and replies_received >= 5: + next_week_plan_ar.append("ركّز على qualifying — ردود كثيرة بدون اجتماع.") + if deals_won == 0 and proposals_sent >= 2: + next_week_plan_ar.append("مراجعة العروض المرسلة + جلسة Deal Coach.") + if not next_week_plan_ar: + next_week_plan_ar.append("ركّز على الـ scale — زد عدد الـ leads بنسبة 30%.") + + md_lines = [ + f"# Dealix Proof Pack — {customer_name}", + f"**الفترة:** {week_label}", + f"**التقييم:** {grade}", + "", + "## TL;DR", + headline_ar, + "", + "## النشاط", + *(f"- {k}: {v}" for k, v in activity.items()), + "", + "## المال", + *(f"- {k}: {v}" for k, v in money.items()), + "", + "## الجودة + الأمان", + *(f"- {k}: {v}" for k, v in quality.items()), + "", + "## أفضل أداء", + f"- subject الأنجح: {best_message_subject or '—'}", + f"- معدل ردها: {(best_message_reply_rate or 0)*100:.1f}%", + "", + "## خطة الأسبوع القادم", + *(f"- {x}" for x in next_week_plan_ar), + "", + f"_Generated by Dealix at {datetime.now(timezone.utc).isoformat()}_", + ] + + return { + "customer_id": customer_id, + "customer_name": customer_name, + "week_label": week_label, + "grade": grade, + "headline_ar": headline_ar, + "activity": activity, + "money": money, + "quality": quality, + "best_message": { + "subject": best_message_subject, + "reply_rate": best_message_reply_rate, + }, + "next_week_plan_ar": next_week_plan_ar, + "markdown_export": "\n".join(md_lines), + "approval_required": False, + "compliance_note_ar": ( + "هذا تقرير قراءة فقط — يُولَّد من سجلات حقيقية ولا يحوي أي PII " + "خارج هوية الشركة المشتركة." + ), + } diff --git a/dealix/auto_client_acquisition/growth_operator/targeting.py b/dealix/auto_client_acquisition/growth_operator/targeting.py new file mode 100644 index 00000000..92bef78f --- /dev/null +++ b/dealix/auto_client_acquisition/growth_operator/targeting.py @@ -0,0 +1,146 @@ +""" +Targeting — turn a list of safe contacts into a ranked Top-N with Why-Now. + +Pure functions; no LLM calls. Heuristic ranking: + - existing customer / inbound lead: highest base score + - event lead: strong recency boost + - old lead with last_contacted_at: medium + - referral: high trust + - unknown / cold: filtered out unless explicitly allowed +""" + +from __future__ import annotations + +import hashlib +from typing import Any + +from auto_client_acquisition.growth_operator.contact_importer import ( + classify_contact_source, + detect_opt_out, + normalize_phone, +) +from auto_client_acquisition.growth_operator.contactability import ( + score_contactability, +) + + +# ── Segments ───────────────────────────────────────────────────── +_SEGMENT_BASE_SCORE: dict[str, float] = { + "existing_customer": 90.0, + "inbound_lead": 85.0, + "referral": 80.0, + "event_lead": 75.0, + "old_lead": 60.0, + "unknown": 35.0, + "cold_list": 20.0, +} + + +def segment_contacts(contacts: list[dict[str, Any]]) -> dict[str, list[dict[str, Any]]]: + """Group contacts into segments using classify_contact_source.""" + segs: dict[str, list[dict[str, Any]]] = { + "existing_customer": [], + "inbound_lead": [], + "referral": [], + "event_lead": [], + "old_lead": [], + "unknown": [], + "cold_list": [], + "blocked_or_invalid": [], + } + for c in contacts: + if detect_opt_out(c) or not normalize_phone(c.get("phone")): + segs["blocked_or_invalid"].append(c) + continue + src = classify_contact_source(c) + segs.setdefault(src, []).append(c) + return segs + + +# ── Why-Now stub (deterministic; placeholder until live signals) ── +_WHY_NOW_TEMPLATES_AR: dict[str, str] = { + "existing_customer": "علاقة قائمة — توقيت ممتاز لعرض expansion / upsell.", + "inbound_lead": "أبدى اهتماماً مؤخراً — السرعة (≤24 ساعة) ترفع التحويل.", + "referral": "قادم بإحالة موثوقة — احترام السياق المهني.", + "event_lead": "تواصل من فعالية مؤخراً — نافذة 30 يوم ذهبية.", + "old_lead": "lead سابق — انتهز موسم/حدث جديد للعودة.", + "unknown": "مصدر غير محدد — يحتاج warm-up + توثيق lawful basis.", + "cold_list": "قائمة باردة — لا تواصل قبل توثيق العلاقة.", +} + + +def why_now_stub(contact: dict[str, Any], *, sector_hint: str = "") -> dict[str, Any]: + """ + Deterministic Why-Now stub. + + In production, this is replaced by a live signal-driven explainer + that reads market_intelligence + company website diff + jobs. + """ + src = classify_contact_source(contact) + company = contact.get("company") or contact.get("name") or "—" + rationale = _WHY_NOW_TEMPLATES_AR.get(src, "تواصل قياسي — راجع المصدر قبل الإرسال.") + if sector_hint and src in ("event_lead", "inbound_lead", "old_lead"): + rationale += f" · مرتبط بقطاع {sector_hint}." + # Synthetic stable score (testable, no entropy) + seed = hashlib.md5(f"{company}|{src}|{sector_hint}".encode()).digest() + bonus = (seed[0] % 21) - 10 # -10..+10 + return { + "rationale_ar": rationale, + "score_modifier": bonus, + "source": src, + } + + +# ── Ranking ────────────────────────────────────────────────────── +def rank_targets( + contacts: list[dict[str, Any]], + *, + sector_hint: str = "", + channel: str = "whatsapp", + require_safe: bool = True, +) -> list[dict[str, Any]]: + """ + Score every contact, optionally filter to safe-only, return sorted desc. + + Each item in the result is the original contact + score + why_now + decision. + """ + out: list[dict[str, Any]] = [] + for c in contacts: + decision = score_contactability(c, channel=channel) + if require_safe and decision["label"] != "safe": + continue + why = why_now_stub(c, sector_hint=sector_hint) + base = _SEGMENT_BASE_SCORE.get(why["source"], 30.0) + score = max(0.0, min(100.0, base + why["score_modifier"])) + out.append({ + **c, + "fit_score": round(score, 1), + "why_now": why, + "contactability": decision, + }) + out.sort(key=lambda x: x["fit_score"], reverse=True) + return out + + +def recommend_top_10( + contacts: list[dict[str, Any]], + *, + sector_hint: str = "", + channel: str = "whatsapp", +) -> dict[str, Any]: + """The Top-10 view consumed by the dashboard's Growth Radar tile.""" + ranked = rank_targets( + contacts, sector_hint=sector_hint, channel=channel, require_safe=True, + ) + top = ranked[:10] + return { + "channel": channel, + "sector_hint": sector_hint, + "candidates_evaluated": len(contacts), + "candidates_safe": len(ranked), + "top": top, + "recommendation_ar": ( + f"اخترنا أعلى {len(top)} فرصة آمنة من قائمة {len(contacts)} " + f"بعد فلترة المخاطرة. كل واحدة بحالة pending_approval." + ), + } diff --git a/dealix/docs/ARABIC_GROWTH_OPERATOR_FULL_SPEC.md b/dealix/docs/ARABIC_GROWTH_OPERATOR_FULL_SPEC.md new file mode 100644 index 00000000..622af6df --- /dev/null +++ b/dealix/docs/ARABIC_GROWTH_OPERATOR_FULL_SPEC.md @@ -0,0 +1,351 @@ +# Arabic Growth Operator — Full Spec + +> **الرؤية:** Dealix ليس CRM ولا أداة WhatsApp ولا بوت رسائل. هو **Saudi Autonomous Revenue OS**: بوت عربي ذكي داخل WhatsApp/الداشبورد يفهم الشركة، السوق السعودي، الأرقام المرفوعة من العميل، الشراكات، الاجتماعات، المتابعة، الدفع، والامتثال — ويقترح وينفذ بموافقة واضحة. +> **آخر تحديث:** 2026-05-01 +> **حالة الكود:** ✅ مبني، 50/50 unit tests خضراء على Python 3.10 venv + +--- + +## 1. الجملة المحورية + +> **Dealix هو مدير نمو عربي ذكي للشركات السعودية: +> يعرف من تستهدف، ماذا تقول، متى تتابع، من تشارك، وكيف تثبت أن كل هذا جاب نتيجة.** + +--- + +## 2. تجربة WhatsApp مثل Boardy لكن أقوى + +**Boardy** يقترح علاقات. +**Dealix** يقترح علاقات + leads + رسائل + اجتماعات + مدفوعات + proof + revenue. + +كل بطاقة في الـ feed: +- **Why now** ولماذا الآن تحديداً +- **Recommended action** بعربي طبيعي +- **3 buttons فقط** (حد WhatsApp Reply Buttons): قبول / تخطي / رسالة +- لو ضغط "رسالة" → يدخل draft mode: اعتماد / تعديل / إلغاء + +--- + +## 3. أنواع العملاء التي تخدمهم + +| النوع | كيف يخدمه Dealix | +|---|---| +| **صاحب الشركة** | daily brief 3 قرارات صباحاً، قرار مطلوب feed، Proof Pack أسبوعي | +| **مدير المبيعات** | deals at risk، reps slow follow-up، messages to approve، forecast، coaching | +| **متجر / SMB** | تصنيف العملاء VIP/inactive/repeat/leads، حملات استرجاع، payment links، عروض موسمية | +| **مؤسس فردي** | First 10 Customers Autopilot، Personal Operator، Strategic Board Brief | +| **وكالة تسويق** | reseller / revenue share + سياسة موافقات لكل عميل | + +--- + +## 4. استخدام الأرقام المرفوعة من العميل + +> **القاعدة الذهبية:** الأرقام لازم تكون مملوكة/مصرّح بها أو عندها علاقة مناسبة. لا cold WhatsApp بدون lawful basis. + +العميل يرفع ملف: + +```text +name, phone, company, city, sector, source, relationship_status, +opt_in_status, last_contacted_at, notes +``` + +ثم Dealix يعمل تلقائياً: + +1. **normalize_phone** — تطبيع للأرقام السعودية (E.164) +2. **dedupe_contacts** — إزالة التكرار، الإبقاء على السجل الأغنى +3. **classify_contact_source** — existing / inbound / event / referral / old_lead / cold / unknown +4. **detect_opt_out** — إشارات الـ opt-out بالعربي + الإنجليزي +5. **score_contactability** — `safe / needs_review / blocked` مع أسباب عربية واضحة +6. **summarize_import** — ملخص مرئي يطلع للعميل + +### المخرج للعميل +```text +رفعت 1,000 رقم. +- 420 آمن للتواصل +- 180 يحتاج مراجعة +- 90 opt-out أو ممنوع +- 310 غير واضح المصدر +أقترح نبدأ فقط بـ 420 رقم الآمنة. +[اعرض العينة] [جهز الرسائل] [احذف غير الآمن] +``` + +--- + +## 5. Contactability + opt-in + +### القرارات الـ 3 +- **safe**: علاقة قائمة (existing/inbound/referral) + رقم صالح + ليس opt-out +- **needs_review**: مصدر غير واضح / lead قديم بدون last_contacted_at +- **blocked**: opt-out / cold WhatsApp بدون lawful basis / رقم غير صالح + +### قاعدة WhatsApp الافتراضية +```text +لا cold WhatsApp بدون lawful basis (PDPL م.5). +السياسة: لا cold WhatsApp افتراضياً. +``` + +العميل يقدر يعدل القاعدة لكل قائمة بعد توثيق المصدر، لكن الأمر الافتراضي هو **الحماية**. + +--- + +## 6. WhatsApp Approvals + +WhatsApp Reply Buttons محدودة بـ 3: + +```text +[قبول] [تخطي] [رسالة] +``` + +لو ضغط "رسالة": + +```text +[اعتماد] [تعديل] [إلغاء] +``` + +كل draft يخرج بـ: +- `approval_required: True` +- `approval_status: "pending_approval"` +- `guardrails_ar`: قائمة قواعد عربية واضحة + +--- + +## 7. Gmail Drafts (لا إرسال مباشر) + +- Endpoint يستخدم `gmail.compose` scope فقط +- ينشئ مسودة في صندوق المستخدم بـ label `DRAFT` +- المستخدم يضغط "Send" بنفسه من Gmail + +--- + +## 8. Calendar Drafts (لا إنشاء مباشر) + +- `build_calendar_draft()` يرجع dict مطابق لـ Google Calendar `events.insert` body +- `live_inserted: False` دائماً +- `conferenceDataVersion: 1` للحصول على Google Meet +- الـ insert الفعلي يحدث في خدمة منفصلة، فقط بعد: + - موافقة OAuth صريحة من المستخدم + - ضغط زر "أنشئ الاجتماع" + +--- + +## 9. Payment Links (Moyasar) + +- `build_moyasar_payment_link_draft()` يبني payload بصيغة Moyasar +- المبلغ بالـ halalas (1 SAR = 100) +- `live_charged: False` دائماً +- `POST /v1/payments` الفعلي يحدث في billing service + +داخل المحادثة: + +```text +الباقة المقترحة: +نظام النمو (Growth OS) — 2,999 ريال +[ادفع الآن] [أرسل فاتورة] [كلم المبيعات] +``` + +ضغط "ادفع الآن" → ينقل لـ Moyasar Hosted Checkout (PCI-safe)، **ليس** إدخال بطاقة داخل WhatsApp. + +--- + +## 10. الشراكات + +`suggest_partner_types()` يرجع 6 أنواع شركاء جاهزة: +- marketing_agency +- sales_consultant +- tech_integrator (Supabase / Make.com) +- crm_vendor (Zoho / Salla / Odoo سعودي) +- founder_community +- sector_influencer (عقار / صحة / لوجستيات) + +كل نوع له: rationale_ar، model_ar (Reseller / Revenue share / Affiliate / Equity)، ideal_size. + +`partner_scorecard()` يحسب: tier (platinum / gold / silver / bronze) من intros + deals + revenue share + age. + +--- + +## 11. الاجتماعات + +`build_meeting_agenda()` يخلق agenda سعودي مناسب: +- 15min → 4 فقرات +- 20-30min → 5 فقرات +- 45min+ → 6 فقرات (يشمل demo حي + ROI breakdown) + +`build_post_meeting_followup()` ينتج draft شكر + ملخص + خطوة تالية. + +--- + +## 12. Proof Pack + +`build_weekly_proof_pack()` يولد تقرير أسبوعي: +- **Activity:** 10 أرقام (opportunities, drafts, sent, replies, meetings, proposals, deals) +- **Money:** Pipeline + Revenue + Multiple +- **Quality:** drafts خطرة محبوطة (PDPL gates) + leaks recovered + avg response +- **Best of:** أفضل subject + reply rate +- **Next week plan:** قائمة عربية ديناميكية بناءً على الأرقام + +التقدير: A+ / A / B / C / D حسب pipeline / cost multiple + deals. + +تصدير Markdown جاهز للإرسال للإدارة. + +--- + +## 13. Growth Missions (6 مهمات outcome-shaped) + +| ID | Title AR | Kill Metric | Endpoint | +|---|---|---|---| +| **first_10_opportunities** ⭐ | اطلع لي 10 فرص | ten_drafts_approved | `/api/v1/innovation/opportunities/ten-in-ten` | +| recover_stalled_deals | أنقذ الصفقات المتوقفة | stalled_deals_revived | `/api/v1/revenue-os/leaks` | +| partnership_sprint | ابدأ شراكات | partner_intros_replied | `/api/v1/growth-operator/partners/suggest` | +| safe_whatsapp_campaign | جهز حملة واتساب آمنة | safe_messages_drafted | `/api/v1/growth-operator/contacts/import-preview` | +| meeting_booking_sprint | احجز لي 3 اجتماعات | meetings_confirmed | `/api/v1/growth-operator/meetings/draft` | +| list_cleanup | ارفع قائمتي ونظفها | safe_contacts_extracted | `/api/v1/growth-operator/contacts/import-preview` | + +**Kill feature:** `first_10_opportunities` — الميزة التي تبيع. + +--- + +## 14. حدود البحث في السوق والسوشال + +### المصادر المسموحة +- موقع الشركة + صفحات عامة +- Google Search / Maps (API مع keys) +- LinkedIn (API / يدوي مصرّح) +- X / Instagram / Facebook Graph API (بإذن العميل) +- Job boards / event pages / tender feeds +- CRM العميل + ملفاته + Google Sheets / Gmail / Calendar (بإذن) +- WhatsApp opt-in / inbound + +### ما لا يُبنى أبداً +- Scraping مخالف +- تجاوز login +- DM تلقائي بدون موافقة +- جمع أرقام عشوائية +- تخزين PII غير ضرورية +- إرسال جماعي غير مصرح + +> **اللغة الصحيحة في المنتج:** +> "Dealix يبحث في المصادر المصرح بها والمتاحة، ويحوّلها إلى فرص قابلة للمراجعة، ولا يرسل بدون موافقة." + +--- + +## 15. القواعد الـ 12 الأساسية (Compliance) + +1. **Client Growth Profile** — كل عميل له ملف، بدونه البوت عام +2. **Contactability Engine** — كل رقم/lead له decision +3. **WhatsApp Approval OS** — 3 buttons فقط، draft-first +4. **Lead Intelligence** — fit_score, intent_score, why_now, best_angle, risk +5. **Saudi Message Engine** — قصير، غير مبالغ، سبب واضح، طلب بسيط +6. **Objection-to-Action** — كل رد → action مع interpretation +7. **Meeting Operator** — agenda + draft + followup، بدون live insert +8. **Gmail Draft Operator** — `gmail.compose` فقط، draft مع label DRAFT +9. **Payment-in-Chat** — Moyasar payment link، لا بطاقات في WhatsApp +10. **Partnership Operator** — 6 أنواع + outreach + scorecard +11. **Proof Pack** — weekly evidence ضد churn +12. **Growth Missions** — outcome-shaped tasks (لا dashboard معقد) + +--- + +## 16. ما يُنفَّذ الآن (في الكود) + +✅ **مبني وعليه 50 unit test ناجحة:** + +``` +auto_client_acquisition/growth_operator/ +├── __init__.py # exports +├── client_profile.py # ClientGrowthProfile + defaults +├── contact_importer.py # normalize/dedupe/classify/opt_out/summarize +├── contactability.py # safe/needs_review/blocked + reasons +├── targeting.py # segment + rank + top-10 + why_now stub +├── message_planner.py # Arabic drafts + followups + objections +├── partnership_planner.py # types + outreach + scorecard +├── meeting_planner.py # agenda + calendar draft + followup +├── payment_offer.py # Moyasar payment link draft +├── proof_pack.py # weekly proof pack +└── mission_planner.py # 6 growth missions + +api/routers/growth_operator.py # 16 endpoints under /api/v1/growth-operator/ +tests/unit/test_growth_operator.py # 50 passing +``` + +### Endpoints الـ 16 + +``` +POST /api/v1/growth-operator/contacts/import-preview +POST /api/v1/growth-operator/contactability/score +POST /api/v1/growth-operator/targets/top-10 +POST /api/v1/growth-operator/messages/draft +POST /api/v1/growth-operator/messages/followup +POST /api/v1/growth-operator/messages/objection-response +POST /api/v1/growth-operator/partners/suggest +POST /api/v1/growth-operator/partners/outreach +POST /api/v1/growth-operator/partners/scorecard +POST /api/v1/growth-operator/meetings/draft +POST /api/v1/growth-operator/meetings/post-followup +POST /api/v1/growth-operator/payment-offer/draft +GET /api/v1/growth-operator/missions +POST /api/v1/growth-operator/missions/{id}/run +GET /api/v1/growth-operator/proof-pack/demo +POST /api/v1/growth-operator/profile +``` + +--- + +## 17. ما يُؤجَّل (بعد أول 10 عملاء) + +❌ **لا تنفذ الآن:** +- Live WhatsApp send (نظل draft-first) +- Live Calendar `events.insert` (يحتاج OAuth + UI confirm) +- Live Moyasar charges (يحتاج billing service منفصل) +- Live LinkedIn / X / Instagram scraping +- Multi-tenant SSO +- Mobile app +- MCP gateway مفتوح +- Marketplace خارجي +- Local LLM infra + +--- + +## 18. الجاهزية للتجربة (Beta) + +| المعيار | الحالة | +|---|---| +| Unit tests | ✅ 527 passed (50 منها growth_operator + 477 موجودة) | +| AST + import sanity | ✅ كل الـ 13 ملف | +| Approval invariant | ✅ كل draft عنده `approval_required: True` | +| لا live charge / send | ✅ كل drafts فقط | +| PDPL guardrails | ✅ no cold WhatsApp by default | +| Arabic body content | ✅ كل القوالب عربية طبيعية | +| Endpoint coverage | ✅ 16 endpoint + 6 missions + Top-10 + Proof Pack | + +**جاهز للـ private beta** بمجرد: +1. ربط Railway env vars +2. ربط Moyasar live keys +3. ربط WhatsApp Cloud / Green API +4. 10 شركات pilot + +--- + +## 19. المقارنة الموجزة + +| ضد | قوته | تميزنا | +|---|---|---| +| **Boardy** | اقتراح علاقات | علاقات + leads + رسائل + اجتماعات + payments + proof | +| **HubSpot** | عام، شامل | عربي، سعودي، WhatsApp-first، outcome-first | +| **Gong** | conversation intelligence | نبدأ من market signal → action، لا فقط analytics | +| **أدوات WhatsApp** | إرسال bulk | نقرر هل ترسل، ماذا، لمن، متى، آمن أم لا | +| **الوكالات** | تنفيذ يدوي | system قابل للقياس + شفاف + scalable | + +--- + +## 20. الخطوة التالية المباشرة + +1. **Beta-recruit 10 شركات سعودية** (real_estate / clinics / training / agencies — لكل قطاع 2-3) +2. **شغّل `first_10_opportunities` mission** لكل شركة في أول 24 ساعة +3. **اقصد 50% approval rate + 5+ replies** في أول أسبوع +4. **أرسل Proof Pack الأسبوع الأول** لكل عميل +5. **اجمع feedback** وحدّث القوالب + +> هذا النهج (kill feature → 10 عملاء → proof) أقوى من إطلاق عام بدون validation. +> Boardy للعلاقات + Dealix للنمو والإيرادات. + +— Dealix · Saudi Autonomous Revenue Platform · 🇸🇦 diff --git a/dealix/tests/unit/test_growth_operator.py b/dealix/tests/unit/test_growth_operator.py new file mode 100644 index 00000000..84846069 --- /dev/null +++ b/dealix/tests/unit/test_growth_operator.py @@ -0,0 +1,401 @@ +"""Unit tests for the Arabic Growth Operator layer.""" + +from __future__ import annotations + +import pytest + +from auto_client_acquisition.growth_operator import ( + build_calendar_draft, + build_demo_profile, + build_meeting_agenda, + build_moyasar_payment_link_draft, + build_post_meeting_followup, + build_weekly_proof_pack, + classify_contact_source, + contactability_summary, + dedupe_contacts, + detect_opt_out, + draft_arabic_message, + draft_followup, + draft_objection_response, + draft_partner_outreach, + list_missions, + normalize_phone, + partner_scorecard, + profile_from_dict, + rank_targets, + recommend_top_10, + run_mission, + sar_to_halalas, + score_contactability, + segment_contacts, + suggest_partner_types, + summarize_import, +) + + +# ── 1. Phone normalization ─────────────────────────────────────── +def test_normalize_phone_country_prefix_kept(): + assert normalize_phone("+966500000001") == "966500000001" + + +def test_normalize_phone_local_zero_to_country(): + assert normalize_phone("0500000001") == "966500000001" + + +def test_normalize_phone_bare_9_digits(): + assert normalize_phone("500000001") == "966500000001" + + +def test_normalize_phone_double_zero(): + assert normalize_phone("00966500000001") == "966500000001" + + +def test_normalize_phone_strips_punctuation(): + assert normalize_phone("+966 (50) 000-0001") == "966500000001" + + +def test_normalize_phone_invalid_returns_empty(): + assert normalize_phone("") == "" + assert normalize_phone("abc") == "" + + +# ── 2. Dedupe ──────────────────────────────────────────────────── +def test_dedupe_drops_exact_phone_duplicates(): + out = dedupe_contacts([ + {"name": "X", "phone": "0500000001"}, + {"name": "X duplicate", "phone": "+966 50 000 0001"}, + ]) + assert len(out) == 1 + + +def test_dedupe_keeps_richer_record(): + out = dedupe_contacts([ + {"name": "X", "phone": "0500000001"}, + {"name": "X full", "phone": "0500000001", "email": "x@example.sa", "company": "Co"}, + ]) + assert len(out) == 1 + assert out[0].get("email") == "x@example.sa" + + +# ── 3. Source classification ───────────────────────────────────── +def test_classify_existing_customer(): + assert classify_contact_source({"relationship_status": "customer"}) == "existing_customer" + + +def test_classify_inbound(): + assert classify_contact_source({"source": "website_form"}) == "inbound_lead" + + +def test_classify_event(): + assert classify_contact_source({"source": "exhibition"}) == "event_lead" + + +def test_classify_cold(): + assert classify_contact_source({"source": "cold"}) == "cold_list" + + +def test_classify_unknown_default(): + assert classify_contact_source({}) == "unknown" + + +# ── 4. Opt-out detection ───────────────────────────────────────── +def test_detect_opt_out_via_status(): + assert detect_opt_out({"opt_in_status": "opted_out"}) is True + + +def test_detect_opt_out_via_arabic_notes(): + assert detect_opt_out({"notes": "العميل طلب إيقاف الرسائل"}) is True + + +def test_detect_opt_out_clean(): + assert detect_opt_out({"name": "X"}) is False + + +# ── 5. Summarize import ────────────────────────────────────────── +def test_summarize_import_aggregates(): + contacts = [ + {"name": "A", "phone": "0500000001", "source": "customer"}, + {"name": "A dup", "phone": "0500000001", "source": "customer"}, # dup + {"name": "B", "phone": "0500000002", "source": "cold"}, + {"name": "C", "phone": "0500000003", "opt_in_status": "opted_out"}, + ] + s = summarize_import(contacts) + assert s["raw_total"] == 4 + assert s["after_dedupe"] == 3 + assert s["duplicates_removed"] == 1 + assert s["opt_out_count"] == 1 + + +# ── 6. Contactability — core safety rules ─────────────────────── +def test_contactability_blocks_opt_out(): + out = score_contactability({"opt_in_status": "opted_out", "phone": "0500000001"}) + assert out["label"] == "blocked" + + +def test_contactability_blocks_cold_whatsapp_by_default(): + """No cold WhatsApp without lawful basis — that's the policy.""" + out = score_contactability( + {"phone": "0500000001", "source": "cold", "name": "X"}, + channel="whatsapp", + ) + assert out["label"] == "blocked" + assert any("PDPL" in r or "lawful" in r or "بدون" in r for r in out["reasons"]) + + +def test_contactability_unknown_source_needs_review(): + out = score_contactability( + {"phone": "0500000001", "name": "X"}, + channel="whatsapp", + ) + assert out["label"] == "needs_review" + + +def test_contactability_existing_customer_safe(): + out = score_contactability( + {"phone": "0500000001", "name": "X", "relationship_status": "customer"}, + channel="whatsapp", + ) + assert out["label"] == "safe" + + +def test_contactability_inbound_lead_safe(): + out = score_contactability( + {"phone": "0500000001", "name": "X", "source": "website_form"}, + channel="whatsapp", + ) + assert out["label"] == "safe" + + +def test_contactability_summary_aggregates(): + s = contactability_summary( + [ + {"phone": "0500000001", "relationship_status": "customer"}, + {"phone": "0500000002", "source": "cold"}, + {"phone": "0500000003", "opt_in_status": "opted_out"}, + ], + channel="whatsapp", + ) + assert s["by_label"]["safe"] >= 1 + assert s["by_label"]["blocked"] >= 2 # cold + opt-out + + +# ── 7. Targeting + ranking ────────────────────────────────────── +def test_rank_targets_filters_unsafe(): + contacts = [ + {"phone": "0500000001", "relationship_status": "customer"}, + {"phone": "0500000002", "source": "cold"}, + ] + ranked = rank_targets(contacts, sector_hint="real_estate", channel="whatsapp") + assert len(ranked) == 1 # only the safe customer survives + + +def test_recommend_top_10_returns_at_most_10(): + contacts = [ + {"phone": f"05000000{i:02d}", "relationship_status": "customer", "name": f"X{i}"} + for i in range(15) + ] + out = recommend_top_10(contacts, sector_hint="real_estate") + assert out["candidates_evaluated"] == 15 + assert len(out["top"]) == 10 + + +def test_segment_contacts_buckets(): + segs = segment_contacts([ + {"phone": "0500000001", "relationship_status": "customer"}, + {"phone": "0500000002", "source": "exhibition"}, + {"phone": "0500000003", "source": "cold"}, + {"phone": "0500000004", "opt_in_status": "opted_out"}, + ]) + assert len(segs["existing_customer"]) == 1 + assert len(segs["event_lead"]) == 1 + assert len(segs["cold_list"]) == 1 + assert len(segs["blocked_or_invalid"]) == 1 + + +# ── 8. Message planner — Arabic + approval invariant ──────────── +def test_arabic_message_always_pending_approval(): + out = draft_arabic_message( + {"phone": "0500000001", "name": "سامي", "city": "الرياض", "sector": "real_estate"}, + ) + assert out["approval_required"] is True + assert out["approval_status"] == "pending_approval" + + +def test_arabic_message_contains_arabic(): + out = draft_arabic_message({"phone": "0500000001", "name": "X", "sector": "clinics"}) + assert any("؀" <= ch <= "ۿ" for ch in out["body_ar"]), "body must contain Arabic" + + +def test_arabic_message_no_overhyped_phrases(): + """The default templates must not contain 'ضمان 100%' / 'مضمونة' etc.""" + out = draft_arabic_message({"phone": "0500000001", "name": "X"}) + body = out["body_ar"] + for banned in ("ضمان 100", "نتائج مضمونة", "آخر فرصة", "اضغط هنا فوراً"): + assert banned not in body + + +def test_followup_returns_pending_approval(): + out = draft_followup({"phone": "0500000001", "name": "X"}, days_since_last=3) + assert out["approval_required"] is True + + +def test_objection_response_known(): + out = draft_objection_response("send_offer_whatsapp") + assert "next_action" in out + assert out["approval_required"] is True + + +def test_objection_response_unknown_diagnostic(): + out = draft_objection_response("totally_unknown_objection") + assert out["next_action"] == "diagnostic_question" + + +# ── 9. Partnership planner ────────────────────────────────────── +def test_partnership_suggestions_smb_emphasizes_agencies(): + out = suggest_partner_types(customer_size="smb") + top_keys = [s["key"] for s in out["suggestions"][:3]] + # SMB should prioritize agency / consultant / community + assert any(k in ("marketing_agency", "sales_consultant", "founder_community") for k in top_keys) + + +def test_partnership_outreach_pending(): + out = draft_partner_outreach(partner_type_key="marketing_agency", partner_name="Test Agency") + assert out["approval_required"] is True + + +def test_partnership_outreach_unknown_type(): + out = draft_partner_outreach(partner_type_key="bogus_type") + assert "error" in out + + +def test_partner_scorecard_grading(): + high = partner_scorecard( + partner_id="p1", intros_made=10, deals_influenced=4, + revenue_share_paid_sar=50_000, relationship_age_months=6, + ) + low = partner_scorecard(partner_id="p2") + assert high["overall_score"] > low["overall_score"] + assert high["tier"] in ("platinum", "gold") + assert low["tier"] == "bronze" + + +# ── 10. Meeting planner — no live event ───────────────────────── +def test_meeting_agenda_returns_slots(): + out = build_meeting_agenda( + contact_name="سامي", company="Test Co.", + purpose_ar="تأهيل أولي", duration_minutes=20, + ) + assert out["agenda_ar"] + assert out["approval_required"] is True + + +def test_meeting_calendar_draft_not_inserted(): + out = build_calendar_draft( + contact_email="x@test.sa", contact_name="X", company="Test Co.", + duration_minutes=30, + ) + assert out["live_inserted"] is False + assert out["approval_required"] is True + # Required Google Calendar shape + assert "summary" in out and "start" in out and "end" in out + assert out["start"]["timeZone"] == "Asia/Riyadh" + + +def test_post_meeting_followup_pending(): + out = build_post_meeting_followup( + contact_name="X", company="Test", summary_ar="مهتم في pilot.", + ) + assert out["approval_required"] is True + + +# ── 11. Payment offer — no live charge ────────────────────────── +def test_sar_to_halalas_basic(): + assert sar_to_halalas(1) == 100 + assert sar_to_halalas(2999) == 299_900 + + +def test_sar_to_halalas_negative_raises(): + with pytest.raises(ValueError): + sar_to_halalas(-5) + + +def test_payment_draft_does_not_charge(): + out = build_moyasar_payment_link_draft( + plan_key="growth_os", customer_id="c1", contact_email="x@test.sa", + ) + assert out["live_charged"] is False + assert out["approval_required"] is True + # Moyasar payload uses halalas + assert out["moyasar_request_draft"]["amount"] == 299_900 + assert out["moyasar_request_draft"]["currency"] == "SAR" + + +def test_payment_draft_unknown_plan(): + out = build_moyasar_payment_link_draft(plan_key="bogus", customer_id="c1") + assert "error" in out + assert out["live_charged"] is False + + +# ── 12. Proof pack ────────────────────────────────────────────── +def test_proof_pack_structure(): + out = build_weekly_proof_pack( + customer_id="c1", customer_name="Test Co.", week_label="W18-2026", + plan_cost_weekly_sar=750, + opportunities_discovered=42, messages_drafted=38, + messages_approved=33, messages_sent=33, + replies_received=11, positive_replies=4, + meetings_booked=3, meetings_held=2, + proposals_sent=1, deals_won=0, + pipeline_added_sar=185_000, revenue_won_sar=0, + risky_drafts_blocked=5, revenue_leaks_recovered=2, + avg_response_minutes=42, + ) + assert out["grade"] in ("A+", "A", "B", "C", "D") + assert "activity" in out and "money" in out and "quality" in out + assert out["next_week_plan_ar"] + assert "Dealix Proof Pack" in out["markdown_export"] + + +# ── 13. Missions ──────────────────────────────────────────────── +def test_missions_include_first_10_opportunities(): + out = list_missions() + ids = {m["id"] for m in out["missions"]} + assert "first_10_opportunities" in ids + assert "recover_stalled_deals" in ids + assert "partnership_sprint" in ids + assert "safe_whatsapp_campaign" in ids + assert out["kill_feature_id"] == "first_10_opportunities" + + +def test_run_mission_known(): + out = run_mission("first_10_opportunities", payload={"sector": "real_estate"}) + assert out["mission_id"] == "first_10_opportunities" + assert out["next_step_ar"] + assert out["primary_endpoint"] + assert out["approval_required"] is True + + +def test_run_mission_unknown(): + out = run_mission("bogus_mission") + assert "error" in out + + +# ── 14. Profile ───────────────────────────────────────────────── +def test_demo_profile_specialized(): + p = build_demo_profile() + assert p.is_specialized() + assert "compliance_rules" in p.to_dict() + + +def test_profile_from_dict_partial_not_specialized(): + p = profile_from_dict({"customer_id": "c1", "company_name": "X"}) + assert not p.is_specialized() + + +def test_profile_default_compliance_blocks_keywords(): + p = profile_from_dict({"customer_id": "c1"}) + rules = p.compliance_rules + assert "blocked_keywords" in rules + assert "ضمان 100" in rules["blocked_keywords"] + assert rules["no_cold_whatsapp_without_lawful_basis"] is True