mirror of
https://github.com/x1xhlol/system-prompts-and-models-of-ai-tools.git
synced 2026-06-18 07:19:35 +00:00
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:
parent
80e1fc3533
commit
8942c6e84c
@ -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)
|
||||
|
||||
|
||||
260
dealix/api/routers/growth_operator.py
Normal file
260
dealix/api/routers/growth_operator.py
Normal 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"]
|
||||
),
|
||||
}
|
||||
96
dealix/auto_client_acquisition/growth_operator/__init__.py
Normal file
96
dealix/auto_client_acquisition/growth_operator/__init__.py
Normal 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",
|
||||
]
|
||||
116
dealix/auto_client_acquisition/growth_operator/client_profile.py
Normal file
116
dealix/auto_client_acquisition/growth_operator/client_profile.py
Normal 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),
|
||||
)
|
||||
@ -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,
|
||||
}
|
||||
186
dealix/auto_client_acquisition/growth_operator/contactability.py
Normal file
186
dealix/auto_client_acquisition/growth_operator/contactability.py
Normal 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 — السياسة الافتراضية. "
|
||||
"العميل يقدر يعدل القاعدة لكل قائمة بعد توثيق المصدر."
|
||||
),
|
||||
}
|
||||
@ -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
|
||||
@ -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",
|
||||
}
|
||||
@ -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",
|
||||
}
|
||||
@ -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 جديد أو إحالة محتملة."
|
||||
),
|
||||
}
|
||||
@ -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'."
|
||||
),
|
||||
}
|
||||
162
dealix/auto_client_acquisition/growth_operator/proof_pack.py
Normal file
162
dealix/auto_client_acquisition/growth_operator/proof_pack.py
Normal 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 "
|
||||
"خارج هوية الشركة المشتركة."
|
||||
),
|
||||
}
|
||||
146
dealix/auto_client_acquisition/growth_operator/targeting.py
Normal file
146
dealix/auto_client_acquisition/growth_operator/targeting.py
Normal 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."
|
||||
),
|
||||
}
|
||||
351
dealix/docs/ARABIC_GROWTH_OPERATOR_FULL_SPEC.md
Normal file
351
dealix/docs/ARABIC_GROWTH_OPERATOR_FULL_SPEC.md
Normal 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 · 🇸🇦
|
||||
401
dealix/tests/unit/test_growth_operator.py
Normal file
401
dealix/tests/unit/test_growth_operator.py
Normal 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
|
||||
Loading…
Reference in New Issue
Block a user