system-prompts-and-models-o.../dealix/auto_client_acquisition/platform_services/action_policy.py
Dealix Builder 4e969131c7 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>
2026-05-01 16:05:12 +03:00

174 lines
6.3 KiB
Python

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