mirror of
https://github.com/x1xhlol/system-prompts-and-models-of-ai-tools.git
synced 2026-06-18 23:39:34 +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,
|
ecosystem,
|
||||||
email_send,
|
email_send,
|
||||||
full_os,
|
full_os,
|
||||||
|
growth_operator,
|
||||||
health,
|
health,
|
||||||
innovation,
|
innovation,
|
||||||
leads,
|
leads,
|
||||||
@ -146,6 +147,7 @@ def create_app() -> FastAPI:
|
|||||||
app.include_router(innovation.router)
|
app.include_router(innovation.router)
|
||||||
app.include_router(business.router)
|
app.include_router(business.router)
|
||||||
app.include_router(personal_operator.router)
|
app.include_router(personal_operator.router)
|
||||||
|
app.include_router(growth_operator.router)
|
||||||
app.include_router(public.router)
|
app.include_router(public.router)
|
||||||
app.include_router(admin.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