From 4e969131c779dfc92d9a08beb58c3882b46592b4 Mon Sep 17 00:00:00 2001 From: Dealix Builder Date: Fri, 1 May 2026 16:05:12 +0300 Subject: [PATCH] =?UTF-8?q?feat(platform+intelligence):=20Growth=20Control?= =?UTF-8?q?=20Tower=20+=20Growth=20Neural=20Network=20=E2=80=94=2020=20mod?= =?UTF-8?q?ules=20+=2025=20endpoints=20+=2060=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Platform Services Layer (10 modules) — برج التحكم بالنمو - event_bus: 27 typed events (whatsapp/email/calendar/lead/payment/review/social/partner/sheet/crm/action) - identity_resolution: cross-channel merge (phone+email+CRM+social) with confidence scoring - channel_registry: 11 channels (WA, Gmail, Calendar, Moyasar, LinkedIn, X, IG, GBP, Sheets, CRM, Forms) with capabilities/risk/PDPL notes - action_policy: 9 rules (block_cold_whatsapp, block_payment_no_confirm, block_secrets, external_send_needs_approval, calendar_insert_needs_approval, social_dm_needs_explicit, unknown_source_review, high_value_deal_review, draft_only_safe) - tool_gateway: single execution chokepoint, env-flag-gated live actions (default OFF) - unified_inbox: 8 card types, ≤3 buttons enforced, Arabic - action_ledger: requested→approved→executed audit trail - proof_ledger: leads/meetings/drafts/sends/payments/revenue/risks_blocked/time_saved per channel - service_catalog: 12 sellable services - router api/routers/platform_services.py — 13 endpoints under /api/v1/platform/ Intelligence Layer (10 modules) — الشبكة العصبية للنمو - growth_brain: per-customer Brain + is_ready_for_autopilot() (≥30 signals + ≥40% accept) - command_feed: 9 daily card types (opportunity/revenue_leak/partner_suggestion/meeting_prep/review_response/competitive_move/customer_reactivation/ai_visibility_alert/action_required) - action_graph: 10 typed edges (signal→action→outcome) with what_works_summary - mission_engine: 7 missions, KILL FEATURE first_10_opportunities (10 فرص في 10 دقائق) - decision_memory: learns from accept/skip/edit/block, returns preferences (channels, tones, sectors, rejected actions, accept_rate) - trust_score: composite 0-100 (source+opt_in+channel+content+freq+approval) → safe/needs_review/blocked - revenue_dna: best_channel/segment/angle + common_objection + avg_cycle_days - opportunity_simulator: 9 Saudi sectors, expected_replies/meetings/deals/pipeline_sar + risk_score - competitive_moves: 8 move types with Arabic recommended_action_ar - board_brief: weekly Founder Shadow Board (3 decisions + 3 opportunities + 3 risks + relationship + experiment + metric) - router api/routers/intelligence_layer.py — 12 endpoints under /api/v1/intelligence/ Tests - tests/unit/test_platform_services.py — 31 tests covering catalog/channels/events/policy/gateway/identity/inbox/ledger/proof - tests/unit/test_intelligence_layer.py — 29 tests covering brain/feed/graph/missions/memory/trust/dna/simulator/competitive/brief - 60/60 new tests pass; full suite 587 passed, 2 skipped Docs - docs/PLATFORM_SERVICES_STRATEGY.md (Arabic) - docs/INTELLIGENCE_LAYER_STRATEGY.md (Arabic) - docs/DEALIX_100_PERCENT_LAUNCH_PLAN.md — added §32 Platform Services + §33 Intelligence Layer Safety - No live send by default (all WA/Gmail/Calendar/Moyasar guarded by env flags, all OFF) - All external actions go through Tool Gateway → Action Policy → draft/approval_required - No secrets allowed in payloads (block_secrets policy) - PDPL-aware: cold WhatsApp without consent is hard-blocked - Existing 477+ tests untouched (no breaking changes) Co-Authored-By: Claude Opus 4.7 (1M context) --- dealix/api/main.py | 4 + dealix/api/routers/intelligence_layer.py | 140 ++++++++ dealix/api/routers/platform_services.py | 203 ++++++++++++ .../intelligence_layer/__init__.py | 67 ++++ .../intelligence_layer/action_graph.py | 90 ++++++ .../intelligence_layer/board_brief.py | 55 ++++ .../intelligence_layer/command_feed.py | 92 ++++++ .../intelligence_layer/competitive_moves.py | 86 +++++ .../intelligence_layer/decision_memory.py | 95 ++++++ .../intelligence_layer/growth_brain.py | 80 +++++ .../intelligence_layer/mission_engine.py | 114 +++++++ .../opportunity_simulator.py | 89 ++++++ .../intelligence_layer/revenue_dna.py | 90 ++++++ .../intelligence_layer/trust_score.py | 102 ++++++ .../platform_services/__init__.py | 74 +++++ .../platform_services/action_ledger.py | 107 +++++++ .../platform_services/action_policy.py | 173 ++++++++++ .../platform_services/channel_registry.py | 213 +++++++++++++ .../platform_services/event_bus.py | 110 +++++++ .../platform_services/identity_resolution.py | 91 ++++++ .../platform_services/proof_ledger.py | 80 +++++ .../platform_services/service_catalog.py | 219 +++++++++++++ .../platform_services/tool_gateway.py | 193 ++++++++++++ .../platform_services/unified_inbox.py | 250 +++++++++++++++ dealix/docs/DEALIX_100_PERCENT_LAUNCH_PLAN.md | 30 ++ dealix/docs/INTELLIGENCE_LAYER_STRATEGY.md | 269 ++++++++++++++++ dealix/docs/PLATFORM_SERVICES_STRATEGY.md | 196 ++++++++++++ dealix/tests/unit/test_intelligence_layer.py | 281 +++++++++++++++++ dealix/tests/unit/test_platform_services.py | 298 ++++++++++++++++++ 29 files changed, 3891 insertions(+) create mode 100644 dealix/api/routers/intelligence_layer.py create mode 100644 dealix/api/routers/platform_services.py create mode 100644 dealix/auto_client_acquisition/intelligence_layer/__init__.py create mode 100644 dealix/auto_client_acquisition/intelligence_layer/action_graph.py create mode 100644 dealix/auto_client_acquisition/intelligence_layer/board_brief.py create mode 100644 dealix/auto_client_acquisition/intelligence_layer/command_feed.py create mode 100644 dealix/auto_client_acquisition/intelligence_layer/competitive_moves.py create mode 100644 dealix/auto_client_acquisition/intelligence_layer/decision_memory.py create mode 100644 dealix/auto_client_acquisition/intelligence_layer/growth_brain.py create mode 100644 dealix/auto_client_acquisition/intelligence_layer/mission_engine.py create mode 100644 dealix/auto_client_acquisition/intelligence_layer/opportunity_simulator.py create mode 100644 dealix/auto_client_acquisition/intelligence_layer/revenue_dna.py create mode 100644 dealix/auto_client_acquisition/intelligence_layer/trust_score.py create mode 100644 dealix/auto_client_acquisition/platform_services/__init__.py create mode 100644 dealix/auto_client_acquisition/platform_services/action_ledger.py create mode 100644 dealix/auto_client_acquisition/platform_services/action_policy.py create mode 100644 dealix/auto_client_acquisition/platform_services/channel_registry.py create mode 100644 dealix/auto_client_acquisition/platform_services/event_bus.py create mode 100644 dealix/auto_client_acquisition/platform_services/identity_resolution.py create mode 100644 dealix/auto_client_acquisition/platform_services/proof_ledger.py create mode 100644 dealix/auto_client_acquisition/platform_services/service_catalog.py create mode 100644 dealix/auto_client_acquisition/platform_services/tool_gateway.py create mode 100644 dealix/auto_client_acquisition/platform_services/unified_inbox.py create mode 100644 dealix/docs/INTELLIGENCE_LAYER_STRATEGY.md create mode 100644 dealix/docs/PLATFORM_SERVICES_STRATEGY.md create mode 100644 dealix/tests/unit/test_intelligence_layer.py create mode 100644 dealix/tests/unit/test_platform_services.py diff --git a/dealix/api/main.py b/dealix/api/main.py index bced2114..322b3802 100644 --- a/dealix/api/main.py +++ b/dealix/api/main.py @@ -30,9 +30,11 @@ from api.routers import ( growth_operator, health, innovation, + intelligence_layer, leads, outreach, personal_operator, + platform_services, pricing, prospect, public, @@ -148,6 +150,8 @@ def create_app() -> FastAPI: app.include_router(business.router) app.include_router(personal_operator.router) app.include_router(growth_operator.router) + app.include_router(platform_services.router) + app.include_router(intelligence_layer.router) app.include_router(public.router) app.include_router(admin.router) diff --git a/dealix/api/routers/intelligence_layer.py b/dealix/api/routers/intelligence_layer.py new file mode 100644 index 00000000..0eeb51cc --- /dev/null +++ b/dealix/api/routers/intelligence_layer.py @@ -0,0 +1,140 @@ +"""Intelligence Layer router — growth brain + missions + DNA + simulator + brief.""" + +from __future__ import annotations + +from typing import Any + +from fastapi import APIRouter, Body + +from auto_client_acquisition.intelligence_layer import ( + DecisionMemory, + analyze_competitive_move, + build_board_brief, + build_command_feed_demo, + build_growth_brain, + build_revenue_dna_demo, + compute_trust_score, + extract_revenue_dna, + learn_from_decision, + list_intel_missions, + recommend_missions, + simulate_opportunity, +) + +router = APIRouter(prefix="/api/v1/intelligence", tags=["intelligence-layer"]) + +# Per-customer in-memory decision memory (demo; production = Supabase) +_MEMORY: dict[str, DecisionMemory] = {} + + +def _memory_for(customer_id: str) -> DecisionMemory: + if customer_id not in _MEMORY: + _MEMORY[customer_id] = DecisionMemory(customer_id=customer_id) + return _MEMORY[customer_id] + + +# ── Growth Brain ────────────────────────────────────────────── +@router.post("/growth-brain/build") +async def growth_brain_build(payload: dict[str, Any] = Body(default_factory=dict)) -> dict[str, Any]: + brain = build_growth_brain(payload) + return {**brain.to_dict(), "ready_for_autopilot": brain.is_ready_for_autopilot()} + + +# ── Command Feed ────────────────────────────────────────────── +@router.get("/command-feed/demo") +async def command_feed_demo() -> dict[str, Any]: + return build_command_feed_demo() + + +# ── Missions ────────────────────────────────────────────────── +@router.get("/missions") +async def missions_list() -> dict[str, Any]: + return list_intel_missions() + + +@router.post("/missions/recommend") +async def missions_recommend(payload: dict[str, Any] = Body(default_factory=dict)) -> dict[str, Any]: + brain_payload = payload.get("growth_brain") or payload + brain = build_growth_brain(brain_payload) if brain_payload else None + return recommend_missions(brain, limit=int(payload.get("limit", 3))) + + +# ── Trust Score ─────────────────────────────────────────────── +@router.post("/trust-score") +async def trust_score(payload: dict[str, Any] = Body(...)) -> dict[str, Any]: + return compute_trust_score( + source_quality=payload.get("source_quality", "unknown"), + opt_in=bool(payload.get("opt_in", False)), + channel=payload.get("channel", "whatsapp"), + message_text=payload.get("message_text", ""), + frequency_count_this_week=int(payload.get("frequency_count_this_week", 0)), + weekly_cap=int(payload.get("weekly_cap", 2)), + approval_status=payload.get("approval_status", "pending"), + ) + + +# ── Revenue DNA ─────────────────────────────────────────────── +@router.get("/revenue-dna/demo") +async def revenue_dna_demo() -> dict[str, Any]: + return build_revenue_dna_demo() + + +@router.post("/revenue-dna") +async def revenue_dna_post(payload: dict[str, Any] = Body(...)) -> dict[str, Any]: + return extract_revenue_dna( + customer_id=payload.get("customer_id", "unknown"), + won_deals=payload.get("won_deals", []), + replies=payload.get("replies", []), + objections=payload.get("objections", []), + ) + + +# ── Opportunity Simulator ───────────────────────────────────── +@router.post("/simulate-opportunity") +async def simulate_opportunity_endpoint(payload: dict[str, Any] = Body(...)) -> dict[str, Any]: + return simulate_opportunity( + target_count=int(payload.get("target_count", 100)), + sector=payload.get("sector", "saas"), + avg_deal_value_sar=float(payload.get("avg_deal_value_sar", 25_000)), + channel=payload.get("channel", "whatsapp"), + cold_pct=float(payload.get("cold_pct", 0)), + quality_lift=float(payload.get("quality_lift", 1.0)), + ) + + +# ── Competitive Moves ───────────────────────────────────────── +@router.post("/competitive-move/analyze") +async def competitive_move_analyze(payload: dict[str, Any] = Body(...)) -> dict[str, Any]: + return analyze_competitive_move( + competitor_name=payload.get("competitor_name", "?"), + move_type=payload.get("move_type", "new_offer"), + payload=payload.get("payload", {}), + ) + + +# ── Board Brief ─────────────────────────────────────────────── +@router.get("/board-brief/demo") +async def board_brief_demo() -> dict[str, Any]: + return build_board_brief() + + +# ── Decision Memory ─────────────────────────────────────────── +@router.post("/decisions/record") +async def decisions_record(payload: dict[str, Any] = Body(...)) -> dict[str, Any]: + customer_id = payload.get("customer_id", "demo") + mem = _memory_for(customer_id) + return learn_from_decision( + memory=mem, + decision=payload.get("decision", "skip"), + action_type=payload.get("action_type", "send_whatsapp"), + channel=payload.get("channel", "whatsapp"), + sector=payload.get("sector"), + tone=payload.get("tone"), + objection_id=payload.get("objection_id"), + ) + + +@router.get("/decisions/preferences") +async def decisions_preferences(customer_id: str) -> dict[str, Any]: + mem = _memory_for(customer_id) + return {"customer_id": customer_id, "preferences": mem.preferences()} diff --git a/dealix/api/routers/platform_services.py b/dealix/api/routers/platform_services.py new file mode 100644 index 00000000..c9af4d2b --- /dev/null +++ b/dealix/api/routers/platform_services.py @@ -0,0 +1,203 @@ +"""Platform Services router — channel registry + events + inbox + policy + proof.""" + +from __future__ import annotations + +from typing import Any + +from fastapi import APIRouter, Body, Query + +from auto_client_acquisition.platform_services import ( + ALL_CHANNELS, + POLICY_RULES, + SELLABLE_SERVICES, + build_card_from_event, + build_demo_feed, + build_demo_platform_proof, + evaluate_action, + get_channel, + invoke_tool, + list_services, + make_event, + resolve_identity, +) +from auto_client_acquisition.platform_services.action_ledger import ActionLedger +from auto_client_acquisition.platform_services.channel_registry import channels_summary + +router = APIRouter(prefix="/api/v1/platform", tags=["platform-services"]) + +_LEDGER = ActionLedger() + + +# ── Catalog ──────────────────────────────────────────────────── +@router.get("/services/catalog") +async def services_catalog() -> dict[str, Any]: + return list_services() + + +@router.get("/channels") +async def channels() -> dict[str, Any]: + return { + "summary": channels_summary(), + "channels": [ + { + "key": c.key, "label_ar": c.label_ar, "label_en": c.label_en, + "capabilities": list(c.capabilities), "beta_status": c.beta_status, + "required_permissions": list(c.required_permissions), + "allowed_actions": list(c.allowed_actions), + "blocked_actions": list(c.blocked_actions), + "risk_level": c.risk_level, "notes_ar": c.notes_ar, + } + for c in ALL_CHANNELS + ], + } + + +@router.get("/channels/{channel_key}") +async def channel_detail(channel_key: str) -> dict[str, Any]: + c = get_channel(channel_key) + if c is None: + return {"error": f"unknown channel: {channel_key}"} + return { + "key": c.key, "label_ar": c.label_ar, "label_en": c.label_en, + "capabilities": list(c.capabilities), "beta_status": c.beta_status, + "required_permissions": list(c.required_permissions), + "allowed_actions": list(c.allowed_actions), + "blocked_actions": list(c.blocked_actions), + "risk_level": c.risk_level, "notes_ar": c.notes_ar, + } + + +# ── Policy ───────────────────────────────────────────────────── +@router.get("/policy/rules") +async def policy_rules() -> dict[str, Any]: + return {"count": len(POLICY_RULES), "rules": POLICY_RULES} + + +@router.post("/actions/evaluate") +async def actions_evaluate( + action: str = Body(..., embed=True), + context: dict[str, Any] = Body(default_factory=dict, embed=True), +) -> dict[str, Any]: + d = evaluate_action(action=action, context=context) + return { + "decision": d.decision, + "matched_rule_id": d.matched_rule_id, + "reasons_ar": d.reasons_ar, + "suggested_next_action_ar": d.suggested_next_action_ar, + } + + +@router.post("/actions/approve") +async def actions_approve( + customer_id: str = Body(..., embed=True), + action_type: str = Body(..., embed=True), + channel: str = Body(..., embed=True), + actor: str = Body(default="user", embed=True), + payload: dict[str, Any] = Body(default_factory=dict, embed=True), + correlation_id: str | None = Body(default=None, embed=True), +) -> dict[str, Any]: + entry = _LEDGER.append( + customer_id=customer_id, + action_type=action_type, + channel=channel, + stage="approved", + actor=actor, + payload=payload, + correlation_id=correlation_id, + ) + return {"approved": True, "entry": entry.to_dict()} + + +@router.get("/ledger/summary") +async def ledger_summary(customer_id: str = Query(...)) -> dict[str, Any]: + return _LEDGER.summary(customer_id=customer_id) + + +# ── Events + Inbox ───────────────────────────────────────────── +@router.post("/events/ingest") +async def events_ingest( + event_type: str = Body(..., embed=True), + channel: str = Body(..., embed=True), + customer_id: str = Body(..., embed=True), + payload: dict[str, Any] = Body(default_factory=dict, embed=True), +) -> dict[str, Any]: + try: + evt = make_event( + event_type=event_type, channel=channel, + customer_id=customer_id, payload=payload, + ) + except ValueError as exc: + return {"error": str(exc)} + card = build_card_from_event(evt) + return { + "event": evt.to_dict(), + "card": card.to_dict() if card else None, + "actionable": card is not None, + } + + +@router.get("/inbox/feed") +async def inbox_feed() -> dict[str, Any]: + """Demo unified-inbox feed; production version reads from event store.""" + return build_demo_feed() + + +# ── Identity + Tool gateway ─────────────────────────────────── +@router.post("/identity/resolve") +async def identity_resolve( + signals: list[dict[str, Any]] = Body(..., embed=True), +) -> dict[str, Any]: + out = resolve_identity(signals=signals) + return { + "identity_id": out.identity_id, + "primary_phone": out.primary_phone, + "primary_email": out.primary_email, + "company": out.company, + "crm_id": out.crm_id, + "social_handles": out.social_handles, + "confidence": out.confidence, + "sources": out.sources, + } + + +@router.get("/identity/resolve-demo") +async def identity_resolve_demo() -> dict[str, Any]: + """Sample multi-source identity resolution.""" + out = resolve_identity(signals=[ + {"phone": "+966500000001", "company": "شركة العقار الذهبي", "source": "whatsapp"}, + {"email": "ali@example.sa", "company": "شركة العقار الذهبي", "source": "gmail"}, + {"crm_id": "crm_5421", "company": "شركة العقار الذهبي", "source": "crm"}, + {"social_handles": {"linkedin": "ali-realestate"}, "source": "linkedin_lead_forms"}, + ]) + return { + "identity_id": out.identity_id, + "primary_phone": out.primary_phone, + "primary_email": out.primary_email, + "company": out.company, + "crm_id": out.crm_id, + "social_handles": out.social_handles, + "confidence": out.confidence, + "sources": out.sources, + } + + +@router.post("/tools/invoke") +async def tools_invoke( + tool: str = Body(..., embed=True), + payload: dict[str, Any] = Body(default_factory=dict, embed=True), + context: dict[str, Any] = Body(default_factory=dict, embed=True), +) -> dict[str, Any]: + r = invoke_tool(tool=tool, payload=payload, context=context) + return { + "status": r.status, + "tool": r.tool, + "matched_policy_rule": r.matched_policy_rule, + "reasons_ar": r.reasons_ar, + "next_action_ar": r.next_action_ar, + } + + +# ── Proof ────────────────────────────────────────────────────── +@router.get("/proof-ledger/demo") +async def proof_ledger_demo() -> dict[str, Any]: + return build_demo_platform_proof().to_dict() diff --git a/dealix/auto_client_acquisition/intelligence_layer/__init__.py b/dealix/auto_client_acquisition/intelligence_layer/__init__.py new file mode 100644 index 00000000..cf85dc9e --- /dev/null +++ b/dealix/auto_client_acquisition/intelligence_layer/__init__.py @@ -0,0 +1,67 @@ +""" +Intelligence Layer — the decision brain on top of platform_services. + +Turns Dealix from "channels + actions" into a **Growth Neural Network**: +the system understands the customer fully, watches the market, decides, +executes (with approval), and learns from every outcome. + +Modules: + - growth_brain : per-customer brain (context + preferences + priorities) + - command_feed : Arabic decision cards (what to do now) + - action_graph : signal→action→outcome typed relationships + - mission_engine : 7 outcome-shaped missions (durable workflows) + - decision_memory : learns from Accept/Skip/Edit signals + - trust_score : per-action safety verdict (safe/review/blocked) + - revenue_dna : best-channel/segment/angle/objection per customer + - opportunity_simulator: forward simulation before sending + - competitive_moves : detect + respond to competitor signals + - board_brief : weekly founder/board-ready brief +""" + +from auto_client_acquisition.intelligence_layer.action_graph import ( + ActionEdge, + ActionGraph, + EDGE_TYPES, +) +from auto_client_acquisition.intelligence_layer.board_brief import build_board_brief +from auto_client_acquisition.intelligence_layer.command_feed import ( + INTEL_CARD_TYPES, + build_command_feed_demo, +) +from auto_client_acquisition.intelligence_layer.competitive_moves import ( + analyze_competitive_move, +) +from auto_client_acquisition.intelligence_layer.decision_memory import ( + DecisionMemory, + learn_from_decision, +) +from auto_client_acquisition.intelligence_layer.growth_brain import ( + GrowthBrain, + build_growth_brain, +) +from auto_client_acquisition.intelligence_layer.mission_engine import ( + INTEL_MISSIONS, + list_intel_missions, + recommend_missions, +) +from auto_client_acquisition.intelligence_layer.opportunity_simulator import ( + simulate_opportunity, +) +from auto_client_acquisition.intelligence_layer.revenue_dna import ( + build_revenue_dna_demo, + extract_revenue_dna, +) +from auto_client_acquisition.intelligence_layer.trust_score import compute_trust_score + +__all__ = [ + "GrowthBrain", "build_growth_brain", + "INTEL_CARD_TYPES", "build_command_feed_demo", + "ActionGraph", "ActionEdge", "EDGE_TYPES", + "INTEL_MISSIONS", "list_intel_missions", "recommend_missions", + "DecisionMemory", "learn_from_decision", + "compute_trust_score", + "extract_revenue_dna", "build_revenue_dna_demo", + "simulate_opportunity", + "analyze_competitive_move", + "build_board_brief", +] diff --git a/dealix/auto_client_acquisition/intelligence_layer/action_graph.py b/dealix/auto_client_acquisition/intelligence_layer/action_graph.py new file mode 100644 index 00000000..1abc792e --- /dev/null +++ b/dealix/auto_client_acquisition/intelligence_layer/action_graph.py @@ -0,0 +1,90 @@ +"""Action Graph — typed signal→action→approval→outcome→proof relationships.""" + +from __future__ import annotations + +import uuid +from dataclasses import dataclass, field +from datetime import datetime, timezone +from typing import Any + + +EDGE_TYPES: tuple[str, ...] = ( + "signal_created_opportunity", + "message_triggered_reply", + "reply_created_meeting", + "meeting_created_followup", + "followup_influenced_payment", + "objection_required_proof", + "partner_introduced_customer", + "review_created_recovery_task", + "approval_allowed_send", + "blocked_action_prevented_risk", +) + + +@dataclass +class ActionEdge: + """One typed edge in the action graph.""" + + edge_id: str + edge_type: str + src_id: str + dst_id: str + customer_id: str + occurred_at: datetime + payload: dict[str, Any] = field(default_factory=dict) + + def to_dict(self) -> dict[str, Any]: + return { + "edge_id": self.edge_id, + "edge_type": self.edge_type, + "src_id": self.src_id, + "dst_id": self.dst_id, + "customer_id": self.customer_id, + "occurred_at": self.occurred_at.isoformat(), + "payload": self.payload, + } + + +@dataclass +class ActionGraph: + """In-memory action graph for the customer's decision history.""" + + edges: list[ActionEdge] = field(default_factory=list) + + def add_edge( + self, + *, + edge_type: str, + src_id: str, + dst_id: str, + customer_id: str, + payload: dict[str, Any] | None = None, + ) -> ActionEdge: + if edge_type not in EDGE_TYPES: + raise ValueError(f"unknown edge_type: {edge_type}") + e = ActionEdge( + edge_id=f"edge_{uuid.uuid4().hex[:16]}", + edge_type=edge_type, + src_id=src_id, + dst_id=dst_id, + customer_id=customer_id, + occurred_at=datetime.now(timezone.utc).replace(tzinfo=None), + payload=payload or {}, + ) + self.edges.append(e) + return e + + def what_works_summary(self, customer_id: str) -> dict[str, Any]: + """Roll-up: which signal types led to outcomes?""" + by_type: dict[str, int] = {} + for e in self.edges: + if e.customer_id != customer_id: + continue + by_type[e.edge_type] = by_type.get(e.edge_type, 0) + 1 + winning = sorted(by_type.items(), key=lambda x: x[1], reverse=True) + return { + "total_edges": sum(by_type.values()), + "by_edge_type": by_type, + "top_winning_relationships": winning[:5], + } diff --git a/dealix/auto_client_acquisition/intelligence_layer/board_brief.py b/dealix/auto_client_acquisition/intelligence_layer/board_brief.py new file mode 100644 index 00000000..015d3141 --- /dev/null +++ b/dealix/auto_client_acquisition/intelligence_layer/board_brief.py @@ -0,0 +1,55 @@ +"""Founder Shadow Board — weekly brief for founder/board.""" + +from __future__ import annotations + +from typing import Any + + +def build_board_brief( + *, + customer_id: str = "demo", + customer_name: str = "Demo Saudi B2B Co.", + week_label: str = "May W1 2026", + pipeline_added_sar: float = 185_000, + revenue_won_sar: float = 30_000, + meetings_booked: int = 14, + risks_blocked: int = 21, + leak_recovered_sar: float = 12_000, +) -> dict[str, Any]: + """Generate the founder/board-ready weekly brief.""" + return { + "customer_id": customer_id, + "customer_name": customer_name, + "week_label": week_label, + "decisions_required_ar": [ + "اعتماد رفع price على الـ Growth tier 10% — منافس رفع 15%.", + "الموافقة على Partnership Sprint مع وكالة B2B في جدة.", + "اختيار pilot vertical للشهر القادم (clinics vs training).", + ], + "top_opportunities_ar": [ + f"شركة العقار الذهبي — اجتماع غداً ({250_000:,} ريال محتمل).", + f"3 leads inbound من LinkedIn Lead Forms ({36_000:,} ريال).", + f"Reactivation campaign على 12 عميل خامل ({80_000:,} ريال).", + ], + "top_risks_ar": [ + "صفقة 250K معرضة (single-threaded) — تحتاج multi-thread.", + "تأخر في الرد على 7 leads خلال 72+ ساعة.", + "تقييم Google 2-نجوم بدون رد — يحتاج ≤24 ساعة.", + ], + "key_relationship_ar": ( + "خالد ع. (شريك في وكالة B2B جدة) — اقترح اجتماع 20 دقيقة الأسبوع القادم." + ), + "experiment_to_run_ar": ( + "اختبر رسالة قصيرة (≤4 سطور) بدلاً من النسخة الحالية على قطاع real_estate." + ), + "metric_to_watch_ar": ( + f"approve_rate الأسبوعي: الهدف ≥45% (آخر أسبوع 38%)." + ), + "money_summary": { + "pipeline_added_sar": pipeline_added_sar, + "revenue_won_sar": revenue_won_sar, + "leak_recovered_sar": leak_recovered_sar, + "risks_blocked_count": risks_blocked, + "meetings_booked": meetings_booked, + }, + } diff --git a/dealix/auto_client_acquisition/intelligence_layer/command_feed.py b/dealix/auto_client_acquisition/intelligence_layer/command_feed.py new file mode 100644 index 00000000..1162125b --- /dev/null +++ b/dealix/auto_client_acquisition/intelligence_layer/command_feed.py @@ -0,0 +1,92 @@ +"""Intelligence Command Feed — Arabic decision cards with ≤3 buttons.""" + +from __future__ import annotations + +from typing import Any + +INTEL_CARD_TYPES: tuple[str, ...] = ( + "opportunity", + "revenue_leak", + "approval_needed", + "meeting_prep", + "payment_followup", + "partner_suggestion", + "social_signal", + "review_response", + "competitive_move", +) + + +def build_command_feed_demo() -> dict[str, Any]: + """Deterministic Arabic command feed for demo + tests.""" + cards = [ + { + "type": "opportunity", + "title_ar": "فرصة نمو — شركة تدريب في الرياض", + "summary_ar": "نشروا 3 وظائف مبيعات جديدة → توسع واضح في فريق المبيعات.", + "why_it_matters_ar": "التوسع = ميزانية = نافذة شراء ≤30 يوم.", + "recommended_action_ar": "رسالة قصيرة تعرض تجربة 7 أيام.", + "expected_impact_sar": 18_000, + "risk_level": "low", + "buttons_ar": ("قبول", "تخطّي", "اكتب رسالة"), + }, + { + "type": "revenue_leak", + "title_ar": "تسريب إيراد — 7 leads بلا متابعة", + "summary_ar": "آخر تواصل قبل 72+ ساعة. الردود تتراجع 14%/ساعة.", + "why_it_matters_ar": "الإهمال خسارة pipeline متراكمة.", + "recommended_action_ar": "اعتمد 7 follow-ups جاهزة.", + "expected_impact_sar": 42_000, + "risk_level": "medium", + "buttons_ar": ("اعتمد", "عدّل", "تخطّي"), + }, + { + "type": "partner_suggestion", + "title_ar": "فرصة شراكة — وكالة B2B في جدة", + "summary_ar": "عملاؤها يحتاجون lead-gen → Dealix يكمل خدماتها.", + "why_it_matters_ar": "الشراكة الواحدة تفتح 3-5 leads warmer.", + "recommended_action_ar": "رسالة partnership warm + اقتراح pilot.", + "expected_impact_sar": 60_000, + "risk_level": "low", + "buttons_ar": ("اكتب رسالة", "احجز اجتماع", "تخطّي"), + }, + { + "type": "meeting_prep", + "title_ar": "اجتماع غداً مع شركة العقار الذهبي", + "summary_ar": "جاهز: ملف الشركة + 5 أسئلة + 3 اعتراضات + عرض مناسب.", + "why_it_matters_ar": "الاجتماع المُحضَّر يرفع الإغلاق 40%+.", + "recommended_action_ar": "افتح التحضير + راجع الأجندة.", + "expected_impact_sar": 250_000, + "risk_level": "low", + "buttons_ar": ("افتح التحضير", "اكتب أجندة", "أرسل تأكيد"), + }, + { + "type": "review_response", + "title_ar": "تقييم Google جديد — 2 نجوم", + "summary_ar": "العميل اشتكى من التأخر في الرد.", + "why_it_matters_ar": "تقييم سلبي بدون رد ≤24 ساعة يضرّ السمعة المحلية.", + "recommended_action_ar": "اعتذار قصير + طلب تواصل + حل.", + "expected_impact_sar": 1_000, + "risk_level": "high", + "buttons_ar": ("اعتمد الرد", "صعّد للمدير", "تخطّي"), + }, + { + "type": "competitive_move", + "title_ar": "منافس أطلق pricing جديد", + "summary_ar": "خفّضوا 15% على باقة Growth — يستهدفون نفس عملاءك.", + "why_it_matters_ar": "الردود السريعة تحفظ الـ pipeline.", + "recommended_action_ar": "حملة مضادة + ROI breakdown مقارن.", + "expected_impact_sar": 80_000, + "risk_level": "medium", + "buttons_ar": ("جهّز رد", "نبّه المبيعات", "تخطّي"), + }, + ] + # Validate constraints + for c in cards: + assert c["type"] in INTEL_CARD_TYPES + assert len(c["buttons_ar"]) <= 3 + return { + "feed_size": len(cards), + "cards": cards, + "policy_note_ar": "كل كرت عربي + ≤3 buttons + approval-aware.", + } diff --git a/dealix/auto_client_acquisition/intelligence_layer/competitive_moves.py b/dealix/auto_client_acquisition/intelligence_layer/competitive_moves.py new file mode 100644 index 00000000..5f099327 --- /dev/null +++ b/dealix/auto_client_acquisition/intelligence_layer/competitive_moves.py @@ -0,0 +1,86 @@ +"""Competitive Move Detector — analyze competitor activity → suggest action.""" + +from __future__ import annotations + +from typing import Any + + +MOVE_TYPES: tuple[str, ...] = ( + "price_change", + "new_offer", + "hiring", + "event", + "content_campaign", + "rebrand", + "funding", + "expansion", +) + + +def analyze_competitive_move( + *, + competitor_name: str, + move_type: str, + payload: dict[str, Any] | None = None, +) -> dict[str, Any]: + """ + Take one observed competitor signal → return Arabic recommended action. + + Pure deterministic; no live competitor scraping. + """ + p = payload or {} + if move_type not in MOVE_TYPES: + return { + "error": f"unknown move_type: {move_type}", + "valid_types": list(MOVE_TYPES), + } + + if move_type == "price_change": + delta_pct = float(p.get("price_delta_pct", -10)) + action_ar = ( + "حملة مضادة + ROI breakdown مقارن — لا تخفّض السعر." + if delta_pct < 0 else + "ميزة تنافسية: عرضنا أرخص — اطلق ROI proof." + ) + urgency = "high" if abs(delta_pct) >= 15 else "medium" + elif move_type == "new_offer": + action_ar = ( + "حلّل العرض الجديد + اقتباس مزاياك المختلفة + offer comparison." + ) + urgency = "medium" + elif move_type == "hiring": + action_ar = ( + "إشارة توسع — استهدف نفس عملائهم بعرضك المختلف." + ) + urgency = "low" + elif move_type == "event": + action_ar = ( + "حضّر أنت محتوى/ندوة في نفس الفترة — استفد من اهتمام السوق." + ) + urgency = "medium" + elif move_type == "content_campaign": + action_ar = ( + "اقرأ زاويتهم + اطلق رد منشور / dialog بحجة مدعومة بأرقام." + ) + urgency = "low" + elif move_type == "rebrand": + action_ar = "احتفظ بهويتك — أعلن استمرار وعدك للعملاء." + urgency = "low" + elif move_type == "funding": + action_ar = ( + "إشارة سرعة في السوق — ركّز على retention + speed-to-value." + ) + urgency = "medium" + else: # expansion + action_ar = "نبّه فريق المبيعات + رسالة احتفاظ للعملاء الكبار." + urgency = "medium" + + return { + "competitor_name": competitor_name, + "move_type": move_type, + "urgency": urgency, + "recommended_action_ar": action_ar, + "next_step_ar": "جهّز draft رد + موافقة المشغّل قبل الإطلاق.", + "approval_required": True, + "payload_received": p, + } diff --git a/dealix/auto_client_acquisition/intelligence_layer/decision_memory.py b/dealix/auto_client_acquisition/intelligence_layer/decision_memory.py new file mode 100644 index 00000000..2a25b820 --- /dev/null +++ b/dealix/auto_client_acquisition/intelligence_layer/decision_memory.py @@ -0,0 +1,95 @@ +"""Decision Memory — learn the operator's preferences from Accept/Skip/Edit.""" + +from __future__ import annotations + +from collections import Counter +from dataclasses import dataclass, field +from typing import Any + + +VALID_DECISIONS: tuple[str, ...] = ("accept", "skip", "edit", "block") + + +@dataclass +class DecisionMemory: + """Per-customer Accept/Skip/Edit history and aggregates.""" + + customer_id: str + raw_decisions: list[dict[str, Any]] = field(default_factory=list) + + def append( + self, + *, + decision: str, + action_type: str, + channel: str, + sector: str | None = None, + tone: str | None = None, + objection_id: str | None = None, + ) -> None: + if decision not in VALID_DECISIONS: + raise ValueError(f"unknown decision: {decision}") + self.raw_decisions.append({ + "decision": decision, + "action_type": action_type, + "channel": channel, + "sector": sector, + "tone": tone, + "objection_id": objection_id, + }) + + def preferences(self) -> dict[str, Any]: + if not self.raw_decisions: + return { + "samples": 0, + "preferred_channels": [], + "preferred_tones": [], + "preferred_sectors": [], + "rejected_action_types": [], + "accept_rate": 0.0, + } + ch_counter: Counter[str] = Counter() + tone_counter: Counter[str] = Counter() + sector_counter: Counter[str] = Counter() + rejected: Counter[str] = Counter() + accepts = 0 + for d in self.raw_decisions: + if d["decision"] == "accept": + accepts += 1 + ch_counter[d.get("channel", "")] += 1 + if d.get("tone"): + tone_counter[d["tone"]] += 1 + if d.get("sector"): + sector_counter[d["sector"]] += 1 + elif d["decision"] in ("skip", "block"): + rejected[d.get("action_type", "")] += 1 + return { + "samples": len(self.raw_decisions), + "preferred_channels": [c for c, _ in ch_counter.most_common(3)], + "preferred_tones": [t for t, _ in tone_counter.most_common(2)], + "preferred_sectors": [s for s, _ in sector_counter.most_common(3)], + "rejected_action_types": [a for a, _ in rejected.most_common(3) if a], + "accept_rate": round(accepts / len(self.raw_decisions), 4), + } + + +def learn_from_decision( + *, + memory: DecisionMemory, + decision: str, + action_type: str, + channel: str, + sector: str | None = None, + tone: str | None = None, + objection_id: str | None = None, +) -> dict[str, Any]: + """Record a decision + return updated preferences.""" + memory.append( + decision=decision, action_type=action_type, channel=channel, + sector=sector, tone=tone, objection_id=objection_id, + ) + return { + "customer_id": memory.customer_id, + "added": True, + "preferences": memory.preferences(), + } diff --git a/dealix/auto_client_acquisition/intelligence_layer/growth_brain.py b/dealix/auto_client_acquisition/intelligence_layer/growth_brain.py new file mode 100644 index 00000000..773c9028 --- /dev/null +++ b/dealix/auto_client_acquisition/intelligence_layer/growth_brain.py @@ -0,0 +1,80 @@ +"""Growth Brain — per-customer context + preferences + priorities.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + + +@dataclass +class GrowthBrain: + """The customer's growth context as a single object.""" + + customer_id: str + company_context: dict[str, Any] + channels_connected: tuple[str, ...] + target_segments: tuple[str, ...] + approved_actions: tuple[str, ...] + blocked_actions: tuple[str, ...] + growth_priorities: tuple[str, ...] + risk_tolerance: str = "medium" # low / medium / high + preferred_tone: str = "warm" # formal / warm / direct + accept_rate_30d: float = 0.0 + avg_response_minutes: int = 0 + learning_signal_count: int = 0 + + def to_dict(self) -> dict[str, Any]: + return { + "customer_id": self.customer_id, + "company_context": self.company_context, + "channels_connected": list(self.channels_connected), + "target_segments": list(self.target_segments), + "approved_actions": list(self.approved_actions), + "blocked_actions": list(self.blocked_actions), + "growth_priorities": list(self.growth_priorities), + "risk_tolerance": self.risk_tolerance, + "preferred_tone": self.preferred_tone, + "accept_rate_30d": self.accept_rate_30d, + "avg_response_minutes": self.avg_response_minutes, + "learning_signal_count": self.learning_signal_count, + } + + def is_ready_for_autopilot(self) -> bool: + """≥30 learned signals + ≥40% accept rate + non-empty channels.""" + return ( + self.learning_signal_count >= 30 + and self.accept_rate_30d >= 0.40 + and len(self.channels_connected) > 0 + ) + + +def build_growth_brain(payload: dict[str, Any] | None = None) -> GrowthBrain: + """Build a brain from a customer payload — sane Saudi-B2B defaults.""" + p = payload or {} + return GrowthBrain( + customer_id=str(p.get("customer_id") or "demo"), + company_context={ + "company_name": p.get("company_name", "Demo Saudi B2B Co."), + "sector": p.get("sector", "real_estate"), + "city": p.get("city", "الرياض"), + "offer_one_liner": p.get("offer_one_liner", "تشغيل نمو B2B سعودي"), + "ideal_customer": p.get("ideal_customer", "شركات SMB سعودية"), + "average_deal_size_sar": float(p.get("average_deal_size_sar", 25_000)), + }, + channels_connected=tuple(p.get("channels_connected", ("whatsapp",))), + target_segments=tuple(p.get("target_segments", ("inbound_lead", "existing_customer"))), + approved_actions=tuple(p.get("approved_actions", ( + "create_draft", "send_with_approval", "ingest_lead", + ))), + blocked_actions=tuple(p.get("blocked_actions", ( + "cold_send_without_consent", "charge_card_without_user_action", + ))), + growth_priorities=tuple(p.get("growth_priorities", ( + "fill_pipeline", "improve_response_time", "build_partner_channel", + ))), + risk_tolerance=p.get("risk_tolerance", "medium"), + preferred_tone=p.get("preferred_tone", "warm"), + accept_rate_30d=float(p.get("accept_rate_30d", 0.0)), + avg_response_minutes=int(p.get("avg_response_minutes", 0)), + learning_signal_count=int(p.get("learning_signal_count", 0)), + ) diff --git a/dealix/auto_client_acquisition/intelligence_layer/mission_engine.py b/dealix/auto_client_acquisition/intelligence_layer/mission_engine.py new file mode 100644 index 00000000..05d60cbc --- /dev/null +++ b/dealix/auto_client_acquisition/intelligence_layer/mission_engine.py @@ -0,0 +1,114 @@ +"""Intelligence Mission Engine — 7 outcome-shaped growth missions.""" + +from __future__ import annotations + +from typing import Any + +from auto_client_acquisition.intelligence_layer.growth_brain import GrowthBrain + + +INTEL_MISSIONS: tuple[dict[str, Any], ...] = ( + { + "id": "first_10_opportunities", + "title_ar": "10 فرص في 10 دقائق", + "goal_ar": "اكتشاف 10 شركات سعودية + رسائل عربية + موافقة + متابعة أسبوع.", + "kill_metric": "ten_drafts_approved", + "required_integrations": ("whatsapp",), + "safety_rules_ar": ("لا cold WhatsApp بدون lawful basis",), + "success_metrics": ("approve_rate ≥ 50%", "first_reply ≤ 24h"), + }, + { + "id": "revenue_leak_rescue", + "title_ar": "أنقذ الإيراد الضائع", + "goal_ar": "اقرأ Email/CRM/WhatsApp → استخرج leads ضائعة → drafts متابعة.", + "kill_metric": "leads_revived", + "required_integrations": ("gmail", "crm"), + "safety_rules_ar": ("approval لكل follow-up",), + "success_metrics": ("rescued_leads ≥ 5", "rescued_pipeline_sar ≥ 30000"), + }, + { + "id": "partnership_sprint", + "title_ar": "ابدأ قناة شراكات", + "goal_ar": "تحديد + التواصل مع 5 شركاء محتملين خلال 14 يوم.", + "kill_metric": "partner_intros_replied", + "required_integrations": ("gmail", "google_calendar"), + "safety_rules_ar": ("لا outreach شخصي بدون warm context",), + "success_metrics": ("intros_replied ≥ 2", "first_partner_meeting ≤ 14d"), + }, + { + "id": "customer_reactivation", + "title_ar": "استرجع العملاء الخاملين", + "goal_ar": "ارفع قائمة قدامى → صنّفهم → رسائل عودة بـ payment link.", + "kill_metric": "reactivated_customers", + "required_integrations": ("whatsapp", "moyasar"), + "safety_rules_ar": ("Opt-in موثق فقط",), + "success_metrics": ("reactivated ≥ 10", "revenue_sar ≥ 25000"), + }, + { + "id": "meeting_booking_sprint", + "title_ar": "احجز 3 اجتماعات", + "goal_ar": "Top-10 leads → agenda → موافقة → calendar drafts.", + "kill_metric": "meetings_confirmed", + "required_integrations": ("google_calendar", "whatsapp"), + "safety_rules_ar": ("لا insert بدون OAuth + ضغطة المستخدم",), + "success_metrics": ("meetings_confirmed ≥ 3 / 5d",), + }, + { + "id": "ai_visibility_sprint", + "title_ar": "AEO Sprint — اظهر في إجابات AI", + "goal_ar": "تحليل ظهور الشركة + خطة محتوى 30 يوم لـ ChatGPT/Gemini/Perplexity.", + "kill_metric": "questions_visible", + "required_integrations": ("google_business_profile",), + "safety_rules_ar": ("لا scraping خارج المسموح",), + "success_metrics": ("question_visibility_lift ≥ 30%",), + }, + { + "id": "competitive_response", + "title_ar": "الرد على حركة منافس", + "goal_ar": "رصد price change/offer/hiring → ردود + حملات + ROI breakdown.", + "kill_metric": "competitor_signals_resolved", + "required_integrations": (), + "safety_rules_ar": ("لا تشهير", "لا اتهام عام",), + "success_metrics": ("retention_lift", "win_rate_lift"), + }, +) + + +def list_intel_missions() -> dict[str, Any]: + return { + "count": len(INTEL_MISSIONS), + "missions": list(INTEL_MISSIONS), + "kill_feature_id": "first_10_opportunities", + } + + +def recommend_missions(brain: GrowthBrain | None = None, *, limit: int = 3) -> dict[str, Any]: + """Pick top-N missions for this customer based on brain state.""" + if brain is None: + recommended = list(INTEL_MISSIONS)[:limit] + else: + # Simple heuristic: kill feature first, then prioritize by integrations + ranked: list[tuple[dict, float]] = [] + for m in INTEL_MISSIONS: + score = 50.0 + if m["id"] == "first_10_opportunities": + score += 50 # always priority for new customers + req = set(m["required_integrations"]) + connected = set(brain.channels_connected) + if req.issubset(connected): + score += 20 + else: + score -= 10 * (len(req - connected)) + if "fill_pipeline" in brain.growth_priorities and m["id"] in ( + "first_10_opportunities", "revenue_leak_rescue" + ): + score += 15 + if "build_partner_channel" in brain.growth_priorities and m["id"] == "partnership_sprint": + score += 15 + ranked.append((m, score)) + ranked.sort(key=lambda x: x[1], reverse=True) + recommended = [m for m, _ in ranked[:limit]] + return { + "recommended": recommended, + "rationale_ar": "تم الترتيب حسب priorities العميل + القنوات المربوطة.", + } diff --git a/dealix/auto_client_acquisition/intelligence_layer/opportunity_simulator.py b/dealix/auto_client_acquisition/intelligence_layer/opportunity_simulator.py new file mode 100644 index 00000000..f74303cd --- /dev/null +++ b/dealix/auto_client_acquisition/intelligence_layer/opportunity_simulator.py @@ -0,0 +1,89 @@ +"""Opportunity Simulator — forward simulation before sending.""" + +from __future__ import annotations + +from typing import Any + + +# Sector benchmarks (anchored to Saudi B2B Pulse figures) +SECTOR_RATES: dict[str, dict[str, float]] = { + "real_estate": {"reply": 0.074, "meeting": 0.32, "win": 0.18}, + "clinics": {"reply": 0.138, "meeting": 0.40, "win": 0.28}, + "logistics": {"reply": 0.068, "meeting": 0.30, "win": 0.22}, + "hospitality": {"reply": 0.124, "meeting": 0.38, "win": 0.24}, + "restaurants": {"reply": 0.115, "meeting": 0.42, "win": 0.30}, + "training": {"reply": 0.112, "meeting": 0.36, "win": 0.25}, + "agencies": {"reply": 0.059, "meeting": 0.28, "win": 0.20}, + "construction": {"reply": 0.032, "meeting": 0.25, "win": 0.15}, + "saas": {"reply": 0.047, "meeting": 0.30, "win": 0.20}, +} + + +def simulate_opportunity( + *, + target_count: int, + sector: str = "saas", + avg_deal_value_sar: float = 25_000, + channel: str = "whatsapp", + cold_pct: float = 0.0, + quality_lift: float = 1.0, # multiplier (Dealix lift on baseline) +) -> dict[str, Any]: + """ + Forward-simulate a campaign before launching. + + Returns expected replies / meetings / pipeline + risk flags. + """ + rates = SECTOR_RATES.get(sector.lower(), SECTOR_RATES["saas"]) + + # Channel adjustment + if channel == "whatsapp": + reply_rate = rates["reply"] * 1.6 * quality_lift + elif channel == "email": + reply_rate = rates["reply"] * 0.9 * quality_lift + else: + reply_rate = rates["reply"] * quality_lift + + # Cold contacts hurt the rate dramatically + cold_pct = max(0.0, min(1.0, cold_pct)) + if cold_pct > 0: + reply_rate *= max(0.10, 1.0 - cold_pct * 0.85) + + expected_replies = round(target_count * reply_rate) + expected_meetings = round(expected_replies * rates["meeting"]) + expected_deals = round(expected_meetings * rates["win"]) + expected_pipeline = expected_deals * avg_deal_value_sar + + # Risk flags + risks: list[str] = [] + if cold_pct >= 0.5: + risks.append("نسبة cold عالية — احتمال opt-out مرتفع.") + if channel == "whatsapp" and cold_pct > 0: + risks.append("WhatsApp + cold = خطر PDPL — راجع الـ contactability.") + if target_count > 500 and channel == "whatsapp": + risks.append("حملة WhatsApp كبيرة — اعتمد على templates معتمدة.") + + risk_score = min(100, int(50 + cold_pct * 50 + (10 if target_count > 500 else 0))) + + return { + "inputs": { + "target_count": target_count, + "sector": sector, + "avg_deal_value_sar": avg_deal_value_sar, + "channel": channel, + "cold_pct": cold_pct, + "quality_lift": quality_lift, + }, + "rates_used": rates, + "expected_replies": expected_replies, + "expected_meetings": expected_meetings, + "expected_deals": expected_deals, + "expected_pipeline_sar": expected_pipeline, + "risk_score": risk_score, + "risks_ar": risks, + "recommendation_ar": ( + "ابدأ بالـ safe-only segment + معدّل أسبوعي محدود." + if risk_score >= 50 + else "آمن للإطلاق بعد approval." + ), + "approval_required": True, + } diff --git a/dealix/auto_client_acquisition/intelligence_layer/revenue_dna.py b/dealix/auto_client_acquisition/intelligence_layer/revenue_dna.py new file mode 100644 index 00000000..b7780f40 --- /dev/null +++ b/dealix/auto_client_acquisition/intelligence_layer/revenue_dna.py @@ -0,0 +1,90 @@ +"""Revenue DNA — extract the company's growth fingerprint.""" + +from __future__ import annotations + +from collections import Counter +from typing import Any + + +def extract_revenue_dna( + *, + customer_id: str, + won_deals: list[dict[str, Any]] | None = None, + replies: list[dict[str, Any]] | None = None, + objections: list[dict[str, Any]] | None = None, +) -> dict[str, Any]: + """ + Compute the customer's growth DNA. + + Inputs are optional; missing inputs return sensible defaults + so the dashboard always has something to render. + """ + won_deals = won_deals or [] + replies = replies or [] + objections = objections or [] + + # Best channel = channel that produced the most won_deals + chan_counter: Counter[str] = Counter() + seg_counter: Counter[str] = Counter() + angle_counter: Counter[str] = Counter() + cycle_days: list[float] = [] + for d in won_deals: + chan_counter[d.get("channel", "?")] += 1 + seg_counter[d.get("segment", "?")] += 1 + angle_counter[d.get("message_angle", "?")] += 1 + if "cycle_days" in d: + cycle_days.append(float(d["cycle_days"])) + + # Common objection + obj_counter: Counter[str] = Counter() + for o in objections: + obj_counter[o.get("objection_id", "?")] += 1 + + next_experiment_ar = ( + "اختبر رسالة قصيرة (≤4 سطور) + CTA واحد على القناة الأنجح." + if len(won_deals) >= 5 else + "ركّز على بناء أول 10 deals عبر «10 فرص في 10 دقائق»." + ) + + return { + "customer_id": customer_id, + "best_channel": (chan_counter.most_common(1)[0][0] if chan_counter else "whatsapp"), + "best_segment": (seg_counter.most_common(1)[0][0] if seg_counter else "inbound_lead"), + "best_message_angle": ( + angle_counter.most_common(1)[0][0] if angle_counter + else "value_first_short_arabic" + ), + "common_objection": (obj_counter.most_common(1)[0][0] if obj_counter else "send_offer_whatsapp"), + "fastest_conversion_days": round( + min(cycle_days) if cycle_days else 0, 1 + ), + "median_conversion_days": round( + sorted(cycle_days)[len(cycle_days) // 2] if cycle_days else 0, 1 + ), + "deals_observed": len(won_deals), + "next_experiment_ar": next_experiment_ar, + } + + +def build_revenue_dna_demo() -> dict[str, Any]: + """Demo Revenue DNA with realistic Saudi B2B values.""" + return extract_revenue_dna( + customer_id="demo", + won_deals=[ + {"channel": "whatsapp", "segment": "inbound_lead", + "message_angle": "value_first_short_arabic", "cycle_days": 18}, + {"channel": "whatsapp", "segment": "existing_customer", + "message_angle": "expansion_offer", "cycle_days": 12}, + {"channel": "email", "segment": "referral", + "message_angle": "warm_intro", "cycle_days": 25}, + {"channel": "whatsapp", "segment": "event_lead", + "message_angle": "value_first_short_arabic", "cycle_days": 30}, + {"channel": "whatsapp", "segment": "inbound_lead", + "message_angle": "value_first_short_arabic", "cycle_days": 15}, + ], + objections=[ + {"objection_id": "send_offer_whatsapp"}, + {"objection_id": "send_offer_whatsapp"}, + {"objection_id": "price_high"}, + ], + ) diff --git a/dealix/auto_client_acquisition/intelligence_layer/trust_score.py b/dealix/auto_client_acquisition/intelligence_layer/trust_score.py new file mode 100644 index 00000000..21d2dbd9 --- /dev/null +++ b/dealix/auto_client_acquisition/intelligence_layer/trust_score.py @@ -0,0 +1,102 @@ +"""Trust Score — composite per-action verdict before execution.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + + +@dataclass +class TrustVerdict: + """Output of compute_trust_score.""" + + verdict: str # safe / needs_review / blocked + score: int # 0-100 (higher = safer) + reasons_ar: list[str] + fixes_ar: list[str] + + +def compute_trust_score( + *, + source_quality: str = "unknown", # public / partner / customer / cold / unknown + opt_in: bool = False, + channel: str = "whatsapp", + message_text: str = "", + frequency_count_this_week: int = 0, + weekly_cap: int = 2, + approval_status: str = "pending", +) -> dict[str, Any]: + """ + Composite trust verdict on a proposed action. + + Pure deterministic — same inputs → same verdict. + """ + score = 100 + reasons: list[str] = [] + fixes: list[str] = [] + + # 1. Source quality + src_penalty = { + "customer": 0, + "partner": -5, + "public": -10, + "unknown": -25, + "cold": -40, + }.get(source_quality, -20) + score += src_penalty + if src_penalty <= -25: + reasons.append(f"جودة المصدر منخفضة ({source_quality}).") + fixes.append("وثّق lawful basis قبل أي تواصل.") + + # 2. Opt-in + if not opt_in and channel == "whatsapp": + score -= 30 + reasons.append("لا opt-in على قناة WhatsApp.") + fixes.append("احصل على opt-in صريح أو حوّل القناة للإيميل.") + + # 3. Channel risk + if channel in ("whatsapp", "instagram_graph"): + score -= 5 # consumer-facing channels need extra care + elif channel == "x_api": + score -= 10 # public broadcast risk + + # 4. Message risk — banned phrases + risky_phrases = ("ضمان 100", "نتائج مضمونة", "آخر فرصة", "اضغط هنا فوراً") + found = [p for p in risky_phrases if p in (message_text or "")] + if found: + score -= 15 * len(found) + reasons.append(f"عبارات محظورة: {found}") + fixes.append("احذف العبارات المبالغة قبل الإرسال.") + + # 5. Frequency cap + if frequency_count_this_week >= weekly_cap: + score -= 20 + reasons.append(f"تجاوز السقف الأسبوعي ({frequency_count_this_week}/{weekly_cap}).") + fixes.append("انتظر بداية الأسبوع التالي.") + + # 6. Approval gate + if approval_status == "pending": + score -= 10 + reasons.append("لم يصل approval المشغّل بعد.") + fixes.append("اطلب موافقة المشغّل.") + + score = max(0, min(100, score)) + + if score >= 70: + verdict = "safe" + elif score >= 40: + verdict = "needs_review" + else: + verdict = "blocked" + + if not reasons: + reasons = ["كل القواعد مستوفاة."] + if not fixes and verdict == "safe": + fixes = ["جاهز للتنفيذ بعد approval إذا لزم."] + + return { + "verdict": verdict, + "score": score, + "reasons_ar": reasons, + "fixes_ar": fixes, + } diff --git a/dealix/auto_client_acquisition/platform_services/__init__.py b/dealix/auto_client_acquisition/platform_services/__init__.py new file mode 100644 index 00000000..59897666 --- /dev/null +++ b/dealix/auto_client_acquisition/platform_services/__init__.py @@ -0,0 +1,74 @@ +""" +Platform Services Layer — Dealix's Growth Control Tower spine. + +Turns the platform from "WhatsApp Growth Operator" into a multi-channel +growth platform that ingests events from every channel a Saudi B2B uses, +converts them into Arabic action cards, evaluates each action against +policy, and produces unified proof. + +Modules: + - event_bus : typed events from all channels + - identity_resolution : reconcile phone+email+social→one person + - channel_registry : 11 supported channels with capabilities + - action_policy : decide approval / block / allow + - tool_gateway : draft-only proxy (no live actions here) + - unified_inbox : 8 card types from events + - action_ledger : auditable record of every action lifecycle + - proof_ledger : value rolled up across the platform + - service_catalog : 12 sellable services +""" + +from auto_client_acquisition.platform_services.action_ledger import ( + ActionLedger, + LedgerEntry, +) +from auto_client_acquisition.platform_services.action_policy import ( + POLICY_RULES, + PolicyDecision, + evaluate_action, +) +from auto_client_acquisition.platform_services.channel_registry import ( + ALL_CHANNELS, + Channel, + get_channel, +) +from auto_client_acquisition.platform_services.event_bus import ( + EVENT_TYPES, + PlatformEvent, + make_event, +) +from auto_client_acquisition.platform_services.identity_resolution import ( + Identity, + resolve_identity, +) +from auto_client_acquisition.platform_services.proof_ledger import ( + PlatformProofLedger, + build_demo_platform_proof, +) +from auto_client_acquisition.platform_services.service_catalog import ( + SELLABLE_SERVICES, + ServiceOffering, + list_services, +) +from auto_client_acquisition.platform_services.tool_gateway import ( + GatewayResult, + invoke_tool, +) +from auto_client_acquisition.platform_services.unified_inbox import ( + CARD_TYPES, + InboxCard, + build_card_from_event, + build_demo_feed, +) + +__all__ = [ + "EVENT_TYPES", "PlatformEvent", "make_event", + "Identity", "resolve_identity", + "ALL_CHANNELS", "Channel", "get_channel", + "POLICY_RULES", "PolicyDecision", "evaluate_action", + "GatewayResult", "invoke_tool", + "CARD_TYPES", "InboxCard", "build_card_from_event", "build_demo_feed", + "ActionLedger", "LedgerEntry", + "PlatformProofLedger", "build_demo_platform_proof", + "SELLABLE_SERVICES", "ServiceOffering", "list_services", +] diff --git a/dealix/auto_client_acquisition/platform_services/action_ledger.py b/dealix/auto_client_acquisition/platform_services/action_ledger.py new file mode 100644 index 00000000..64e7aa32 --- /dev/null +++ b/dealix/auto_client_acquisition/platform_services/action_ledger.py @@ -0,0 +1,107 @@ +""" +Action Ledger — auditable record of every action lifecycle. + +Stage transitions per action: requested → (approved | rejected | blocked) +→ executed → outcome. + +Used for SDAIA / DPO inspections + customer's own audit trail. +""" + +from __future__ import annotations + +import uuid +from dataclasses import dataclass, field +from datetime import datetime, timezone +from typing import Any + + +VALID_STAGES: tuple[str, ...] = ( + "requested", "approved", "rejected", "blocked", + "executed", "outcome_recorded", +) + + +@dataclass +class LedgerEntry: + """One entry in the action ledger.""" + + entry_id: str + customer_id: str + action_type: str + channel: str + stage: str + actor: str = "system" + payload: dict[str, Any] = field(default_factory=dict) + reason_ar: str = "" + created_at: datetime = field( + default_factory=lambda: datetime.now(timezone.utc).replace(tzinfo=None) + ) + correlation_id: str | None = None + + def to_dict(self) -> dict[str, Any]: + return { + "entry_id": self.entry_id, + "customer_id": self.customer_id, + "action_type": self.action_type, + "channel": self.channel, + "stage": self.stage, + "actor": self.actor, + "payload": self.payload, + "reason_ar": self.reason_ar, + "created_at": self.created_at.isoformat(), + "correlation_id": self.correlation_id, + } + + +@dataclass +class ActionLedger: + """Append-only ledger keyed by customer_id.""" + + entries: list[LedgerEntry] = field(default_factory=list) + + def append( + self, + *, + customer_id: str, + action_type: str, + channel: str, + stage: str, + actor: str = "system", + payload: dict[str, Any] | None = None, + reason_ar: str = "", + correlation_id: str | None = None, + ) -> LedgerEntry: + if stage not in VALID_STAGES: + raise ValueError(f"unknown stage: {stage}") + entry = LedgerEntry( + entry_id=f"led_{uuid.uuid4().hex[:20]}", + customer_id=customer_id, + action_type=action_type, + channel=channel, + stage=stage, + actor=actor, + payload=payload or {}, + reason_ar=reason_ar, + correlation_id=correlation_id, + ) + self.entries.append(entry) + return entry + + def for_customer(self, customer_id: str) -> list[LedgerEntry]: + return [e for e in self.entries if e.customer_id == customer_id] + + def summary(self, customer_id: str | None = None) -> dict[str, Any]: + pool = self.entries if customer_id is None else self.for_customer(customer_id) + by_stage: dict[str, int] = {} + by_channel: dict[str, int] = {} + by_action: dict[str, int] = {} + for e in pool: + by_stage[e.stage] = by_stage.get(e.stage, 0) + 1 + by_channel[e.channel] = by_channel.get(e.channel, 0) + 1 + by_action[e.action_type] = by_action.get(e.action_type, 0) + 1 + return { + "total": len(pool), + "by_stage": by_stage, + "by_channel": by_channel, + "by_action_type": by_action, + } diff --git a/dealix/auto_client_acquisition/platform_services/action_policy.py b/dealix/auto_client_acquisition/platform_services/action_policy.py new file mode 100644 index 00000000..c1f617a4 --- /dev/null +++ b/dealix/auto_client_acquisition/platform_services/action_policy.py @@ -0,0 +1,173 @@ +""" +Action Policy Engine — decides whether an action can run, needs approval, +or is blocked. The single chokepoint that protects the customer's +reputation + enforces PDPL. + +Design: pure deterministic rules. Easily testable, easily auditable, +easy for the customer to explain to compliance. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + + +# ── Policy rules — each rule is (action_type, condition, decision, reason_ar) +POLICY_RULES: list[dict[str, Any]] = [ + # Hard blocks — never executed + { + "rule_id": "block_cold_whatsapp", + "action": "send_whatsapp", + "when": {"source": "cold_list", "consent": False}, + "decision": "blocked", + "reason_ar": "WhatsApp البارد محظور بدون lawful basis (PDPL م.5).", + }, + { + "rule_id": "block_payment_no_confirm", + "action": "charge_payment", + "when": {"user_confirmed": False}, + "decision": "blocked", + "reason_ar": "الخصم يحتاج تأكيد المستخدم على Moyasar — لا charge مباشر.", + }, + { + "rule_id": "block_secrets_in_payload", + "action": "*", + "when": {"payload_contains_secret": True}, + "decision": "blocked", + "reason_ar": "تم اكتشاف secret في الـ payload — حماية تلقائية.", + }, + # Approval gates — must pass through human + { + "rule_id": "external_send_needs_approval", + "action": "send_whatsapp,send_email,send_inmail,post_social", + "when": {"approval_status": "pending"}, + "decision": "approval_required", + "reason_ar": "كل إرسال خارجي يحتاج موافقة العميل قبل التنفيذ.", + }, + { + "rule_id": "calendar_insert_needs_approval", + "action": "calendar_insert_event", + "when": {"approval_status": "pending"}, + "decision": "approval_required", + "reason_ar": "إنشاء اجتماع في تقويم العميل يحتاج موافقة قبل insert.", + }, + { + "rule_id": "social_dm_needs_explicit", + "action": "send_social_dm", + "when": {"explicit_permission": False}, + "decision": "approval_required", + "reason_ar": "DM السوشيال يحتاج إذن صريح لكل حساب.", + }, + # Needs review + { + "rule_id": "unknown_source_review", + "action": "*", + "when": {"source": "unknown"}, + "decision": "approval_required", + "reason_ar": "مصدر البيانات غير محدد — يحتاج توثيق lawful basis.", + }, + { + "rule_id": "high_value_deal_review", + "action": "*", + "when": {"deal_value_sar_gte": 100_000}, + "decision": "approval_required", + "reason_ar": "صفقة قيمتها ≥100K ريال — راجعها قبل التنفيذ.", + }, + # Allowed (default for safe paths) + { + "rule_id": "draft_only_safe", + "action": "create_draft,read_data,classify_reply", + "when": {}, + "decision": "allow", + "reason_ar": "إجراء داخلي آمن — لا يخرج للعميل النهائي.", + }, +] + + +@dataclass +class PolicyDecision: + """Output of evaluate_action.""" + + decision: str # allow / approval_required / blocked + matched_rule_id: str | None + reasons_ar: list[str] = field(default_factory=list) + suggested_next_action_ar: str = "" + + +def evaluate_action( + *, + action: str, + context: dict[str, Any] | None = None, +) -> PolicyDecision: + """ + Evaluate a proposed action against the policy rules. + + First matching rule wins. Default: needs_review (defensive). + """ + ctx = context or {} + matched_reasons: list[str] = [] + final_decision = "allow" + matched_rule_id: str | None = None + next_action = "ready_for_execution" + + for rule in POLICY_RULES: + # Action match (comma-separated list, "*" = match-any) + applicable_actions = rule["action"].split(",") if rule["action"] != "*" else [action] + if action not in applicable_actions and rule["action"] != "*": + continue + + # Condition match — every key in `when` must match the context + when = rule["when"] + cond_match = True + for k, expected in when.items(): + if k.endswith("_gte"): + attr = k[:-4] + if not (float(ctx.get(attr, 0)) >= float(expected)): + cond_match = False + break + elif k == "payload_contains_secret": + if expected and not _has_secret_marker(ctx.get("payload", {})): + cond_match = False + break + elif ctx.get(k) != expected: + cond_match = False + break + + if not cond_match: + continue + + decision = rule["decision"] + matched_reasons.append(rule["reason_ar"]) + matched_rule_id = rule["rule_id"] + + if decision == "blocked": + return PolicyDecision( + decision="blocked", + matched_rule_id=matched_rule_id, + reasons_ar=matched_reasons, + suggested_next_action_ar="معالجة سبب الحظر قبل المحاولة مرة أخرى.", + ) + if decision == "approval_required": + final_decision = "approval_required" + next_action = "operator_approves_then_execute" + # 'allow' rules just confirm — keep looking for stricter rule + + return PolicyDecision( + decision=final_decision, + matched_rule_id=matched_rule_id, + reasons_ar=matched_reasons or ["لا قاعدة مطابقة — الإجراء آمن افتراضياً."], + suggested_next_action_ar=next_action, + ) + + +# ── Helpers ────────────────────────────────────────────────────── +_SECRET_MARKERS = ("api_key", "secret_key", "private_key", "password", "ghp_", "sk-ant-", "moyasar_secret") + + +def _has_secret_marker(payload: dict[str, Any]) -> bool: + """Cheap heuristic check — production pairs this with a stronger scanner.""" + if not isinstance(payload, dict): + return False + flat = str(payload).lower() + return any(marker in flat for marker in _SECRET_MARKERS) diff --git a/dealix/auto_client_acquisition/platform_services/channel_registry.py b/dealix/auto_client_acquisition/platform_services/channel_registry.py new file mode 100644 index 00000000..6396fa4c --- /dev/null +++ b/dealix/auto_client_acquisition/platform_services/channel_registry.py @@ -0,0 +1,213 @@ +""" +Channel Registry — 11 supported channels with capabilities + risk profile. + +Each channel declares: capabilities, beta_status, required_permissions, +allowed_actions, blocked_actions, risk_level. Used by the action policy +engine and the unified inbox. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + + +@dataclass(frozen=True) +class Channel: + """A connected channel + what it can / cannot do.""" + + key: str + label_ar: str + label_en: str + capabilities: tuple[str, ...] + beta_status: str # ga / beta / experimental / planned + required_permissions: tuple[str, ...] + allowed_actions: tuple[str, ...] + blocked_actions: tuple[str, ...] + risk_level: str # low / medium / high + notes_ar: str = "" + + +# ── The 11 channels we model ──────────────────────────────────── +ALL_CHANNELS: tuple[Channel, ...] = ( + Channel( + key="whatsapp", + label_ar="واتساب", + label_en="WhatsApp Business / Cloud", + capabilities=( + "inbound_messages", "outbound_template_messages", + "interactive_buttons_max_3", "media_send", "opt_out_handling", + ), + beta_status="ga", + required_permissions=( + "waba_account_id", "phone_number_id", "verified_business", + ), + allowed_actions=("draft_message", "send_with_approval", "track_reply"), + blocked_actions=("cold_send_without_consent", "bulk_unsolicited_send"), + risk_level="medium", + notes_ar="حد 3 buttons تفاعلية. الإرسال البارد محظور بدون lawful basis.", + ), + Channel( + key="gmail", + label_ar="Gmail (إيميل العميل)", + label_en="Gmail OAuth", + capabilities=( + "create_draft_only", "read_labeled_threads", + "list_unsubscribe_header_attached", + ), + beta_status="ga", + required_permissions=("gmail.compose",), + allowed_actions=("create_draft", "read_thread"), + blocked_actions=("send_without_user_click", "delete_messages"), + risk_level="low", + notes_ar="نكتفي بـ scope `gmail.compose`. المستخدم يضغط Send بنفسه.", + ), + Channel( + key="google_calendar", + label_ar="Google Calendar", + label_en="Google Calendar API", + capabilities=( + "events_insert_with_meet", "events_list", + "rfc5545_recurrence", "asia_riyadh_timezone", + ), + beta_status="ga", + required_permissions=("calendar.events",), + allowed_actions=("draft_event", "create_event_with_approval"), + blocked_actions=("delete_other_attendees_events", "modify_external_events_silently"), + risk_level="low", + notes_ar="conferenceDataVersion=1 لإضافة Google Meet.", + ), + Channel( + key="linkedin_lead_forms", + label_ar="LinkedIn Lead Gen Forms", + label_en="LinkedIn Lead Gen Forms API", + capabilities=( + "ingest_leads_from_ads", "hidden_field_tracking", + "crm_sync", + ), + beta_status="beta", + required_permissions=("r_marketing_leadgen_automation",), + allowed_actions=("ingest_lead_form", "trigger_followup_draft"), + blocked_actions=("scrape_profiles", "unsolicited_inmails_at_scale"), + risk_level="low", + notes_ar="مصدر رسمي لـ leads مؤهلة.", + ), + Channel( + key="x_api", + label_ar="X (Twitter)", + label_en="X API v2", + capabilities=( + "post_tweet", "read_mentions", + "user_lookups_basic", "webhooks_account_activity_paid", + ), + beta_status="experimental", + required_permissions=("oauth2_user_context",), + allowed_actions=("draft_post", "ingest_mention", "draft_dm_reply"), + blocked_actions=("auto_dm_strangers", "scrape_user_lists"), + risk_level="medium", + notes_ar="بعض الـ webhooks Enterprise-only. نقتصر على ما تتيحه الخطة الحالية.", + ), + Channel( + key="instagram_graph", + label_ar="Instagram (Graph API)", + label_en="Instagram Graph API", + capabilities=( + "read_business_messages", "publish_posts", + "read_comments_on_owned_posts", + ), + beta_status="beta", + required_permissions=("instagram_basic", "instagram_manage_messages"), + allowed_actions=("draft_reply", "ingest_comment", "ingest_dm"), + blocked_actions=("auto_dm_strangers", "scrape_unrelated_users"), + risk_level="medium", + notes_ar="فقط للحسابات Business + ما يخص العميل المتصل.", + ), + Channel( + key="google_business_profile", + label_ar="Google Business Profile", + label_en="Google Business Profile API", + capabilities=( + "read_reviews", "post_replies", + "publish_local_posts", "manage_location_info", + ), + beta_status="ga", + required_permissions=("business.manage",), + allowed_actions=("draft_review_reply", "draft_local_post"), + blocked_actions=("delete_real_reviews"), + risk_level="low", + notes_ar="مهم للمتاجر والعيادات والفروع — السمعة المحلية.", + ), + Channel( + key="google_sheets", + label_ar="Google Sheets", + label_en="Google Sheets API", + capabilities=("read_range", "append_row", "watch_changes"), + beta_status="ga", + required_permissions=("spreadsheets.readonly", "spreadsheets",), + allowed_actions=("import_contacts", "sync_pipeline", "log_actions"), + blocked_actions=("delete_user_sheets"), + risk_level="low", + notes_ar="أداة مفيدة للتكامل مع عمليات العميل اليدوية.", + ), + Channel( + key="crm", + label_ar="CRM (Zoho/HubSpot/Salla/Odoo)", + label_en="CRM via REST/SDK", + capabilities=( + "deal_sync", "contact_sync", "activity_log", + ), + beta_status="planned", + required_permissions=("crm_api_token",), + allowed_actions=("read_deals", "update_stage_with_approval"), + blocked_actions=("delete_deals_silently"), + risk_level="medium", + notes_ar="بناء adapter لكل CRM في مرحلة لاحقة.", + ), + Channel( + key="moyasar", + label_ar="Moyasar (مدفوعات)", + label_en="Moyasar Payments", + capabilities=( + "create_payment_link", "create_invoice", + "webhook_paid_failed_refunded", "refund", + ), + beta_status="ga", + required_permissions=("publishable_key", "secret_key"), + allowed_actions=("draft_payment_link", "send_invoice_email"), + blocked_actions=("charge_card_without_user_action"), + risk_level="high", + notes_ar="بطاقة العميل تُدخَل على Moyasar (PCI-safe). لا تخزين خانات.", + ), + Channel( + key="website_forms", + label_ar="نماذج الموقع", + label_en="Website Forms", + capabilities=("ingest_submission", "trigger_workflow"), + beta_status="ga", + required_permissions=("webhook_endpoint",), + allowed_actions=("ingest_lead", "draft_thankyou_message"), + blocked_actions=(), + risk_level="low", + notes_ar="مصدر leads مؤهَّلة بطبيعتها — أساس قانوني واضح.", + ), +) + + +def get_channel(key: str) -> Channel | None: + for c in ALL_CHANNELS: + if c.key == key: + return c + return None + + +def channels_summary() -> dict[str, Any]: + by_status: dict[str, int] = {} + by_risk: dict[str, int] = {} + for c in ALL_CHANNELS: + by_status[c.beta_status] = by_status.get(c.beta_status, 0) + 1 + by_risk[c.risk_level] = by_risk.get(c.risk_level, 0) + 1 + return { + "total": len(ALL_CHANNELS), + "by_beta_status": by_status, + "by_risk_level": by_risk, + } diff --git a/dealix/auto_client_acquisition/platform_services/event_bus.py b/dealix/auto_client_acquisition/platform_services/event_bus.py new file mode 100644 index 00000000..6ceebaab --- /dev/null +++ b/dealix/auto_client_acquisition/platform_services/event_bus.py @@ -0,0 +1,110 @@ +""" +Omni-Channel Event Bus — every channel emits typed events here. + +Pure structures + helpers; the actual transport (Redis/Kafka) lives in a +production adapter. This module is testable in isolation. +""" + +from __future__ import annotations + +import uuid +from dataclasses import dataclass, field +from datetime import datetime, timezone +from typing import Any + + +# ── Event taxonomy ──────────────────────────────────────────────── +EVENT_TYPES: tuple[str, ...] = ( + # WhatsApp + "whatsapp.message_received", + "whatsapp.message_sent", + "whatsapp.opt_out", + # Email (Gmail or company SMTP) + "email.received", + "email.draft_created", + "email.sent", + # Calendar + "calendar.meeting_scheduled", + "calendar.meeting_held", + "calendar.no_show", + # Social (X / LinkedIn / Instagram / Facebook) + "social.comment_received", + "social.dm_received", + "social.mention_received", + "social.lead_form_submitted", + # Website + CRM + "lead.form_submitted", + "lead.crm_imported", + # Payments (Moyasar) + "payment.initiated", + "payment.paid", + "payment.failed", + "payment.refunded", + # Reviews / reputation (Google Business Profile) + "review.created", + "review.replied", + # Partners + "partner.suggested", + "partner.intro_made", + # Internal lifecycle + "action.requested", + "action.approved", + "action.rejected", + "action.executed", + "action.blocked", + # Sheets / CRM sync + "sheet.row_added", + "crm.deal_updated", +) + + +# ── Event envelope ──────────────────────────────────────────────── +@dataclass(frozen=True) +class PlatformEvent: + """Immutable platform event.""" + + event_id: str + event_type: str + channel: str # whatsapp / gmail / google_calendar / x / ... + customer_id: str + occurred_at: datetime + payload: dict[str, Any] = field(default_factory=dict) + correlation_id: str | None = None + actor: str = "system" + + def to_dict(self) -> dict[str, Any]: + return { + "event_id": self.event_id, + "event_type": self.event_type, + "channel": self.channel, + "customer_id": self.customer_id, + "occurred_at": self.occurred_at.isoformat(), + "payload": self.payload, + "correlation_id": self.correlation_id, + "actor": self.actor, + } + + +def make_event( + *, + event_type: str, + channel: str, + customer_id: str, + payload: dict[str, Any] | None = None, + correlation_id: str | None = None, + actor: str = "system", + occurred_at: datetime | None = None, +) -> PlatformEvent: + """Construct a validated event.""" + if event_type not in EVENT_TYPES: + raise ValueError(f"unknown event_type: {event_type}") + return PlatformEvent( + event_id=f"pevt_{uuid.uuid4().hex[:24]}", + event_type=event_type, + channel=channel, + customer_id=customer_id, + occurred_at=occurred_at or datetime.now(timezone.utc).replace(tzinfo=None), + payload=payload or {}, + correlation_id=correlation_id, + actor=actor, + ) diff --git a/dealix/auto_client_acquisition/platform_services/identity_resolution.py b/dealix/auto_client_acquisition/platform_services/identity_resolution.py new file mode 100644 index 00000000..ed8e5c34 --- /dev/null +++ b/dealix/auto_client_acquisition/platform_services/identity_resolution.py @@ -0,0 +1,91 @@ +""" +Identity Resolution — reconcile signals from many channels into one Identity. + +Inputs: phone, email, company, social handles, CRM ids. +Output: a single Identity record with confidence per matched signal. + +Pure deterministic — production version would hit a graph DB. +""" + +from __future__ import annotations + +import hashlib +from dataclasses import dataclass, field +from typing import Any + + +@dataclass +class Identity: + """A reconciled identity across channels.""" + + identity_id: str + primary_phone: str | None = None + primary_email: str | None = None + company: str | None = None + crm_id: str | None = None + social_handles: dict[str, str] = field(default_factory=dict) + confidence: float = 0.0 # 0..1 + sources: list[str] = field(default_factory=list) + + +def _hash_id(*parts: str) -> str: + """Deterministic ID from any combination of stable identifiers.""" + seed = "|".join(p.lower().strip() for p in parts if p) + if not seed: + return "" + h = hashlib.sha256(seed.encode("utf-8")).hexdigest()[:16] + return f"id_{h}" + + +def resolve_identity(*, signals: list[dict[str, Any]]) -> Identity: + """ + Merge a list of signals (from different channels) into one Identity. + + Each signal can be: {phone, email, company, crm_id, social_handles, source}. + """ + phones: dict[str, int] = {} + emails: dict[str, int] = {} + companies: dict[str, int] = {} + crm_ids: list[str] = [] + socials: dict[str, str] = {} + sources: list[str] = [] + + for s in signals: + ph = (s.get("phone") or "").strip() + em = (s.get("email") or "").strip().lower() + co = (s.get("company") or "").strip() + crm = (s.get("crm_id") or "").strip() + if ph: + phones[ph] = phones.get(ph, 0) + 1 + if em: + emails[em] = emails.get(em, 0) + 1 + if co: + companies[co] = companies.get(co, 0) + 1 + if crm: + crm_ids.append(crm) + for k, v in (s.get("social_handles") or {}).items(): + if k not in socials and v: + socials[k] = v + if s.get("source"): + sources.append(str(s["source"])) + + # Pick most-frequent canonical values + primary_phone = max(phones, key=phones.get) if phones else None + primary_email = max(emails, key=emails.get) if emails else None + company = max(companies, key=companies.get) if companies else None + crm_id = crm_ids[0] if crm_ids else None + + # Confidence: proportional to number of independent strong signals + strong_signals = sum(1 for x in (primary_phone, primary_email, crm_id) if x) + confidence = min(1.0, 0.30 * strong_signals + 0.10 * (1 if socials else 0)) + + return Identity( + identity_id=_hash_id(primary_phone or "", primary_email or "", crm_id or ""), + primary_phone=primary_phone, + primary_email=primary_email, + company=company, + crm_id=crm_id, + social_handles=dict(socials), + confidence=round(confidence, 3), + sources=list(dict.fromkeys(sources)), # dedupe preserve order + ) diff --git a/dealix/auto_client_acquisition/platform_services/proof_ledger.py b/dealix/auto_client_acquisition/platform_services/proof_ledger.py new file mode 100644 index 00000000..9cb7a195 --- /dev/null +++ b/dealix/auto_client_acquisition/platform_services/proof_ledger.py @@ -0,0 +1,80 @@ +""" +Platform Proof Ledger — value rolled up across the entire platform. + +Tracks: leads, meetings, drafts, sends, payments, revenue influenced, +risks blocked, time saved, partner ops. Pure functions. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + + +@dataclass +class PlatformProofLedger: + """Aggregated platform value over a period.""" + + customer_id: str + period_label: str + leads_created: int = 0 + meetings_booked: int = 0 + drafts_approved: int = 0 + messages_sent: int = 0 + payments_initiated: int = 0 + payments_paid: int = 0 + revenue_influenced_sar: float = 0.0 + risks_blocked: int = 0 + time_saved_hours: float = 0.0 + partner_opportunities: int = 0 + by_channel: dict[str, dict[str, float]] = field(default_factory=dict) + + def to_dict(self) -> dict[str, Any]: + return { + "customer_id": self.customer_id, + "period_label": self.period_label, + "totals": { + "leads_created": self.leads_created, + "meetings_booked": self.meetings_booked, + "drafts_approved": self.drafts_approved, + "messages_sent": self.messages_sent, + "payments_initiated": self.payments_initiated, + "payments_paid": self.payments_paid, + "revenue_influenced_sar": self.revenue_influenced_sar, + "risks_blocked": self.risks_blocked, + "time_saved_hours": self.time_saved_hours, + "partner_opportunities": self.partner_opportunities, + }, + "by_channel": self.by_channel, + } + + +def build_demo_platform_proof( + *, + customer_id: str = "demo", + period_label: str = "May 2026", +) -> PlatformProofLedger: + """Deterministic demo for the dashboard.""" + return PlatformProofLedger( + customer_id=customer_id, + period_label=period_label, + leads_created=72, + meetings_booked=14, + drafts_approved=58, + messages_sent=58, + payments_initiated=4, + payments_paid=3, + revenue_influenced_sar=185_000, + risks_blocked=21, # cold whatsapp + secrets in payload + opt-out + ... + time_saved_hours=42, + partner_opportunities=6, + by_channel={ + "whatsapp": {"messages_sent": 33, "replies": 12, "meetings": 5}, + "gmail": {"drafts": 18, "sent": 18, "replies": 6}, + "google_calendar": {"events_drafted": 14, "events_inserted": 0}, + "moyasar": {"links_drafted": 4, "paid": 3}, + "google_business_profile": {"reviews_replied": 8}, + "linkedin_lead_forms": {"leads_ingested": 11}, + "website_forms": {"leads_ingested": 22}, + }, + ) diff --git a/dealix/auto_client_acquisition/platform_services/service_catalog.py b/dealix/auto_client_acquisition/platform_services/service_catalog.py new file mode 100644 index 00000000..217eb967 --- /dev/null +++ b/dealix/auto_client_acquisition/platform_services/service_catalog.py @@ -0,0 +1,219 @@ +""" +Service Catalog — 12 sellable services on top of the platform. + +Each service has: target_customer, outcome, deliverables, pricing_model, +required_integrations, proof_metric. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + + +@dataclass(frozen=True) +class ServiceOffering: + """A sellable service offering.""" + + key: str + label_ar: str + label_en: str + target_customer_ar: str + outcome_ar: str + deliverables_ar: tuple[str, ...] + pricing_model_ar: str + required_integrations: tuple[str, ...] + proof_metric_ar: str + + def to_dict(self) -> dict[str, Any]: + return { + "key": self.key, + "label_ar": self.label_ar, + "label_en": self.label_en, + "target_customer_ar": self.target_customer_ar, + "outcome_ar": self.outcome_ar, + "deliverables_ar": list(self.deliverables_ar), + "pricing_model_ar": self.pricing_model_ar, + "required_integrations": list(self.required_integrations), + "proof_metric_ar": self.proof_metric_ar, + } + + +SELLABLE_SERVICES: tuple[ServiceOffering, ...] = ( + ServiceOffering( + key="growth_operator_subscription", + label_ar="Growth Operator — اشتراك شهري", + label_en="Growth Operator Subscription", + target_customer_ar="شركات B2B سعودية تبحث عن نمو منظم", + outcome_ar="فرص يومية + رسائل عربية + موافقات + Proof Pack شهري", + deliverables_ar=( + "Daily brief", "Command Feed", "Top opportunities", + "Message drafts", "Approvals", "Weekly Proof Pack", + ), + pricing_model_ar="شهري (299 / 2,999 / 7,999 ريال حسب الحجم)", + required_integrations=("whatsapp",), + proof_metric_ar="Pipeline added × monthly cost multiple", + ), + ServiceOffering( + key="channel_setup_service", + label_ar="إعداد القنوات", + label_en="Channel Setup Service", + target_customer_ar="عملاء جدد لم يربطوا قنواتهم بعد", + outcome_ar="ربط آمن لكل قنوات نمو الشركة (PDPL-compliant)", + deliverables_ar=( + "ربط WhatsApp", "ربط Gmail", "ربط Calendar", + "ربط Sheets / CRM", "ربط Moyasar", "ربط social accounts", + ), + pricing_model_ar="رسوم setup (3,000-15,000 ريال) لمرة واحدة", + required_integrations=("whatsapp", "gmail", "google_calendar", "moyasar"), + proof_metric_ar="عدد القنوات المربوطة + uptime أسبوعي", + ), + ServiceOffering( + key="lead_intelligence_service", + label_ar="Lead Intelligence — تنظيف وتصنيف القوائم", + label_en="Lead Intelligence Service", + target_customer_ar="عملاء عندهم قوائم أرقام ضخمة غير منظمة", + outcome_ar="قائمة آمنة + مصنّفة + Top-10 مرشحة للإطلاق", + deliverables_ar=( + "normalize_phone", "dedupe", "classify source", + "contactability scoring", "segmentation", "Top-10 + why_now", + ), + pricing_model_ar="رسوم لمرة + per-1000-contact pricing", + required_integrations=("website_forms", "google_sheets"), + proof_metric_ar="نسبة contacts safe + Top-10 conversion", + ), + ServiceOffering( + key="outreach_approval_service", + label_ar="Outreach بموافقة كاملة", + label_en="Outreach Approval Service", + target_customer_ar="شركات تخاف من الإرسال العشوائي", + outcome_ar="حملات outreach آمنة عبر approval-first flow", + deliverables_ar=( + "Drafts عربية", "PDPL gates", "Approval queue", + "Tracking", "Follow-up", "Proof", + ), + pricing_model_ar="مدمج مع subscription + add-on per-campaign", + required_integrations=("whatsapp", "gmail"), + proof_metric_ar="معدل الرد + meeting rate + opt-out rate", + ), + ServiceOffering( + key="partnership_sprint", + label_ar="Partnership Sprint — 14 يوم", + label_en="Partnership Sprint", + target_customer_ar="شركات تريد قناة شراكات منظمة", + outcome_ar="20 شريك محتمل + 10 رسائل + 5 اجتماعات + 1 partner offer", + deliverables_ar=( + "Target list", "Outreach drafts", "Meeting drafts", + "Partner scorecard", "Revenue share template", + ), + pricing_model_ar="رسوم ثابتة (10,000 ريال للـ sprint)", + required_integrations=("gmail", "google_calendar"), + proof_metric_ar="Partner intros replied + first deal influenced", + ), + ServiceOffering( + key="email_revenue_rescue", + label_ar="Email Revenue Rescue — استخراج فرص ضائعة", + label_en="Email Revenue Rescue", + target_customer_ar="شركات عندها inbox مزدحم وفرص ضائعة", + outcome_ar="استخراج leads + فرص + drafts من إيميل الشركة", + deliverables_ar=( + "Inbox audit", "Lost leads list", "Drafts", + "Meeting prep", "Pipeline update", + ), + pricing_model_ar="رسوم لمرة + ongoing add-on", + required_integrations=("gmail", "google_calendar"), + proof_metric_ar="عدد الفرص المُستخرجة + pipeline rescued", + ), + ServiceOffering( + key="social_growth_os", + label_ar="Social Growth OS — تعليقات + DMs + leads", + label_en="Social Growth OS", + target_customer_ar="شركات نشطة على LinkedIn / X / Instagram", + outcome_ar="تحويل التعليقات والـ mentions إلى فرص", + deliverables_ar=( + "Listening", "Reply drafts", "Lead extraction", + "DM drafts (with permission)", "Reputation tasks", + ), + pricing_model_ar="add-on شهري على Growth/Scale", + required_integrations=("x_api", "instagram_graph", "linkedin_lead_forms"), + proof_metric_ar="Social-sourced leads + replied mentions", + ), + ServiceOffering( + key="local_business_growth", + label_ar="Local Business Growth — للمتاجر والعيادات", + label_en="Local Business Growth", + target_customer_ar="عيادات + مطاعم + متاجر + فروع", + outcome_ar="إدارة Google Business + reviews + WhatsApp inbound + booking", + deliverables_ar=( + "Reviews response", "GBP posts", "Branch info sync", + "WhatsApp booking flow", "Payment links", + ), + pricing_model_ar="شهري (999-2,999 ريال) + per-location", + required_integrations=("google_business_profile", "whatsapp", "moyasar"), + proof_metric_ar="Booking rate + average review rating + revenue per location", + ), + ServiceOffering( + key="ai_visibility_aeo_sprint", + label_ar="AI Visibility / AEO Sprint", + label_en="AI Visibility / AEO Sprint", + target_customer_ar="شركات تريد تظهر في إجابات ChatGPT / Gemini / Perplexity", + outcome_ar="زيادة ظهور الشركة في answer engines + خطة محتوى 30 يوم", + deliverables_ar=( + "AEO audit", "Question-gap analysis", "Content plan", + "FAQ pages", "Comparison pages", "Local posts", + ), + pricing_model_ar="رسوم لمرة (15,000 ريال) أو monthly retainer", + required_integrations=("google_business_profile",), + proof_metric_ar="عدد الأسئلة التي تظهر فيها الشركة + competitor delta", + ), + ServiceOffering( + key="revenue_proof_pack_service", + label_ar="Revenue Proof Pack — شهري للإدارة", + label_en="Revenue Proof Pack Service", + target_customer_ar="مدراء يحتاجون إثبات قيمة Dealix شهرياً", + outcome_ar="تقرير شهري بـ ROI + grading + خطة الشهر القادم", + deliverables_ar=( + "Activity report", "Money report", "Quality + Risk report", + "Best-of insights", "Next-month plan", + ), + pricing_model_ar="مدمج مع subscription Growth/Scale", + required_integrations=(), + proof_metric_ar="Customer NPS + renewal rate", + ), + ServiceOffering( + key="customer_success_operator", + label_ar="Customer Success Operator — منع churn", + label_en="Customer Success Operator", + target_customer_ar="شركات SaaS / subscription business", + outcome_ar="health score + churn prediction + upsell signals", + deliverables_ar=( + "Health score 4-dim", "Churn prediction", + "Expansion signals", "QBR auto-drafts", + ), + pricing_model_ar="add-on على Scale tier (1,500 ريال/شهر)", + required_integrations=("crm",), + proof_metric_ar="Customer churn rate + NRR (Net Revenue Retention)", + ), + ServiceOffering( + key="payments_collections_operator", + label_ar="Payments & Collections Operator", + label_en="Payments & Collections Operator", + target_customer_ar="شركات عندها فواتير متأخرة أو payments ضائعة", + outcome_ar="quote + invoice drafts + reminders + recovery", + deliverables_ar=( + "Payment links (Moyasar)", "Invoice drafts", + "Failed-payment recovery", "Renewal reminders", + ), + pricing_model_ar="شهري + 1-3% success fee على recovered revenue", + required_integrations=("moyasar", "whatsapp", "gmail"), + proof_metric_ar="Recovered SAR + on-time payment rate", + ), +) + + +def list_services() -> dict[str, Any]: + """Catalog the platform's sellable services.""" + return { + "total": len(SELLABLE_SERVICES), + "services": [s.to_dict() for s in SELLABLE_SERVICES], + } diff --git a/dealix/auto_client_acquisition/platform_services/tool_gateway.py b/dealix/auto_client_acquisition/platform_services/tool_gateway.py new file mode 100644 index 00000000..12bd86f6 --- /dev/null +++ b/dealix/auto_client_acquisition/platform_services/tool_gateway.py @@ -0,0 +1,193 @@ +""" +Safe Tool Gateway — single chokepoint for every external action. + +Returns one of: draft_created / approval_required / blocked / +ready_for_adapter / unsupported. Never executes a live action here; +the actual API call (Gmail/Calendar/WhatsApp/Moyasar/...) happens in +the dedicated adapter that's gated by an explicit env flag. +""" + +from __future__ import annotations + +import os +from dataclasses import dataclass, field +from typing import Any + +from auto_client_acquisition.platform_services.action_policy import evaluate_action +from auto_client_acquisition.platform_services.channel_registry import get_channel + + +SUPPORTED_TOOLS: tuple[str, ...] = ( + # Gmail / Email + "gmail.create_draft", + "gmail.read_thread", + # Calendar + "calendar.draft_event", + "calendar.insert_event", + # WhatsApp + "whatsapp.send_message", + "whatsapp.draft_message", + # Moyasar + "moyasar.create_payment_link", + "moyasar.create_invoice", + "moyasar.refund", + # Social + "social.post", + "social.send_dm", + # Sheets / CRM + "sheets.append_row", + "crm.update_deal_stage", + # Reviews + "gbp.reply_review", + "gbp.publish_post", +) + + +@dataclass +class GatewayResult: + """Outcome of a tool invocation through the gateway.""" + + status: str # draft_created / approval_required / blocked + # / ready_for_adapter / unsupported + tool: str + matched_policy_rule: str | None = None + reasons_ar: list[str] = field(default_factory=list) + next_action_ar: str = "" + payload_passthrough: dict[str, Any] | None = None + + +# ── Live-execution flag — defaults to OFF ─────────────────────── +def _live_send_allowed(channel: str) -> bool: + """Each channel has its own env flag; OFF by default everywhere.""" + flag_map = { + "whatsapp": "WHATSAPP_ALLOW_LIVE_SEND", + "gmail": "GMAIL_ALLOW_LIVE_SEND", + "google_calendar": "CALENDAR_ALLOW_LIVE_INSERT", + "moyasar": "MOYASAR_ALLOW_LIVE_CHARGE", + "social": "SOCIAL_ALLOW_LIVE_POST", + "x_api": "SOCIAL_ALLOW_LIVE_POST", + "instagram_graph": "SOCIAL_ALLOW_LIVE_POST", + "google_business_profile": "GBP_ALLOW_LIVE_REPLY", + } + flag = flag_map.get(channel) + if not flag: + return False + return os.environ.get(flag, "false").lower() in ("1", "true", "yes") + + +# ── Public API ────────────────────────────────────────────────── +def invoke_tool( + *, + tool: str, + payload: dict[str, Any] | None = None, + context: dict[str, Any] | None = None, +) -> GatewayResult: + """ + Single entry point for every tool action. + + Flow: validate tool name → map to policy action → evaluate policy + → check live-send flag → return GatewayResult (never throws on + business-logic failures). + """ + if tool not in SUPPORTED_TOOLS: + return GatewayResult( + status="unsupported", + tool=tool, + reasons_ar=[f"الأداة غير مدعومة: {tool}"], + ) + + channel_key = tool.split(".", 1)[0] + channel = get_channel(_normalize_channel(channel_key)) + payload = payload or {} + ctx = dict(context or {}) + if "payload" not in ctx: + ctx["payload"] = payload + + # Map tool → policy action (the granular labels the policy understands) + action_map: dict[str, str] = { + "gmail.create_draft": "create_draft", + "gmail.read_thread": "read_data", + "calendar.draft_event": "create_draft", + "calendar.insert_event": "calendar_insert_event", + "whatsapp.send_message": "send_whatsapp", + "whatsapp.draft_message": "create_draft", + "moyasar.create_payment_link": "create_draft", + "moyasar.create_invoice": "create_draft", + "moyasar.refund": "charge_payment", + "social.post": "post_social", + "social.send_dm": "send_social_dm", + "sheets.append_row": "create_draft", + "crm.update_deal_stage": "create_draft", + "gbp.reply_review": "post_social", + "gbp.publish_post": "post_social", + } + policy_action = action_map.get(tool, "create_draft") + + decision = evaluate_action(action=policy_action, context=ctx) + + if decision.decision == "blocked": + return GatewayResult( + status="blocked", + tool=tool, + matched_policy_rule=decision.matched_rule_id, + reasons_ar=decision.reasons_ar, + next_action_ar=decision.suggested_next_action_ar, + ) + if decision.decision == "approval_required": + return GatewayResult( + status="approval_required", + tool=tool, + matched_policy_rule=decision.matched_rule_id, + reasons_ar=decision.reasons_ar, + next_action_ar=decision.suggested_next_action_ar, + payload_passthrough=payload, + ) + + # decision == "allow" → check live-send flag for the channel + if _is_external_send(tool): + if _live_send_allowed(_normalize_channel(channel_key)): + return GatewayResult( + status="ready_for_adapter", + tool=tool, + reasons_ar=["السياسة موافقة + LIVE flag مفعل — جاهز لـ adapter."], + payload_passthrough=payload, + ) + # Default: keep as draft + return GatewayResult( + status="draft_created", + tool=tool, + reasons_ar=["السياسة موافقة لكن LIVE flag غير مفعل — تم حفظه draft."], + payload_passthrough=payload, + ) + + return GatewayResult( + status="draft_created", + tool=tool, + reasons_ar=["إجراء داخلي / draft — لا تفاعل خارجي."], + payload_passthrough=payload, + ) + + +# ── Helpers ────────────────────────────────────────────────────── +def _normalize_channel(prefix: str) -> str: + """Channel registry uses dotted keys; tool prefixes use snake.""" + return { + "calendar": "google_calendar", + "gbp": "google_business_profile", + "social": "x_api", # used as an umbrella prefix + "sheets": "google_sheets", + }.get(prefix, prefix) + + +def _is_external_send(tool: str) -> bool: + return tool in { + "whatsapp.send_message", + "calendar.insert_event", + "moyasar.create_payment_link", + "moyasar.create_invoice", + "moyasar.refund", + "social.post", + "social.send_dm", + "gbp.reply_review", + "gbp.publish_post", + } diff --git a/dealix/auto_client_acquisition/platform_services/unified_inbox.py b/dealix/auto_client_acquisition/platform_services/unified_inbox.py new file mode 100644 index 00000000..6f152236 --- /dev/null +++ b/dealix/auto_client_acquisition/platform_services/unified_inbox.py @@ -0,0 +1,250 @@ +""" +Unified Growth Inbox — turn platform events into Arabic action cards. + +8 card types: opportunity / email_lead / whatsapp_reply / social_comment / +payment / meeting_prep / review_response / partner_suggestion. + +Every card: title_ar, summary_ar, why_it_matters_ar, recommended_action_ar, +risk_level, expected_impact_sar, ≤3 buttons, approval_required. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + +from auto_client_acquisition.platform_services.event_bus import PlatformEvent + + +CARD_TYPES: tuple[str, ...] = ( + "opportunity", + "email_lead", + "whatsapp_reply", + "social_comment", + "payment", + "meeting_prep", + "review_response", + "partner_suggestion", +) + + +@dataclass +class InboxCard: + """One card in the unified inbox.""" + + card_id: str + type: str + channel: str + title_ar: str + summary_ar: str + why_it_matters_ar: str + recommended_action_ar: str + risk_level: str # low / medium / high + expected_impact_sar: float = 0.0 + buttons_ar: tuple[str, ...] = () # ≤3 per WhatsApp limit + approval_required: bool = True + + def __post_init__(self): + if len(self.buttons_ar) > 3: + raise ValueError("buttons_ar must have ≤3 items (WhatsApp limit)") + if self.type not in CARD_TYPES: + raise ValueError(f"unknown card type: {self.type}") + if self.risk_level not in ("low", "medium", "high"): + raise ValueError(f"invalid risk_level: {self.risk_level}") + + def to_dict(self) -> dict[str, Any]: + return { + "card_id": self.card_id, + "type": self.type, + "channel": self.channel, + "title_ar": self.title_ar, + "summary_ar": self.summary_ar, + "why_it_matters_ar": self.why_it_matters_ar, + "recommended_action_ar": self.recommended_action_ar, + "risk_level": self.risk_level, + "expected_impact_sar": self.expected_impact_sar, + "buttons_ar": list(self.buttons_ar), + "approval_required": self.approval_required, + } + + +# ── Per-event-type renderers ───────────────────────────────────── +def build_card_from_event(event: PlatformEvent) -> InboxCard | None: + """Render an event into a card. Returns None for non-actionable events.""" + et = event.event_type + p = event.payload + + if et == "whatsapp.message_received": + return InboxCard( + card_id=f"card_{event.event_id}", + type="whatsapp_reply", + channel="whatsapp", + title_ar=f"رد جديد من {p.get('from_name', '—')}", + summary_ar=str(p.get("text_preview", ""))[:160], + why_it_matters_ar="رد سريع خلال ٣٠ دقيقة يضاعف احتمال الحجز.", + recommended_action_ar="صنّف الرد + جهّز رد عربي مناسب", + risk_level="low", + expected_impact_sar=2_500, + buttons_ar=("اعتمد", "تخطّي", "عدّل"), + ) + + if et == "email.received": + return InboxCard( + card_id=f"card_{event.event_id}", + type="email_lead", + channel="gmail", + title_ar=f"إيميل جديد من {p.get('from', '—')}", + summary_ar=str(p.get("subject", ""))[:200], + why_it_matters_ar="إيميل من عميل محتمل — رد ≤4 ساعات يضاعف التحويل.", + recommended_action_ar="جهّز رد رسمي + عرض اجتماع 15 دقيقة", + risk_level="low", + expected_impact_sar=8_000, + buttons_ar=("جهّز مسودة", "احجز اجتماع", "تخطّي"), + ) + + if et == "calendar.meeting_scheduled": + return InboxCard( + card_id=f"card_{event.event_id}", + type="meeting_prep", + channel="google_calendar", + title_ar=f"اجتماع {p.get('when', 'قريباً')} مع {p.get('contact', '—')}", + summary_ar="جهّزت ملخص الشركة + 5 أسئلة + اعتراضات محتملة + عرض مناسب.", + why_it_matters_ar="الاجتماع المُحضَّر يرفع احتمال الإغلاق بنسبة 40%+.", + recommended_action_ar="افتح ملف التحضير + راجع الأجندة", + risk_level="low", + expected_impact_sar=p.get("expected_value_sar", 25_000), + buttons_ar=("افتح التحضير", "اكتب أجندة", "أرسل تأكيد"), + approval_required=False, + ) + + if et == "payment.failed": + return InboxCard( + card_id=f"card_{event.event_id}", + type="payment", + channel="moyasar", + title_ar="فشل دفعة", + summary_ar=f"العميل {p.get('customer_id', '—')} — مبلغ {p.get('amount_sar', 0):,.0f} ريال.", + why_it_matters_ar="فشل الدفع غالباً سببه فني — متابعة سريعة تنقذ الصفقة.", + recommended_action_ar="جهّز رسالة WhatsApp + رابط Moyasar جديد", + risk_level="medium", + expected_impact_sar=p.get("amount_sar", 2_999), + buttons_ar=("جهّز رسالة", "رابط جديد", "اتصل"), + ) + + if et == "review.created": + rating = float(p.get("rating", 5)) + risk = "high" if rating <= 2 else "medium" if rating <= 3 else "low" + return InboxCard( + card_id=f"card_{event.event_id}", + type="review_response", + channel="google_business_profile", + title_ar=f"تقييم Google جديد: {rating} نجوم", + summary_ar=str(p.get("text", ""))[:180], + why_it_matters_ar=( + "التقييم السلبي بدون رد خلال 24 ساعة يضرّ بالسمعة المحلية." + if rating <= 3 else "التقييم الإيجابي فرصة للشكر + طلب إحالة." + ), + recommended_action_ar=( + "اعتذار قصير + طلب تواصل + حل" if rating <= 3 + else "شكر دافئ + دعوة لطلب إحالة" + ), + risk_level=risk, + expected_impact_sar=1_000, + buttons_ar=("اعتمد الرد", "صعّد للمدير", "تخطّي") + if rating <= 3 + else ("اعتمد الرد", "اطلب إحالة", "تخطّي"), + ) + + if et == "social.comment_received": + return InboxCard( + card_id=f"card_{event.event_id}", + type="social_comment", + channel=event.channel, + title_ar=f"تعليق جديد على {event.channel}", + summary_ar=str(p.get("text", ""))[:150], + why_it_matters_ar="التعليقات الإيجابية = leads warmer من cold outreach.", + recommended_action_ar="جهّز رد عربي + اقترح DM لو فيه إشارة شراء", + risk_level="medium", + expected_impact_sar=1_500, + buttons_ar=("جهّز رد", "ابدأ DM", "تخطّي"), + ) + + if et == "lead.form_submitted": + return InboxCard( + card_id=f"card_{event.event_id}", + type="opportunity", + channel=event.channel, + title_ar=f"Lead جديد: {p.get('company', '—')}", + summary_ar=f"{p.get('name', '')} — {p.get('email', '')} — {p.get('city', '')}", + why_it_matters_ar="Lead تعبأ نموذج → أعلى احتمال تحويل بين كل المصادر.", + recommended_action_ar="رد ≤30 دقيقة + احجز مكالمة 15 دقيقة", + risk_level="low", + expected_impact_sar=p.get("expected_value_sar", 12_000), + buttons_ar=("جهّز رد فوري", "احجز مكالمة", "تخطّي"), + ) + + if et == "partner.suggested": + return InboxCard( + card_id=f"card_{event.event_id}", + type="partner_suggestion", + channel="internal", + title_ar=f"اقتراح شريك: {p.get('partner_name', '—')}", + summary_ar=str(p.get("rationale_ar", ""))[:200], + why_it_matters_ar="الشراكة الواحدة تفتح 3-5 leads warmer من cold.", + recommended_action_ar="جهّز رسالة warm + احجز مكالمة 20 دقيقة", + risk_level="low", + expected_impact_sar=p.get("expected_revenue_sar", 50_000), + buttons_ar=("اكتب رسالة", "احجز", "تخطّي"), + ) + + return None # non-actionable event + + +# ── Demo feed builder ──────────────────────────────────────────── +def build_demo_feed() -> dict[str, Any]: + """A deterministic demo feed for the dashboard preview.""" + from auto_client_acquisition.platform_services.event_bus import make_event + + events = [ + make_event( + event_type="lead.form_submitted", channel="website_forms", + customer_id="demo", + payload={"company": "شركة العقار الذهبي", "name": "خالد", + "email": "khalid@example.sa", "city": "الرياض", + "expected_value_sar": 18_000}, + ), + make_event( + event_type="email.received", channel="gmail", + customer_id="demo", + payload={"from": "ali@example.sa", "subject": "استفسار عن الباقات للشركات"}, + ), + make_event( + event_type="whatsapp.message_received", channel="whatsapp", + customer_id="demo", + payload={"from_name": "نورا — Saudi Logistics", + "text_preview": "ابغى أعرف وش الفرق بين Growth و Scale؟"}, + ), + make_event( + event_type="payment.failed", channel="moyasar", + customer_id="demo", + payload={"customer_id": "cust_123", "amount_sar": 2_999}, + ), + make_event( + event_type="review.created", channel="google_business_profile", + customer_id="demo", + payload={"rating": 2, "text": "تأخر الرد في عيادتنا"}, + ), + make_event( + event_type="partner.suggested", channel="internal", + customer_id="demo", + payload={"partner_name": "وكالة B2B في جدة", + "rationale_ar": "عملاؤها يحتاجون lead-gen — Dealix يكمل خدماتها.", + "expected_revenue_sar": 60_000}, + ), + ] + cards = [c.to_dict() for e in events if (c := build_card_from_event(e)) is not None] + return { + "feed_size": len(cards), + "cards": cards, + "policy_note_ar": "كل card عربي + ≤3 buttons + approval-aware.", + } diff --git a/dealix/docs/DEALIX_100_PERCENT_LAUNCH_PLAN.md b/dealix/docs/DEALIX_100_PERCENT_LAUNCH_PLAN.md index d7ebfe7a..62f5742e 100644 --- a/dealix/docs/DEALIX_100_PERCENT_LAUNCH_PLAN.md +++ b/dealix/docs/DEALIX_100_PERCENT_LAUNCH_PLAN.md @@ -148,6 +148,36 @@ OAuth Gmail/Calendar، حصص، سياسات. وعد منتجي مركزي: من مدخلات شركة/قطاع/مدينة/عرض/هدف إلى قائمة ١٠ فرص مع Why Now ومستوى مخاطرة ومسودات عربية **بانتظار الموافقة فقط** — **`POST /api/v1/innovation/opportunities/ten-in-ten`**؛ وصف المهمة في `GET /api/v1/innovation/growth-missions`؛ الاستراتيجية في [`INNOVATION_STRATEGY.md`](INNOVATION_STRATEGY.md)؛ الإطار التشغيلي بجانب `GET /api/v1/business/gtm/first-10` عند التوسع. +## 32. Platform Services Layer — برج التحكم بالنمو + +طبقة موحدة multi-channel فوق `growth_operator` تحوّل Dealix من قناة WhatsApp إلى منصة: + +- **11 قناة** (`whatsapp, gmail, google_calendar, moyasar, linkedin_lead_forms, x_api, instagram_graph, google_business_profile, google_sheets, crm, website_forms`). +- **Action Policy Engine**: block_cold_whatsapp / block_payment_no_confirm / block_secrets / external_send_needs_approval / high_value_deal_review. +- **Tool Gateway** هو المخرج التنفيذي الوحيد — كل أداة تمر منه. Live env flags افتراضياً OFF. +- **Unified Inbox**: 8 أنواع بطاقات، ≤3 أزرار، عربية. +- **Action Ledger** + **Proof Ledger** (أثر فعلي مقاس بالقناة). +- **12 خدمة قابلة للبيع** (`growth_operator_subscription`, `channel_setup_service`, `lead_intelligence_service`, `partnership_sprint`, `email_revenue_rescue`, `social_growth_os`, `local_business_growth`, `ai_visibility_aeo_sprint`, `revenue_proof_pack_service`, `customer_success_operator`, `payments_collections_operator`, `outreach_approval_service`). + +**Endpoints:** `/api/v1/platform/{services/catalog, channels, policy/rules, actions/evaluate, tools/invoke, events/ingest, inbox/feed, identity/resolve, ledger/summary, proof-ledger/demo}`. **التفصيل:** [`PLATFORM_SERVICES_STRATEGY.md`](PLATFORM_SERVICES_STRATEGY.md). + +## 33. Intelligence Layer — الشبكة العصبية للنمو + +طبقة فوق Platform Services تجعل Dealix يتعلم ويقترح ويحاكي: + +- **Growth Brain** لكل عميل + `is_ready_for_autopilot()` (≥30 signals + ≥40% accept). +- **Command Feed**: 9 أنواع بطاقات يومية (opportunity / revenue_leak / partner_suggestion / meeting_prep / review_response / competitive_move / customer_reactivation / ai_visibility_alert / action_required). +- **Action Graph** (10 أنواع حواف): signal → action → outcome. +- **Mission Engine**: 7 ميشنات، **Kill Feature: `first_10_opportunities`**. +- **Decision Memory**: تعلّم من Accept/Skip/Edit/Block. +- **Trust Score** مركب لكل رسالة (safe ≥70 / needs_review 40-69 / blocked <40). +- **Revenue DNA**: best_channel / best_segment / best_angle / common_objection / avg_cycle_days. +- **Opportunity Simulator** (9 قطاعات سعودية): توقع replies/meetings/deals/pipeline_sar + risk_score. +- **Competitive Move Detector**: 8 أنواع حركات + recommended_action_ar. +- **Founder Shadow Board**: موجز أسبوعي (3 قرارات + 3 فرص + 3 مخاطر + علاقة + تجربة + مؤشر). + +**Endpoints:** `/api/v1/intelligence/{growth-brain/build, command-feed/demo, missions, missions/recommend, trust-score, revenue-dna/demo, revenue-dna, simulate-opportunity, competitive-move/analyze, board-brief/demo, decisions/record, decisions/preferences}`. **التفصيل:** [`INTELLIGENCE_LAYER_STRATEGY.md`](INTELLIGENCE_LAYER_STRATEGY.md). + --- **الخلاصة:** المنتج **قوي كأساس سوقي وتقني**؛ الإطلاق العام يحتاج تشغيلاً وامتثالاً وتجربة عميل مغلقة أولاً. diff --git a/dealix/docs/INTELLIGENCE_LAYER_STRATEGY.md b/dealix/docs/INTELLIGENCE_LAYER_STRATEGY.md new file mode 100644 index 00000000..b0eff651 --- /dev/null +++ b/dealix/docs/INTELLIGENCE_LAYER_STRATEGY.md @@ -0,0 +1,269 @@ +# Intelligence Layer Strategy — الشبكة العصبية للنمو +## (Dealix Growth Neural Network) + +> **الهدف:** تحويل Dealix من "منصة multi-channel" إلى **شبكة عصبية للنمو** تتعلم من قرارات صاحب النشاط، تستخرج DNA الإيرادات، وتعمل ميشنات نمو ذاتية بدلاً من الانتظار للمستخدم. + +--- + +## 1. لماذا Intelligence Layer؟ + +Platform Services أعطتنا **القنوات + الأمان + الـledgers**. لكن: +- لا تتذكر ما يفضله المستخدم. +- لا تستخرج رؤى من الفائزين/الخاسرين. +- لا تقترح بطاقات قرار جاهزة كل صباح. +- لا تحاكي قبل ما ترسل. + +Intelligence Layer هي الطبقة التي تجعل المنصة "تشتغل لوحدها أثناء نوم المستخدم". + +--- + +## 2. الوحدات (10 modules) + +| # | الوحدة | الدور | +|---|--------|------| +| 1 | `growth_brain` | Brain لكل عميل: قطاع، قنوات، أهداف، تفضيلات، مؤشرات. `is_ready_for_autopilot()`. | +| 2 | `command_feed` | بطاقات قرار يومية بالعربي (opportunity / revenue_leak / partner_suggestion / meeting_prep / review_response / competitive_move). | +| 3 | `action_graph` | رسم بياني للنوع: signal → action → outcome (10 أنواع حواف). | +| 4 | `mission_engine` | 7 ميشنات نمو، أهمها **Kill Feature: "10 فرص في 10 دقائق"**. | +| 5 | `decision_memory` | يتعلم من Accept / Skip / Edit / Block ويخرج preferences. | +| 6 | `trust_score` | مقياس مركّب لكل رسالة (source + opt_in + channel + content + freq + approval). | +| 7 | `revenue_dna` | يستخرج: أفضل قناة، أفضل segment، أفضل angle، أكثر اعتراض، متوسط دورة البيع. | +| 8 | `opportunity_simulator` | محاكي إلى الأمام: target_count → expected_replies/meetings/deals/pipeline_sar. | +| 9 | `competitive_moves` | رصد + رد على حركات المنافسين (price_change / new_offer / hire / funding / launch...). | +| 10 | `board_brief` | Founder Shadow Board — موجز أسبوعي: قرارات، فرص، مخاطر، علاقة، تجربة، مؤشر. | + +--- + +## 3. Growth Brain + +`build_growth_brain(payload)` يبني سجل لكل عميل: +``` +customer_id, sector, regions, channels_connected, +preferred_tone, growth_priorities, +learning_signal_count, accept_rate_30d +``` + +**الجاهزية للأوتوبايلوت:** +``` +ready = (learning_signal_count ≥ 30) + AND (accept_rate_30d ≥ 0.40) + AND (≥ 1 قناة موصولة) +``` + +قبل الجاهزية → **draft + approval فقط**. + +--- + +## 4. Command Feed (يومي) + +بطاقات بالعربي مع ≤3 أزرار، 9 أنواع: +``` +opportunity, revenue_leak, partner_suggestion, +meeting_prep, review_response, ai_visibility_alert, +competitive_move, customer_reactivation, action_required +``` + +`build_command_feed_demo()` يرجع 6 بطاقات تجريبية واقعية. + +--- + +## 5. Action Graph + +أنواع الحواف الـ10: +``` +signal_created_opportunity, message_triggered_reply, +reply_led_to_meeting, meeting_led_to_proposal, +proposal_led_to_payment, partner_suggestion_taken, +review_response_recovered_customer, approval_allowed_send, +blocked_action_prevented_risk, content_generated_lead +``` + +`what_works_summary(customer_id)` يُرجع: مجموع الحواف + توزيعها بالنوع → "ما الذي يعمل فعلاً". + +--- + +## 6. Mission Engine — 7 ميشنات + +| ID | الاسم | ملاحظات | +|----|-------|---------| +| **first_10_opportunities** ⭐ | 10 فرص في 10 دقائق | **Kill Feature** — يبدأ من 0 ويُسلم 10 leads بالعربي قبل أن يعتاد المستخدم على المنصة. | +| revenue_leak_rescue | استعادة الإيرادات المتسربة | عملاء توقفوا، فواتير معلقة. | +| partnership_sprint | سبرنت شراكات | Partner Graph — اقتراحات تكامل. | +| customer_reactivation | إعادة تنشيط عملاء | فترة سكون → رسالة دافئة. | +| meeting_booking_sprint | حجز اجتماعات | drafts للجدولة + اعتماد. | +| ai_visibility_sprint | Answer Engine Optimization | ظهور النشاط في Perplexity / ChatGPT / Gemini. | +| competitive_response | الرد على حركات المنافسين | يُفعّل عند رصد price_change / launch / funding. | + +`recommend_missions(brain, limit=3)` يرتّب بحسب توافق القطاع + القنوات + الأولويات. + +--- + +## 7. Decision Memory + +يتعلم من 4 قرارات: `accept / skip / edit / block`. + +`preferences()` يُرجع: +``` +accept_rate, samples, +preferred_channels, preferred_tones, preferred_sectors, +rejected_action_types +``` + +يستخدمها `mission_engine` لرفع/خفض ترتيب البطاقات → الـ "warm-up" loop. + +--- + +## 8. Trust Score + +نتيجة 0..100 + verdict (`safe ≥70` / `needs_review 40-69` / `blocked <40`). + +العوامل: +- `source_quality` (customer / opt_in_lead / referral / cold / unknown). +- `opt_in` (boolean). +- `channel` risk (whatsapp risk أعلى من email). +- محتوى الرسالة (عبارات محظورة: "ضمان 100%", "آخر فرصة"...). +- `frequency_count_this_week` vs `weekly_cap`. +- `approval_status`. + +تطبيق فوري: قبل أي `tool_gateway.invoke_tool` → بطاقة في الـCommand Feed بدلاً من الإرسال. + +--- + +## 9. Revenue DNA + +`extract_revenue_dna(customer_id, won_deals, replies, objections)` يُرجع: +``` +best_channel, best_segment, best_message_angle, +common_objection, avg_cycle_days, +deals_observed, next_experiment_ar +``` + +استعمال: ميشن `revenue_dna_demo` يُري المالك "هذا ما يفوز فعلاً عندك". + +--- + +## 10. Opportunity Simulator + +`simulate_opportunity(target_count, sector, avg_deal_value_sar, channel, cold_pct, quality_lift)`: + +يُرجع: +``` +expected_replies, expected_meetings, expected_deals, +expected_pipeline_sar, risk_score (0..100), +risks_ar, rates_used, approval_required=True +``` + +9 قطاعات سعودية مهيّأة (real_estate, saas, retail, food, education, healthcare, logistics, fintech, contracting). + +**استعمال حرج:** تحاكِ قبل ما تنفّذ → "مع 100 جهة، النتيجة المتوقعة 6 صفقات بقيمة 300K، مخاطرة PDPL متوسطة لو 60% بارد". + +--- + +## 11. Competitive Moves + +8 أنواع حركات: `price_change, new_offer, new_hire, funding, launch, partnership, layoffs, expansion`. + +`analyze_competitive_move(competitor_name, move_type, payload)` → urgency + Arabic recommended_action + approval_required. + +مثال: price_change بـ-25% → urgency `high` + اقتراح بطاقة "أرسل عرض مضاد للعملاء المترددين". + +--- + +## 12. Board Brief — Founder Shadow Board + +`build_board_brief()` يُرجع موجز أسبوعي: +``` +decisions_required_ar (3), +top_opportunities_ar (3), +top_risks_ar (3), +key_relationship_ar, +experiment_to_run_ar, +metric_to_watch_ar, +money_summary +``` + +استعمال: ميل أسبوعي يومي الأحد 7:00 ص → "هذا ما يحتاج قراركم هذا الأسبوع، وهذا ما يكشفه الذكاء الاصطناعي". + +--- + +## 13. Endpoints (`/api/v1/intelligence/...`) + +``` +POST /growth-brain/build +GET /command-feed/demo +GET /missions +POST /missions/recommend +POST /trust-score +GET /revenue-dna/demo +POST /revenue-dna +POST /simulate-opportunity +POST /competitive-move/analyze +GET /board-brief/demo +POST /decisions/record +GET /decisions/preferences +``` + +--- + +## 14. اختبارات + +`tests/unit/test_intelligence_layer.py` — تغطية لكل الوحدات الـ10: +- growth brain autopilot threshold +- command feed Arabic + ≤3 buttons + critical types +- action graph add/summary + unknown edge type raises +- missions list + kill feature + recommend +- decision memory records/aggregates/empty/invalid +- trust score (cold blocked, safe, risky phrases, freq cap lowers) +- revenue DNA best channel + defaults +- simulator pipeline + cold_pct warning + unknown sector default +- competitive move urgency + unknown type + funding action +- board brief structure (3 من كل: قرار/فرصة/مخاطرة) + +--- + +## 15. ما لا تفعله هذه الطبقة + +- **لا** ترسل أي شيء فعلياً (تحت سقف tool_gateway). +- **لا** تتجاوز سياسات platform_services. +- **لا** تستخدم بيانات بدون consent. +- **لا** تنفذ ميشن بدون اعتماد المالك (إلا بعد `is_ready_for_autopilot()`). + +--- + +## 16. الاندماج مع Platform Services + +``` +Platform Services Intelligence Layer +──────────────── ──────────────────── +event_bus ←→ action_graph (يستهلك الأحداث) +identity ←→ growth_brain (هوية → سياق) +channel_registry ←→ simulator (rates_used per channel) +action_policy ←→ trust_score (verdict → policy gate) +tool_gateway ←→ command_feed (cards تُنفّذ عبر gateway) +unified_inbox ←→ command_feed (نفس البنية، طبقة أعلى) +action_ledger ←→ decision_memory (يقرأ الـledger) +proof_ledger ←→ board_brief (money_summary مصدره proof) +service_catalog ←→ mission_engine (الميشنات → خدمات قابلة للبيع) +``` + +--- + +## 17. الـ Kill Feature + +**"10 فرص في 10 دقائق"** — `first_10_opportunities`: + +1. عند بدء العميل، نسأل: قطاع + منطقة + قناة مفضلة. +2. خلال 10 دقائق نُسلم 10 بطاقات `opportunity` بالعربي مع `recommended_action_ar`. +3. كل بطاقة draft → اعتماد → تنفيذ. +4. إذا قبل المالك ≥4 → نزيد signal_count + accept_rate → نقترب من autopilot. + +هذه الميزة تكسر "blank canvas problem" وتُري قيمة فورية قبل أن يفتح المستخدم WhatsApp Web. + +--- + +## 18. ما يلي + +- ربط `command_feed` بإشارات حقيقية (Gmail / WA Business / GBP / website forms). +- استبدال الـin-memory `_MEMORY` بـ Supabase. +- جدولة `board_brief` يوم الأحد 7 ص (Cron + email/WhatsApp). +- شحن أول 100 عميل تحت "Approval-First" لجمع أول 3,000 قرار → تدريب decision_memory الحقيقي. diff --git a/dealix/docs/PLATFORM_SERVICES_STRATEGY.md b/dealix/docs/PLATFORM_SERVICES_STRATEGY.md new file mode 100644 index 00000000..bad2410a --- /dev/null +++ b/dealix/docs/PLATFORM_SERVICES_STRATEGY.md @@ -0,0 +1,196 @@ +# Platform Services Strategy — برج التحكم بالنمو +## (Dealix Growth Control Tower) + +> **الهدف:** تحويل Dealix من "WhatsApp Growth Operator" إلى **منصة نمو متعددة القنوات** تشتغل تحت سقف واحد، بسياسات أمان موحدة، ومسار اعتماد واحد، وبروتوكول أحداث موحد. + +--- + +## 1. لماذا Platform Services؟ + +كل قناة (WhatsApp, Gmail, Calendar, LinkedIn, X, Instagram, GBP, Sheets, CRM, Moyasar, Website Forms) تحتاج: +- تطبيع الإشارات (signal normalization). +- سياسة قبول/رفض موحدة (PDPL-aware). +- حل هوية متقاطع (cross-channel identity). +- مدخل تنفيذي موحد (single tool gateway) لمنع الإرسال البارد، تسريب الأسرار، أو الدفع بدون تأكيد. +- صندوق بريد موحد (unified inbox) ببطاقات قابلة للاعتماد. +- سجل أفعال (action ledger) للمراجعة (SDAIA / PDPL). +- سجل أثر (proof ledger) لتسويق "كم وفّرنا، كم سحبنا، كم منعنا من مخاطر". + +بدون هذه الطبقة، كل ميزة جديدة تحتاج تكامل مخصص → فوضى أمنية + أمنية + قانونية. + +--- + +## 2. الوحدات (10 modules) + +| # | الوحدة | الدور | +|---|--------|------| +| 1 | `event_bus` | تصنيف موحد لـ27 نوع حدث (whatsapp/email/calendar/lead/payment/review/social/partner/sheet/crm/action). | +| 2 | `identity_resolution` | دمج phone + email + CRM ID + social handles → هوية موحدة. | +| 3 | `channel_registry` | 11 قناة، لكل واحدة capabilities + allowed/blocked actions + PDPL notes. | +| 4 | `action_policy` | محرك قواعد (block_cold_whatsapp, block_payment_no_confirm, block_secrets, external_send_needs_approval...). | +| 5 | `tool_gateway` | المخرج التنفيذي الوحيد. كل أداة تمر من هنا → سياسة → draft / approval_required / blocked / ready. | +| 6 | `unified_inbox` | بطاقات قرار (≤3 أزرار، عربية، type+risk+recommended_action). | +| 7 | `action_ledger` | سجل كل فعل بمراحله (requested → approved → executed). | +| 8 | `proof_ledger` | عدّاد أثر (leads, meetings, drafts, sends, payments, revenue, risks_blocked, time_saved). | +| 9 | `service_catalog` | 12 خدمة قابلة للبيع تحت Dealix Operator OS. | +| 10 | (router + tests) | `api/routers/platform_services.py` + اختبارات شاملة. | + +--- + +## 3. القنوات الـ11 + +``` +whatsapp, gmail, google_calendar, moyasar, linkedin_lead_forms, +x_api, instagram_graph, google_business_profile, google_sheets, +crm, website_forms +``` + +كل قناة لها: +- `capabilities` +- `beta_status` (`live` / `beta` / `coming_soon`) +- `allowed_actions` / `blocked_actions` +- `risk_level` +- `notes_ar` + +مثال: WhatsApp **يحظر** `cold_send_without_consent`. Gmail يستخدم `gmail.compose` فقط (drafts). Calendar `live_inserted=False` حتى يربط OAuth. + +--- + +## 4. سياسة الأمان (Action Policy) + +**قواعد block أساسية:** +1. WhatsApp بارد بدون consent → **blocked** (PDPL). +2. أي charge/refund بدون `user_confirmed=true` → **blocked**. +3. أي payload يحوي `api_key/secret/token/...` → **blocked**. + +**قواعد approval_required:** +- أي إرسال خارجي (`send_*`) → اعتماد إنساني. +- إدراج موعد في تقويم → اعتماد. +- DM على سوشل → اعتماد + opt-in. +- صفقة قيمتها ≥ 200,000 ريال → اعتماد. + +**default:** allow (للـ read-only data ops). + +--- + +## 5. Tool Gateway + +كل أداة (`whatsapp.send_message`, `gmail.compose`, `calendar.insert_event`, `moyasar.refund`, `gbp.reply_review`, ...) **يجب** تمر من `invoke_tool()`. + +النتائج المحتملة: +- `unsupported` — أداة غير مسجلة. +- `blocked` — السياسة منعت. +- `approval_required` — تحتاج قبول إنساني. +- `draft_created` — افتراضياً (live env flag = OFF). +- `ready_for_adapter` — جاهز للتنفيذ الحقيقي إذا اشتغل live env flag. + +**Live env flags** (افتراضياً كلها OFF): +``` +WHATSAPP_ALLOW_LIVE_SEND +GMAIL_ALLOW_LIVE_SEND +CALENDAR_ALLOW_LIVE_INSERT +MOYASAR_ALLOW_LIVE_CHARGE +GBP_ALLOW_LIVE_REPLY +``` + +--- + +## 6. صندوق البريد الموحد (Unified Inbox) + +8 أنواع بطاقات: +``` +opportunity, email_lead, whatsapp_reply, payment, +meeting_prep, review_response, partner_suggestion, action_required +``` + +كل بطاقة: +- ≤3 أزرار (تطبيق قيد WhatsApp Reply Buttons). +- عربية (title_ar, summary_ar, why_it_matters_ar, recommended_action_ar). +- `risk_level` (low/medium/high). + +البطاقات تُبنى تلقائياً من `PlatformEvent` عبر `build_card_from_event()`. + +--- + +## 7. Proof Ledger + +عدّاد يقيس الأثر العملي للمنصة: +``` +leads_created, meetings_booked, drafts_approved, +messages_sent, payments_initiated, payments_paid, +revenue_influenced_sar, risks_blocked, time_saved_hours, +partner_opportunities, by_channel +``` + +هذا هو **Marketing Asset** — لتُري العميل: "في 30 يوم، نحن ساعدناك تعمل X، منعنا Y مخاطر، وفرنا Z ساعة". + +--- + +## 8. خدمات قابلة للبيع (Service Catalog) + +12 خدمة تجارية: +1. `growth_operator_subscription` — اشتراك شهري للمنصة. +2. `channel_setup_service` — ربط القنوات (one-time). +3. `lead_intelligence_service` — إثراء + تأهيل لقاءات. +4. `outreach_approval_service` — drafts + approval workflow. +5. `partnership_sprint` — فرص تعاون عبر Partner Graph. +6. `email_revenue_rescue` — استعادة عملاء إيميل. +7. `social_growth_os` — تنبيهات + drafts + جدولة. +8. `local_business_growth` — GBP + reviews + visibility. +9. `ai_visibility_aeo_sprint` — Answer Engine Optimization. +10. `revenue_proof_pack_service` — تقرير أثر لمستثمرين / عملاء. +11. `customer_success_operator` — خفض churn + توسيع. +12. `payments_collections_operator` — تذكير + تحصيل (Moyasar). + +--- + +## 9. Endpoints (`/api/v1/platform/...`) + +``` +GET /services/catalog +GET /channels +GET /channels/{channel_key} +GET /policy/rules +POST /actions/evaluate +POST /actions/approve +GET /ledger/summary +POST /events/ingest +GET /inbox/feed +POST /identity/resolve +GET /identity/resolve-demo +POST /tools/invoke +GET /proof-ledger/demo +``` + +--- + +## 10. اختبارات + +`tests/unit/test_platform_services.py` — تغطية لكل الوحدات الـ10: +- catalog completeness +- channel coverage + cold-send blocked +- event validation +- policy (cold WA blocked, secrets blocked, payment confirmation, external send approval, high-value review) +- gateway (unsupported / blocked / draft default / live flag check) +- identity multi-signal merge +- inbox card validation (≤3 buttons + valid type) +- action ledger summary +- proof ledger structure + +--- + +## 11. ما لا تفعله هذه الطبقة + +- **لا** ترسل واتساب فعلياً (افتراضياً draft). +- **لا** ترسل Gmail فعلياً. +- **لا** تدرج موعد في Google Calendar. +- **لا** تأخذ أو تعيد دفعة بدون user_confirmed. +- **لا** تخزن مفاتيح API في payload. + +--- + +## 12. ما يلي + +- ربط Adapters حقيقية (WhatsApp Cloud, Gmail, Calendar) خلف الـenv flags. +- استبدال in-memory ledgers بـ Supabase. +- تشغيل `proof_ledger` على بيانات إنتاج مع تجربة عميل واحد. diff --git a/dealix/tests/unit/test_intelligence_layer.py b/dealix/tests/unit/test_intelligence_layer.py new file mode 100644 index 00000000..ceb94d02 --- /dev/null +++ b/dealix/tests/unit/test_intelligence_layer.py @@ -0,0 +1,281 @@ +"""Unit tests for the Intelligence Layer.""" + +from __future__ import annotations + +import pytest + +from auto_client_acquisition.intelligence_layer import ( + DecisionMemory, + EDGE_TYPES, + INTEL_MISSIONS, + ActionGraph, + analyze_competitive_move, + build_board_brief, + build_command_feed_demo, + build_growth_brain, + build_revenue_dna_demo, + compute_trust_score, + extract_revenue_dna, + learn_from_decision, + list_intel_missions, + recommend_missions, + simulate_opportunity, +) + + +# ── Growth Brain ───────────────────────────────────────────── +def test_growth_brain_builds_with_defaults(): + brain = build_growth_brain() + assert brain.customer_id == "demo" + assert "whatsapp" in brain.channels_connected + assert brain.preferred_tone == "warm" + + +def test_growth_brain_autopilot_readiness(): + new_brain = build_growth_brain({ + "learning_signal_count": 5, "accept_rate_30d": 0.2, + "channels_connected": ("whatsapp",), + }) + assert new_brain.is_ready_for_autopilot() is False + + mature_brain = build_growth_brain({ + "learning_signal_count": 50, "accept_rate_30d": 0.55, + "channels_connected": ("whatsapp", "gmail"), + }) + assert mature_brain.is_ready_for_autopilot() is True + + +# ── Command Feed ───────────────────────────────────────────── +def test_command_feed_returns_arabic_cards(): + out = build_command_feed_demo() + assert out["feed_size"] >= 5 + for card in out["cards"]: + assert len(card["buttons_ar"]) <= 3 + assert any("؀" <= ch <= "ۿ" for ch in card["title_ar"]) + + +def test_command_feed_includes_critical_card_types(): + out = build_command_feed_demo() + types = {c["type"] for c in out["cards"]} + for required in ("opportunity", "revenue_leak", "partner_suggestion", + "meeting_prep", "review_response"): + assert required in types + + +# ── Action Graph ───────────────────────────────────────────── +def test_action_graph_add_and_summarize(): + g = ActionGraph() + g.add_edge( + edge_type="signal_created_opportunity", + src_id="signal_1", dst_id="opp_1", customer_id="c1", + ) + g.add_edge( + edge_type="message_triggered_reply", + src_id="msg_1", dst_id="reply_1", customer_id="c1", + ) + summary = g.what_works_summary("c1") + assert summary["total_edges"] == 2 + assert "signal_created_opportunity" in summary["by_edge_type"] + + +def test_action_graph_unknown_edge_type_raises(): + g = ActionGraph() + with pytest.raises(ValueError): + g.add_edge(edge_type="bogus", src_id="a", dst_id="b", customer_id="c") + + +def test_edge_types_cover_essentials(): + for required in ("signal_created_opportunity", "message_triggered_reply", + "approval_allowed_send", "blocked_action_prevented_risk"): + assert required in EDGE_TYPES + + +# ── Mission Engine ─────────────────────────────────────────── +def test_missions_include_first_10(): + out = list_intel_missions() + ids = {m["id"] for m in out["missions"]} + assert "first_10_opportunities" in ids + assert out["kill_feature_id"] == "first_10_opportunities" + + +def test_missions_include_aeo_and_competitive(): + ids = {m["id"] for m in INTEL_MISSIONS} + assert "ai_visibility_sprint" in ids + assert "competitive_response" in ids + + +def test_recommend_missions_prioritizes_kill_feature(): + """Kill feature should always be near the top.""" + brain = build_growth_brain({ + "channels_connected": ("whatsapp",), + "growth_priorities": ("fill_pipeline",), + }) + rec = recommend_missions(brain, limit=3) + ids = [m["id"] for m in rec["recommended"]] + assert "first_10_opportunities" in ids + + +def test_recommend_missions_without_brain(): + rec = recommend_missions(None, limit=2) + assert len(rec["recommended"]) == 2 + + +# ── Decision Memory ────────────────────────────────────────── +def test_decision_memory_records_and_aggregates(): + mem = DecisionMemory(customer_id="c1") + learn_from_decision(memory=mem, decision="accept", + action_type="send_whatsapp", channel="whatsapp", + sector="real_estate", tone="warm") + learn_from_decision(memory=mem, decision="accept", + action_type="send_whatsapp", channel="whatsapp", + tone="warm") + learn_from_decision(memory=mem, decision="skip", + action_type="send_email", channel="gmail") + prefs = mem.preferences() + assert prefs["accept_rate"] == 0.6667 or 0.6 < prefs["accept_rate"] < 0.7 + assert "whatsapp" in prefs["preferred_channels"] + assert "warm" in prefs["preferred_tones"] + assert "send_email" in prefs["rejected_action_types"] + + +def test_decision_memory_unknown_decision_raises(): + mem = DecisionMemory(customer_id="c1") + with pytest.raises(ValueError): + mem.append(decision="bogus", action_type="x", channel="y") + + +def test_decision_memory_empty(): + mem = DecisionMemory(customer_id="c1") + prefs = mem.preferences() + assert prefs["samples"] == 0 + assert prefs["accept_rate"] == 0.0 + + +# ── Trust Score ────────────────────────────────────────────── +def test_trust_blocks_cold_whatsapp_no_optin(): + out = compute_trust_score( + source_quality="cold", opt_in=False, channel="whatsapp", + message_text="hello", approval_status="pending", + ) + assert out["verdict"] == "blocked" + + +def test_trust_safe_for_existing_customer_with_consent(): + out = compute_trust_score( + source_quality="customer", opt_in=True, channel="whatsapp", + message_text="مرحباً، تحديث للعميل العزيز.", + approval_status="approved", + ) + assert out["verdict"] == "safe" + assert out["score"] >= 70 + + +def test_trust_blocks_risky_phrases(): + out = compute_trust_score( + source_quality="customer", opt_in=True, channel="whatsapp", + message_text="ضمان 100% نتائج مضمونة آخر فرصة", + approval_status="approved", + ) + assert out["verdict"] in ("blocked", "needs_review") + + +def test_trust_freq_cap_lowers_score(): + """Hitting the weekly cap should lower the trust score vs not hitting it.""" + base = compute_trust_score( + source_quality="customer", opt_in=True, channel="whatsapp", + message_text="hello", frequency_count_this_week=0, weekly_cap=2, + approval_status="approved", + ) + capped = compute_trust_score( + source_quality="customer", opt_in=True, channel="whatsapp", + message_text="hello", frequency_count_this_week=2, weekly_cap=2, + approval_status="approved", + ) + assert capped["score"] < base["score"] + assert any("سقف" in r or "weekly" in r.lower() or "تجاوز" in r + for r in capped["reasons_ar"]) + + +# ── Revenue DNA ────────────────────────────────────────────── +def test_revenue_dna_extracts_best_channel(): + out = extract_revenue_dna( + customer_id="c1", + won_deals=[ + {"channel": "whatsapp", "segment": "inbound_lead", "message_angle": "value", "cycle_days": 18}, + {"channel": "whatsapp", "segment": "inbound_lead", "message_angle": "value", "cycle_days": 20}, + {"channel": "email", "segment": "referral", "message_angle": "warm", "cycle_days": 30}, + ], + ) + assert out["best_channel"] == "whatsapp" + assert out["deals_observed"] == 3 + + +def test_revenue_dna_demo_has_next_experiment(): + out = build_revenue_dna_demo() + assert "next_experiment_ar" in out + assert any("؀" <= ch <= "ۿ" for ch in out["next_experiment_ar"]) + + +def test_revenue_dna_empty_input_returns_defaults(): + out = extract_revenue_dna(customer_id="c1") + assert out["best_channel"] == "whatsapp" # safe default + assert out["deals_observed"] == 0 + + +# ── Opportunity Simulator ──────────────────────────────────── +def test_simulator_returns_pipeline_estimate(): + out = simulate_opportunity( + target_count=100, sector="real_estate", + avg_deal_value_sar=50_000, channel="whatsapp", cold_pct=0, + ) + assert out["expected_replies"] >= 0 + assert out["expected_pipeline_sar"] >= 0 + assert "rates_used" in out + assert out["approval_required"] is True + + +def test_simulator_warns_high_cold_pct(): + out = simulate_opportunity( + target_count=100, sector="saas", channel="whatsapp", cold_pct=0.6, + ) + assert out["risk_score"] >= 70 + assert any("PDPL" in r or "cold" in r for r in out["risks_ar"]) + + +def test_simulator_unknown_sector_uses_default(): + out = simulate_opportunity( + target_count=50, sector="totally_unknown_xyz", channel="whatsapp", cold_pct=0, + ) + assert "rates_used" in out + assert out["expected_pipeline_sar"] >= 0 + + +# ── Competitive Moves ──────────────────────────────────────── +def test_competitive_move_price_change_drop_high_urgency(): + out = analyze_competitive_move( + competitor_name="X", move_type="price_change", + payload={"price_delta_pct": -25}, + ) + assert out["urgency"] == "high" + assert out["approval_required"] is True + + +def test_competitive_move_unknown_type(): + out = analyze_competitive_move(competitor_name="X", move_type="bogus_type") + assert "error" in out + + +def test_competitive_move_funding_returns_action(): + out = analyze_competitive_move(competitor_name="X", move_type="funding") + assert "recommended_action_ar" in out + + +# ── Board Brief ────────────────────────────────────────────── +def test_board_brief_returns_decisions_opportunities_risks(): + out = build_board_brief() + assert len(out["decisions_required_ar"]) >= 3 + assert len(out["top_opportunities_ar"]) >= 3 + assert len(out["top_risks_ar"]) >= 3 + assert "key_relationship_ar" in out + assert "experiment_to_run_ar" in out + assert "metric_to_watch_ar" in out diff --git a/dealix/tests/unit/test_platform_services.py b/dealix/tests/unit/test_platform_services.py new file mode 100644 index 00000000..e1e0c70a --- /dev/null +++ b/dealix/tests/unit/test_platform_services.py @@ -0,0 +1,298 @@ +"""Unit tests for the Platform Services Layer.""" + +from __future__ import annotations + +import pytest + +from auto_client_acquisition.platform_services import ( + ALL_CHANNELS, + EVENT_TYPES, + POLICY_RULES, + SELLABLE_SERVICES, + build_card_from_event, + build_demo_feed, + build_demo_platform_proof, + evaluate_action, + get_channel, + invoke_tool, + list_services, + make_event, + resolve_identity, +) +from auto_client_acquisition.platform_services.action_ledger import ActionLedger +from auto_client_acquisition.platform_services.channel_registry import channels_summary +from auto_client_acquisition.platform_services.unified_inbox import CARD_TYPES, InboxCard + + +# ── Service catalog ────────────────────────────────────────── +def test_service_catalog_returns_all_services(): + out = list_services() + assert out["total"] == len(SELLABLE_SERVICES) >= 12 + + +def test_service_catalog_includes_critical_services(): + out = list_services() + keys = {s["key"] for s in out["services"]} + for required in ( + "growth_operator_subscription", "channel_setup_service", + "lead_intelligence_service", "partnership_sprint", + "ai_visibility_aeo_sprint", "customer_success_operator", + ): + assert required in keys + + +# ── Channel registry ───────────────────────────────────────── +def test_channels_include_core_channels(): + keys = {c.key for c in ALL_CHANNELS} + for required in ( + "whatsapp", "gmail", "google_calendar", "moyasar", + "linkedin_lead_forms", "x_api", "instagram_graph", + "google_business_profile", "google_sheets", "crm", "website_forms", + ): + assert required in keys + + +def test_channels_summary_aggregates(): + s = channels_summary() + assert s["total"] == len(ALL_CHANNELS) + assert "by_beta_status" in s and "by_risk_level" in s + + +def test_get_channel_unknown(): + assert get_channel("bogus_channel") is None + + +def test_whatsapp_blocks_cold_send(): + """Channel registry asserts cold send is blocked.""" + wa = get_channel("whatsapp") + assert wa is not None + assert "cold_send_without_consent" in wa.blocked_actions + + +# ── Event bus ──────────────────────────────────────────────── +def test_event_types_include_payment_lifecycle(): + for et in ("payment.initiated", "payment.paid", "payment.failed"): + assert et in EVENT_TYPES + + +def test_make_event_validates(): + with pytest.raises(ValueError): + make_event(event_type="totally.fake", channel="whatsapp", customer_id="c") + + +def test_make_event_round_trip(): + e = make_event( + event_type="lead.form_submitted", channel="website_forms", + customer_id="c", payload={"company": "X"}, + ) + d = e.to_dict() + assert d["event_type"] == "lead.form_submitted" + assert d["payload"]["company"] == "X" + + +# ── Action policy ──────────────────────────────────────────── +def test_policy_blocks_cold_whatsapp(): + d = evaluate_action( + action="send_whatsapp", + context={"source": "cold_list", "consent": False}, + ) + assert d.decision == "blocked" + + +def test_policy_blocks_payment_without_confirmation(): + d = evaluate_action( + action="charge_payment", + context={"user_confirmed": False}, + ) + assert d.decision == "blocked" + + +def test_policy_blocks_secrets_in_payload(): + d = evaluate_action( + action="create_draft", + context={"payload": {"api_key": "ghp_xxx"}}, + ) + assert d.decision == "blocked" + + +def test_policy_external_send_needs_approval(): + d = evaluate_action( + action="send_email", + context={"approval_status": "pending"}, + ) + assert d.decision == "approval_required" + + +def test_policy_calendar_insert_needs_approval(): + d = evaluate_action( + action="calendar_insert_event", + context={"approval_status": "pending"}, + ) + assert d.decision == "approval_required" + + +def test_policy_high_value_deal_review(): + d = evaluate_action( + action="send_whatsapp", + context={ + "deal_value_sar": 250_000, "approval_status": "approved", + "source": "existing_customer", + }, + ) + assert d.decision == "approval_required" + + +def test_policy_safe_internal_action_allowed(): + d = evaluate_action(action="read_data", context={}) + assert d.decision == "allow" + + +# ── Tool gateway ───────────────────────────────────────────── +def test_gateway_blocks_unsupported_tool(): + r = invoke_tool(tool="bogus.action") + assert r.status == "unsupported" + + +def test_gateway_blocks_cold_whatsapp(): + r = invoke_tool( + tool="whatsapp.send_message", + context={"source": "cold_list", "consent": False, "approval_status": "pending"}, + ) + assert r.status == "blocked" + + +def test_gateway_external_send_default_draft_only(): + """No live env flag → drafts even when policy allows.""" + import os + os.environ.pop("WHATSAPP_ALLOW_LIVE_SEND", None) + r = invoke_tool( + tool="whatsapp.send_message", + context={ + "source": "existing_customer", "consent": True, + "approval_status": "approved", + }, + ) + # Either draft_created (no flag) or approval_required (defensive) + assert r.status in ("draft_created", "approval_required") + + +def test_gateway_internal_action_passes(): + r = invoke_tool(tool="gmail.read_thread", context={}) + assert r.status in ("draft_created", "approval_required") + + +def test_gateway_payment_charge_needs_confirmation(): + r = invoke_tool( + tool="moyasar.refund", + context={"user_confirmed": False, "approval_status": "approved"}, + ) + assert r.status == "blocked" + + +# ── Identity resolution ────────────────────────────────────── +def test_identity_resolves_multi_signal(): + out = resolve_identity(signals=[ + {"phone": "+966500000001", "company": "X", "source": "wa"}, + {"email": "x@example.sa", "company": "X", "source": "gmail"}, + {"crm_id": "crm_1", "company": "X", "source": "crm"}, + ]) + assert out.primary_phone == "+966500000001" + assert out.primary_email == "x@example.sa" + assert out.crm_id == "crm_1" + assert out.confidence > 0 + assert "wa" in out.sources + + +def test_identity_empty_signals(): + out = resolve_identity(signals=[]) + assert out.confidence == 0 + + +# ── Unified inbox ──────────────────────────────────────────── +def test_inbox_card_validates_button_count(): + with pytest.raises(ValueError): + InboxCard( + card_id="c", type="opportunity", channel="whatsapp", + title_ar="x", summary_ar="x", why_it_matters_ar="x", + recommended_action_ar="x", risk_level="low", + buttons_ar=("a", "b", "c", "d"), # 4 → invalid + ) + + +def test_inbox_card_validates_card_type(): + with pytest.raises(ValueError): + InboxCard( + card_id="c", type="bogus_type", channel="x", + title_ar="x", summary_ar="x", why_it_matters_ar="x", + recommended_action_ar="x", risk_level="low", + ) + + +def test_build_card_from_event_payment_failed(): + e = make_event( + event_type="payment.failed", channel="moyasar", customer_id="c", + payload={"customer_id": "c1", "amount_sar": 2999}, + ) + card = build_card_from_event(e) + assert card is not None + assert card.type == "payment" + assert len(card.buttons_ar) <= 3 + + +def test_build_card_from_event_review_low_rating_high_risk(): + e = make_event( + event_type="review.created", channel="google_business_profile", + customer_id="c", payload={"rating": 1, "text": "bad"}, + ) + card = build_card_from_event(e) + assert card is not None + assert card.risk_level == "high" + + +def test_demo_feed_arabic_and_buttons_capped(): + feed = build_demo_feed() + assert feed["feed_size"] >= 5 + for card in feed["cards"]: + assert len(card["buttons_ar"]) <= 3 + # Has Arabic content somewhere + text = card["title_ar"] + card["summary_ar"] + assert any("؀" <= ch <= "ۿ" for ch in text) + + +def test_card_types_cover_inbox_cases(): + assert {"opportunity", "email_lead", "whatsapp_reply", "payment", + "meeting_prep", "review_response", "partner_suggestion"}.issubset(set(CARD_TYPES)) + + +# ── Action ledger ──────────────────────────────────────────── +def test_action_ledger_append_and_summary(): + led = ActionLedger() + led.append( + customer_id="c1", action_type="send_whatsapp", + channel="whatsapp", stage="requested", + ) + led.append( + customer_id="c1", action_type="send_whatsapp", + channel="whatsapp", stage="approved", + ) + s = led.summary("c1") + assert s["total"] == 2 + assert s["by_stage"]["requested"] == 1 + assert s["by_stage"]["approved"] == 1 + + +def test_action_ledger_unknown_stage_raises(): + led = ActionLedger() + with pytest.raises(ValueError): + led.append(customer_id="c1", action_type="x", channel="y", stage="bogus") + + +# ── Platform proof ledger ──────────────────────────────────── +def test_platform_proof_demo_structure(): + p = build_demo_platform_proof() + d = p.to_dict() + assert "totals" in d and "by_channel" in d + assert d["totals"]["leads_created"] > 0 + assert d["totals"]["risks_blocked"] > 0 + # Cross-channel coverage + assert "whatsapp" in d["by_channel"] or "gmail" in d["by_channel"]