mirror of
https://github.com/x1xhlol/system-prompts-and-models-of-ai-tools.git
synced 2026-06-17 23:09:35 +00:00
feat(platform+intelligence): Growth Control Tower + Growth Neural Network — 20 modules + 25 endpoints + 60 tests
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) <noreply@anthropic.com>
This commit is contained in:
parent
8942c6e84c
commit
4e969131c7
@ -30,9 +30,11 @@ from api.routers import (
|
|||||||
growth_operator,
|
growth_operator,
|
||||||
health,
|
health,
|
||||||
innovation,
|
innovation,
|
||||||
|
intelligence_layer,
|
||||||
leads,
|
leads,
|
||||||
outreach,
|
outreach,
|
||||||
personal_operator,
|
personal_operator,
|
||||||
|
platform_services,
|
||||||
pricing,
|
pricing,
|
||||||
prospect,
|
prospect,
|
||||||
public,
|
public,
|
||||||
@ -148,6 +150,8 @@ def create_app() -> FastAPI:
|
|||||||
app.include_router(business.router)
|
app.include_router(business.router)
|
||||||
app.include_router(personal_operator.router)
|
app.include_router(personal_operator.router)
|
||||||
app.include_router(growth_operator.router)
|
app.include_router(growth_operator.router)
|
||||||
|
app.include_router(platform_services.router)
|
||||||
|
app.include_router(intelligence_layer.router)
|
||||||
app.include_router(public.router)
|
app.include_router(public.router)
|
||||||
app.include_router(admin.router)
|
app.include_router(admin.router)
|
||||||
|
|
||||||
|
|||||||
140
dealix/api/routers/intelligence_layer.py
Normal file
140
dealix/api/routers/intelligence_layer.py
Normal file
@ -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()}
|
||||||
203
dealix/api/routers/platform_services.py
Normal file
203
dealix/api/routers/platform_services.py
Normal file
@ -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()
|
||||||
@ -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",
|
||||||
|
]
|
||||||
@ -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],
|
||||||
|
}
|
||||||
@ -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,
|
||||||
|
},
|
||||||
|
}
|
||||||
@ -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.",
|
||||||
|
}
|
||||||
@ -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,
|
||||||
|
}
|
||||||
@ -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(),
|
||||||
|
}
|
||||||
@ -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)),
|
||||||
|
)
|
||||||
@ -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 العميل + القنوات المربوطة.",
|
||||||
|
}
|
||||||
@ -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,
|
||||||
|
}
|
||||||
@ -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"},
|
||||||
|
],
|
||||||
|
)
|
||||||
102
dealix/auto_client_acquisition/intelligence_layer/trust_score.py
Normal file
102
dealix/auto_client_acquisition/intelligence_layer/trust_score.py
Normal file
@ -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,
|
||||||
|
}
|
||||||
74
dealix/auto_client_acquisition/platform_services/__init__.py
Normal file
74
dealix/auto_client_acquisition/platform_services/__init__.py
Normal file
@ -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",
|
||||||
|
]
|
||||||
@ -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,
|
||||||
|
}
|
||||||
@ -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)
|
||||||
@ -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,
|
||||||
|
}
|
||||||
110
dealix/auto_client_acquisition/platform_services/event_bus.py
Normal file
110
dealix/auto_client_acquisition/platform_services/event_bus.py
Normal file
@ -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,
|
||||||
|
)
|
||||||
@ -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
|
||||||
|
)
|
||||||
@ -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},
|
||||||
|
},
|
||||||
|
)
|
||||||
@ -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],
|
||||||
|
}
|
||||||
193
dealix/auto_client_acquisition/platform_services/tool_gateway.py
Normal file
193
dealix/auto_client_acquisition/platform_services/tool_gateway.py
Normal file
@ -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",
|
||||||
|
}
|
||||||
@ -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.",
|
||||||
|
}
|
||||||
@ -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` عند التوسع.
|
وعد منتجي مركزي: من مدخلات شركة/قطاع/مدينة/عرض/هدف إلى قائمة ١٠ فرص مع 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).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**الخلاصة:** المنتج **قوي كأساس سوقي وتقني**؛ الإطلاق العام يحتاج تشغيلاً وامتثالاً وتجربة عميل مغلقة أولاً.
|
**الخلاصة:** المنتج **قوي كأساس سوقي وتقني**؛ الإطلاق العام يحتاج تشغيلاً وامتثالاً وتجربة عميل مغلقة أولاً.
|
||||||
|
|||||||
269
dealix/docs/INTELLIGENCE_LAYER_STRATEGY.md
Normal file
269
dealix/docs/INTELLIGENCE_LAYER_STRATEGY.md
Normal file
@ -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 الحقيقي.
|
||||||
196
dealix/docs/PLATFORM_SERVICES_STRATEGY.md
Normal file
196
dealix/docs/PLATFORM_SERVICES_STRATEGY.md
Normal file
@ -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` على بيانات إنتاج مع تجربة عميل واحد.
|
||||||
281
dealix/tests/unit/test_intelligence_layer.py
Normal file
281
dealix/tests/unit/test_intelligence_layer.py
Normal file
@ -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
|
||||||
298
dealix/tests/unit/test_platform_services.py
Normal file
298
dealix/tests/unit/test_platform_services.py
Normal file
@ -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"]
|
||||||
Loading…
Reference in New Issue
Block a user