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>
This commit is contained in:
Dealix Builder 2026-05-01 15:33:11 +03:00
parent 80e1fc3533
commit 8942c6e84c
15 changed files with 2710 additions and 0 deletions

View File

@ -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)

View File

@ -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"]
),
}

View File

@ -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",
]

View File

@ -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),
)

View File

@ -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,
}

View File

@ -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 — السياسة الافتراضية. "
"العميل يقدر يعدل القاعدة لكل قائمة بعد توثيق المصدر."
),
}

View File

@ -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

View File

@ -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",
}

View File

@ -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",
}

View File

@ -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 جديد أو إحالة محتملة."
),
}

View File

@ -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'."
),
}

View File

@ -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 "
"خارج هوية الشركة المشتركة."
),
}

View File

@ -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."
),
}

View File

@ -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 · 🇸🇦

View File

@ -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