feat(dealix): ship revenue discovery launch hardening

Add revenue discovery APIs/services, launch verification gates, CI quality checks, and frontend E2E/docs updates to prepare the branch for production go-live.

Made-with: Cursor
This commit is contained in:
Sami Assiri 2026-04-15 17:51:23 +03:00
parent 07557c4be9
commit d8bb836614
35 changed files with 2575 additions and 244 deletions

View File

@ -30,6 +30,18 @@ jobs:
DATABASE_URL: sqlite+aiosqlite:///./ci_dealix.db
DEALIX_INTERNAL_API_TOKEN: ""
run: python -m pytest tests -q --tb=line
- name: OpenAPI vs frontend API paths
working-directory: salesflow-saas
env:
DATABASE_URL: sqlite+aiosqlite:///./openapi_verify.db
DEALIX_INTERNAL_API_TOKEN: ""
run: python scripts/verify_frontend_openapi_paths.py
- name: Go-live gate (in-process CLI)
working-directory: salesflow-saas
env:
DATABASE_URL: sqlite+aiosqlite:///./go_live_gate_ci.db
DEALIX_INTERNAL_API_TOKEN: ""
run: python scripts/check_go_live_gate.py
frontend:
runs-on: ubuntu-latest

View File

@ -75,6 +75,17 @@ HUBSPOT_API_KEY=
UNIFONIC_APP_SID=
RAPIDAPI_KEY=
# ---------- استكشاف إيرادات — بحث ويب مرخّص (Tavily) ----------
# TAVILY_API_KEY=
# DEALIX_ALLOW_LICENSED_SEARCH=true
# DEALIX_TAVILY_TENANT_ALLOWLIST=
# DEALIX_INTEL_RATE_LIMIT=60
# DEALIX_INTEL_RATE_WINDOW_SEC=3600
# DEALIX_INTEL_CACHE_TTL_SEC=120
# DEALIX_ENRICH_IDEMPOTENT_DAILY=true
# DEALIX_KNOWLEDGE_RAG_ENRICH=true
# DEALIX_ASYNC_ENRICH_JOBS=true
# ---------- حلقة مستقلة ----------
SELF_IMPROVEMENT_INTERVAL_SECONDS=900

View File

@ -2,10 +2,22 @@
Dealix Master API Full Power Endpoints
أقوى وأشمل API في مجال المبيعات السعودية
"""
from fastapi import APIRouter, BackgroundTasks, Query
from pydantic import BaseModel
from typing import Optional, List
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, Request
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from typing import Any, Optional, List
import hashlib
import json
import logging
import os
from pathlib import Path
from app.api.deps import get_optional_user
from app.database import async_session, get_db
from app.models.user import User
from app.schemas.dealix_master import EnrichExplorationBody
logger = logging.getLogger("dealix.api.master")
router = APIRouter(prefix="/dealix", tags=["🏰 Dealix Master API"])
@ -16,15 +28,215 @@ def _key():
# ── Lead Generation ───────────────────────────────────────────
@router.post("/generate-leads")
async def generate_leads(
request: Request,
sector: str = Query(default="تقنية المعلومات", description="القطاع"),
city: str = Query(default="الرياض", description="المدينة"),
count: int = Query(default=10, le=50)
count: int = Query(default=10, le=50),
user: Optional[User] = Depends(get_optional_user),
):
"""🎯 توليد leads مؤهلة تلقائياً لأي قطاع وأي مدينة سعودية."""
from app.services.intelligence_plane_control import audit_ai_decision, check_rate_limit, cache_get, cache_set
from app.services.lead_generation import GoogleMapsLeadScraper
from app.services.revenue_discovery_service import attach_generation_provenance
client = request.client.host if request.client else "unknown"
xf = request.headers.get("x-forwarded-for")
tenant_id = str(user.tenant_id) if user else None
ok, reason = check_rate_limit(client_ip=client, x_forwarded_for=xf, tenant_id=tenant_id)
if not ok:
raise HTTPException(status_code=429, detail=reason)
cache_key = f"genleads:{sector}:{city}:{count}"
cached = cache_get(cache_key)
if cached is not None:
return cached
scraper = GoogleMapsLeadScraper()
leads = await scraper.generate_leads_for_sector(sector, city, count)
return {"sector": sector, "city": city, "count": len(leads), "leads": leads}
leads, sector_insights = await scraper.generate_leads_for_sector(sector, city, count)
manifest = attach_generation_provenance(leads, sector, city)
out = {
"sector": sector,
"city": city,
"count": len(leads),
"leads": leads,
"sector_insights": sector_insights,
"discovery_manifest": manifest,
}
cache_set(cache_key, out)
audit_ai_decision(
operation="generate_leads",
tenant_id=tenant_id,
user_id=str(user.id) if user else None,
model_id="llama-3.3-70b-versatile",
extra={"count": len(leads), "sector": sector},
)
return out
async def _run_enrich_job(
job_id: str,
body_dict: dict[str, Any],
tenant_id: str | None,
tid_for_tavily: str | None,
) -> None:
from app.services.dealix_enrichment_runner import compute_enrich_exploration
from app.services.intel_async_jobs import mark_done, mark_error, mark_running
from app.services.intelligence_plane_control import audit_ai_decision
mark_running(job_id)
try:
body = EnrichExplorationBody(**body_dict)
async with async_session() as db:
out = await compute_enrich_exploration(
db, body, tenant_id=tenant_id, tid_for_tavily=tid_for_tavily
)
mark_done(job_id, out)
audit_ai_decision(
operation="enrich_exploration_async",
tenant_id=tenant_id,
user_id=None,
model_id=out.get("model_id"),
extra={"job_id": job_id, "playbook": out.get("vertical_playbook_id")},
)
except Exception:
logger.exception("enrich job failed job_id=%s", job_id)
mark_error(job_id, "enrichment_failed")
@router.post("/enrich-exploration")
async def enrich_exploration(
request: Request,
body: EnrichExplorationBody,
db: AsyncSession = Depends(get_db),
user: Optional[User] = Depends(get_optional_user),
):
"""Structured enrichment + provenance + vertical playbook linkage (optional Tavily)."""
from app.services.dealix_enrichment_runner import compute_enrich_exploration
from app.services.intelligence_plane_control import audit_ai_decision, check_rate_limit
client = request.client.host if request.client else "unknown"
xf = request.headers.get("x-forwarded-for")
tenant_id = str(user.tenant_id) if user else None
ok, reason = check_rate_limit(client_ip=client, x_forwarded_for=xf, tenant_id=tenant_id)
if not ok:
raise HTTPException(status_code=429, detail=reason)
tid_for_tavily = tenant_id or request.headers.get("x-tenant-id")
out = await compute_enrich_exploration(db, body, tenant_id=tenant_id, tid_for_tavily=tid_for_tavily)
audit_ai_decision(
operation="enrich_exploration",
tenant_id=tenant_id,
user_id=str(user.id) if user else None,
model_id=out.get("model_id"),
extra={"sector": body.sector, "playbook": out.get("vertical_playbook_id")},
)
return out
@router.post("/enrich-exploration/async")
async def enrich_exploration_async(
request: Request,
background_tasks: BackgroundTasks,
body: EnrichExplorationBody,
user: Optional[User] = Depends(get_optional_user),
):
"""Queue enrichment after HTTP response; poll GET .../jobs/{job_id}."""
if os.getenv("DEALIX_ASYNC_ENRICH_JOBS", "true").lower() in ("0", "false", "no"):
raise HTTPException(status_code=404, detail="async enrich jobs disabled")
from app.services.intel_async_jobs import create_job
from app.services.intelligence_plane_control import check_rate_limit
client = request.client.host if request.client else "unknown"
xf = request.headers.get("x-forwarded-for")
tenant_id = str(user.tenant_id) if user else None
ok, reason = check_rate_limit(client_ip=client, x_forwarded_for=xf, tenant_id=tenant_id)
if not ok:
raise HTTPException(status_code=429, detail=reason)
tid_for_tavily = tenant_id or request.headers.get("x-tenant-id")
job_id = create_job()
background_tasks.add_task(
_run_enrich_job,
job_id,
body.model_dump(),
tenant_id,
tid_for_tavily,
)
return {
"job_id": job_id,
"status": "pending",
"poll": f"/api/v1/dealix/enrich-exploration/jobs/{job_id}",
}
@router.get("/enrich-exploration/jobs/{job_id}")
async def enrich_exploration_job_status(job_id: str):
from app.services.intel_async_jobs import get_job
row = get_job(job_id)
if not row:
raise HTTPException(status_code=404, detail="job not found")
return {"job_id": job_id, **row}
@router.get("/intelligence-flags")
async def intelligence_flags(request: Request, user: Optional[User] = Depends(get_optional_user)):
"""Feature flags + intel config for workspace (no secrets)."""
from app.services.intelligence_plane_control import intelligence_feature_snapshot
tid = str(user.tenant_id) if user else request.headers.get("x-tenant-id")
return intelligence_feature_snapshot(tenant_id=tid)
_GOLDEN_PATH = Path(__file__).resolve().parents[2] / "data" / "ai_eval_golden.json"
@router.get("/ai-eval/golden")
async def ai_eval_golden():
"""Golden / rubric JSON for regression & human-in-the-loop QA (no execution)."""
if not _GOLDEN_PATH.is_file():
return {"version": 0, "note": "ai_eval_golden.json missing"}
return json.loads(_GOLDEN_PATH.read_text(encoding="utf-8"))
class ChannelDraftRequest(BaseModel):
company_name: str
partnership_angle_ar: str = ""
contact_name: str = "فريق العمليات"
@router.post("/channel-drafts")
async def governed_channel_drafts(body: ChannelDraftRequest):
"""
مسودات قنوات للمراجعة البشرية واتساب/إيميل قابلة للتعديل؛ لينكدإن: موافقة بشرية إلزامية.
"""
cn = body.company_name.strip() or "الفريق"
angle = (body.partnership_angle_ar or "استكشاف فرص تعاون في مجال عملكم").strip()
return {
"whatsapp_draft_ar": (
f"السلام عليكم، معكم {body.contact_name} من Dealix. "
f"نودّ استكشاف تعاون مع {cn} بخصوص: {angle}. هل يُناسبكم موعد قصير الأسبوع القادم؟"
),
"email_subject_ar": f"Dealix — استكشاف شراكة محتملة مع {cn}",
"email_body_ar": (
f"السلام عليكم،\n\nنتواصل من Dealix لاستكشاف {angle} مع {cn}. "
f"نرحّب بمشاركة المسؤول المناسب لديكم.\n\nمع الشكر،\n{body.contact_name}"
),
"linkedin": {
"human_in_loop_required": True,
"policy_note_ar": (
"لا يُرسل هذا النص تلقائياً عبر LinkedIn — يتطلب موافقة بشرية واستخدام واجهات رسمية أو استيراد يدوي وفق سياسة المنصة."
),
"draft_ar": (
f"تحية طيبة، أتابع عمل {cn} وأودّ ربط نقاش مختصر حول {angle}. "
f"هل يمكن توجيهي للمسؤول المناسب؟"
),
},
"governance": {
"pdpl_note_ar": "تأكد من وجود أساس قانوني للتواصل والموافقة حيث تنطبق PDPL.",
"approval_recommended": True,
},
}
@router.post("/daily-leads")

View File

@ -0,0 +1,16 @@
{
"version": 1,
"channel_drafts": {
"required_keys": ["whatsapp_draft_ar", "email_subject_ar", "email_body_ar", "linkedin", "governance"],
"linkedin": {
"required_keys": ["human_in_loop_required", "policy_note_ar", "draft_ar"]
}
},
"enrich_exploration": {
"required_top_keys": ["provenance", "rag_playbook_refs"]
},
"human_review": {
"cadence_ar": "أسبوعيًا: مراجعة عيّنة من مخرجات الإثراء مقابل provenance ومسودات القنوات.",
"owner_hint_ar": "RevOps + امتثال — سجل في سجلات ai_audit"
}
}

View File

@ -47,8 +47,16 @@ def _exempt_path(path: str) -> bool:
"/api/v1/intelligence/run-pipeline",
"/api/v1/dealix/generate-leads",
"/api/v1/dealix/full-power",
"/api/v1/dealix/enrich-exploration",
"/api/v1/dealix/enrich-exploration/async",
"/api/v1/dealix/channel-drafts",
"/api/v1/dealix/intelligence-flags",
):
return True
if path.startswith("/api/v1/dealix/enrich-exploration/jobs/"):
return True
if path.startswith("/api/v1/dealix/ai-eval/"):
return True
return False

View File

@ -0,0 +1,15 @@
"""Request bodies for Dealix Master API (shared with background runners)."""
from __future__ import annotations
from typing import Any
from pydantic import BaseModel, Field
class EnrichExplorationBody(BaseModel):
sector: str = Field(default="تقنية المعلومات")
city: str = Field(default="الرياض")
lead: dict[str, Any] = Field(default_factory=dict)
icp_notes_ar: str = ""
icp_notes_en: str = ""

View File

@ -0,0 +1,61 @@
"""Structured outputs for revenue discovery / lead exploration with provenance."""
from __future__ import annotations
from typing import Any, Literal
from pydantic import BaseModel, Field
class ProvenanceEntry(BaseModel):
"""Source attribution for a field or block (audit-friendly)."""
field_path: str
source: Literal[
"user_input",
"llm_groq",
"vertical_playbook_static",
"licensed_web_search",
"knowledge_rag",
"derived",
"unavailable",
]
detail: str = Field(default="", description="Provider name, model id, or note")
class MarketSignalItem(BaseModel):
title: str
summary: str
implication_ar: str = ""
class ICPBuyingCommitteeHint(BaseModel):
role_ar: str
role_en: str = ""
rationale_ar: str = ""
class ExplorationEnrichment(BaseModel):
"""Enrichment block stored under Lead.extra_metadata['revenue_discovery'] when persisted."""
vertical_playbook_id: str | None = None
playbook_label_ar: str | None = None
icp_summary_ar: str = ""
icp_summary_en: str = ""
market_signals: list[MarketSignalItem] = Field(default_factory=list)
buying_committee_hints: list[ICPBuyingCommitteeHint] = Field(default_factory=list)
partnership_angle_ar: str = ""
rag_playbook_refs: list[str] = Field(
default_factory=list,
description="Static playbook section keys or titles used (not full RAG chunks)",
)
provenance: list[ProvenanceEntry] = Field(default_factory=list)
model_id: str | None = None
feature_flags_used: dict[str, bool] = Field(default_factory=dict)
class LeadExplorationPersistMeta(BaseModel):
"""Shape recommended for merging into Lead.extra_metadata."""
revenue_discovery: dict[str, Any]
provenance_index: list[ProvenanceEntry] = Field(default_factory=list)

View File

@ -0,0 +1,82 @@
"""Shared enrichment pipeline for sync HTTP and background jobs."""
from __future__ import annotations
import hashlib
import json
import logging
import os
from datetime import datetime, timezone
from sqlalchemy.ext.asyncio import AsyncSession
from app.schemas.dealix_master import EnrichExplorationBody
from app.services.intelligence_plane_control import cache_get, cache_set, tenant_may_use_licensed_search
from app.services.revenue_discovery_service import build_enrichment_for_lead, resolve_playbook_id
logger = logging.getLogger("dealix.enrichment_runner")
async def compute_enrich_exploration(
db: AsyncSession,
body: EnrichExplorationBody,
*,
tenant_id: str | None,
tid_for_tavily: str | None,
) -> dict:
"""
Cache-aware enrichment (payload hash + daily idempotency). Returns model_dump dict.
"""
payload_hash = hashlib.sha256(
json.dumps(body.model_dump(), ensure_ascii=False, sort_keys=True).encode("utf-8")
).hexdigest()[:32]
cache_key = f"enrich:{payload_hash}"
cached = cache_get(cache_key)
if cached is not None:
return cached
company_key = str(body.lead.get("company_name") or "").strip().lower()[:160]
day = datetime.now(timezone.utc).strftime("%Y-%m-%d")
idem_key = (
f"enrich:idemp:{day}:{body.sector}:{body.city}:"
f"{hashlib.sha256(company_key.encode('utf-8')).hexdigest()[:24]}"
)
if os.getenv("DEALIX_ENRICH_IDEMPOTENT_DAILY", "true").lower() not in ("0", "false", "no"):
idem_hit = cache_get(idem_key)
if idem_hit is not None:
return idem_hit
allow_search = (os.getenv("TAVILY_API_KEY") or "").strip() != ""
if os.getenv("DEALIX_ALLOW_LICENSED_SEARCH", "true").lower() in ("0", "false", "no"):
allow_search = False
if tid_for_tavily and not tenant_may_use_licensed_search(tid_for_tavily):
allow_search = False
knowledge_chunks: list[dict] = []
if os.getenv("DEALIX_KNOWLEDGE_RAG_ENRICH", "true").lower() not in ("0", "false", "no"):
try:
from app.services.knowledge_service import KnowledgeService
ks = KnowledgeService(db)
q = f"{company_key} {body.sector} {body.city} sales partnership B2B"
pb = resolve_playbook_id(body.sector)
knowledge_chunks = await ks.search_sector_knowledge(q, sector=pb, limit=3)
if not knowledge_chunks:
knowledge_chunks = await ks.search_sector_knowledge(q, sector=None, limit=3)
except Exception as e:
logger.debug("knowledge RAG skipped: %s", e)
enrichment = await build_enrichment_for_lead(
sector=body.sector,
city=body.city,
lead=body.lead,
icp_notes_ar=body.icp_notes_ar,
icp_notes_en=body.icp_notes_en,
use_licensed_search=allow_search,
knowledge_chunks=knowledge_chunks or None,
)
out = enrichment.model_dump()
cache_set(cache_key, out)
if os.getenv("DEALIX_ENRICH_IDEMPOTENT_DAILY", "true").lower() not in ("0", "false", "no"):
cache_set(idem_key, out, ttl_sec=86400)
return out

View File

@ -0,0 +1,60 @@
"""In-process async enrichment jobs (HTTP poll). Optional path before full Celery."""
from __future__ import annotations
import logging
import threading
import time
import uuid
from typing import Any
_LOCK = threading.Lock()
_JOBS: dict[str, dict[str, Any]] = {}
_TTL_SEC = 3600
logger = logging.getLogger("dealix.intel_jobs")
def _prune() -> None:
t = time.time()
dead = [k for k, v in _JOBS.items() if t - v.get("created_at", t) > _TTL_SEC]
for k in dead:
del _JOBS[k]
def create_job() -> str:
job_id = uuid.uuid4().hex
with _LOCK:
_prune()
_JOBS[job_id] = {
"status": "pending",
"created_at": time.time(),
"result": None,
"error": None,
}
return job_id
def mark_running(job_id: str) -> None:
with _LOCK:
if job_id in _JOBS:
_JOBS[job_id]["status"] = "running"
def mark_done(job_id: str, result: dict[str, Any]) -> None:
with _LOCK:
if job_id in _JOBS:
_JOBS[job_id].update(status="done", result=result, error=None)
def mark_error(job_id: str, message: str) -> None:
with _LOCK:
if job_id in _JOBS:
_JOBS[job_id].update(status="error", error=message, result=None)
def get_job(job_id: str) -> dict[str, Any] | None:
with _LOCK:
_prune()
row = _JOBS.get(job_id)
return dict(row) if row else None

View File

@ -0,0 +1,141 @@
"""Lightweight cache, rate limits, and audit hooks for intelligence endpoints (no Celery required for MVP)."""
from __future__ import annotations
import json
import logging
import os
import threading
import time
from collections import defaultdict
from typing import Any
logger = logging.getLogger("dealix.intelligence_plane")
_LOCK = threading.Lock()
# client_key -> list of unix timestamps (sliding window)
_RATE_BUCKETS: dict[str, list[float]] = defaultdict(list)
# cache key -> (expires_at, payload)
_CACHE: dict[str, tuple[float, Any]] = {}
DEFAULT_WINDOW_SEC = 3600
DEFAULT_MAX_PER_WINDOW = 60
CACHE_TTL_SEC = 120
def _now() -> float:
return time.time()
def _client_key(forwarded: str | None, fallback: str) -> str:
if forwarded:
return forwarded.split(",")[0].strip() or fallback
return fallback
def check_rate_limit(
*,
client_ip: str,
x_forwarded_for: str | None,
tenant_id: str | None = None,
max_per_window: int | None = None,
window_sec: int | None = None,
) -> tuple[bool, str]:
"""
Returns (allowed, reason). Uses tenant_id when provided, else client IP.
"""
max_n = max_per_window or int(os.getenv("DEALIX_INTEL_RATE_LIMIT", str(DEFAULT_MAX_PER_WINDOW)))
win = float(window_sec or os.getenv("DEALIX_INTEL_RATE_WINDOW_SEC", str(DEFAULT_WINDOW_SEC)))
key = f"t:{tenant_id}" if tenant_id else f"ip:{_client_key(x_forwarded_for, client_ip)}"
t = _now()
with _LOCK:
bucket = _RATE_BUCKETS[key]
bucket[:] = [x for x in bucket if t - x < win]
if len(bucket) >= max_n:
return False, f"rate_limited:{key}"
bucket.append(t)
return True, "ok"
def cache_get(key: str) -> Any | None:
with _LOCK:
row = _CACHE.get(key)
if not row:
return None
exp, payload = row
if exp < _now():
del _CACHE[key]
return None
return payload
def cache_set(key: str, payload: Any, ttl_sec: int | None = None) -> None:
ttl = float(ttl_sec or os.getenv("DEALIX_INTEL_CACHE_TTL_SEC", str(CACHE_TTL_SEC)))
with _LOCK:
_CACHE[key] = (_now() + ttl, payload)
def audit_ai_decision(
*,
operation: str,
tenant_id: str | None,
model_id: str | None,
user_id: str | None = None,
extra: dict[str, Any] | None = None,
) -> None:
"""Structured log line for later SIEM / golden-set review (no PII in values)."""
payload = {
"op": operation,
"tenant_id": str(tenant_id) if tenant_id else None,
"user_id": str(user_id) if user_id else None,
"model_id": model_id,
**(extra or {}),
}
logger.info("ai_audit %s", json.dumps(payload, ensure_ascii=False))
def deep_enrich_enabled_for_tenant(tenant_id: str | None) -> bool:
"""Optional heavy enrichment; default off unless DEALIX_DEEP_ENRICH_DEFAULT or allow-list."""
if os.getenv("DEALIX_DEEP_ENRICH_DEFAULT", "").lower() in ("1", "true", "yes"):
return True
raw = os.getenv("DEALIX_DEEP_ENRICH_TENANTS", "")
if not tenant_id or not raw.strip():
return False
allow = {x.strip() for x in raw.split(",") if x.strip()}
return str(tenant_id) in allow
def intelligence_feature_snapshot(*, tenant_id: str | None) -> dict[str, Any]:
"""Effective flags for workspace / ops (no secrets). Prefer JWT tenant over spoofed headers."""
tavily = bool((os.getenv("TAVILY_API_KEY") or "").strip())
allow_search_env = os.getenv("DEALIX_ALLOW_LICENSED_SEARCH", "true").lower() not in ("0", "false", "no")
deep_default = os.getenv("DEALIX_DEEP_ENRICH_DEFAULT", "").lower() in ("1", "true", "yes")
deep_tenants = os.getenv("DEALIX_DEEP_ENRICH_TENANTS", "").strip()
deep_list = {x.strip() for x in deep_tenants.split(",") if x.strip()}
deep_for_tenant = deep_default or (bool(tenant_id) and str(tenant_id) in deep_list)
tavily_allow = tenant_may_use_licensed_search(tenant_id)
return {
"licensed_web_search_configured": tavily,
"licensed_web_search_allowed": tavily and allow_search_env and tavily_allow,
"deep_enrichment_enabled": deep_for_tenant,
"intel_rate_limit_per_window": int(os.getenv("DEALIX_INTEL_RATE_LIMIT", str(DEFAULT_MAX_PER_WINDOW))),
"intel_cache_ttl_sec": int(float(os.getenv("DEALIX_INTEL_CACHE_TTL_SEC", str(CACHE_TTL_SEC)))),
"enrich_idempotent_daily": os.getenv("DEALIX_ENRICH_IDEMPOTENT_DAILY", "true").lower()
not in ("0", "false", "no"),
"async_enrich_jobs_enabled": os.getenv("DEALIX_ASYNC_ENRICH_JOBS", "true").lower()
not in ("0", "false", "no"),
}
def tenant_may_use_licensed_search(tenant_id: str | None) -> bool:
"""
Optional allow-list for Tavily-class search. Empty env any tenant (or anonymous) may use search if keys exist.
"""
raw = os.getenv("DEALIX_TAVILY_TENANT_ALLOWLIST", "").strip()
if not raw:
return True
if not tenant_id:
return True
allow = {x.strip() for x in raw.split(",") if x.strip()}
return str(tenant_id) in allow

View File

@ -32,8 +32,10 @@ class GoogleMapsLeadScraper:
def __init__(self):
self.groq = AsyncGroq(api_key=os.getenv("GROQ_API_KEY", ""))
async def generate_leads_for_sector(self, sector: str, city: str, count: int = 10) -> list:
"""Generate qualified lead list for a sector in Saudi Arabia."""
async def generate_leads_for_sector(
self, sector: str, city: str, count: int = 10
) -> tuple[list, dict]:
"""Generate qualified lead list for a sector in Saudi Arabia. Returns (leads, sector_insights)."""
prompt = f"""أنت نظام جيل leads في السوق السعودي.
@ -75,11 +77,12 @@ class GoogleMapsLeadScraper:
)
data = json.loads(response.choices[0].message.content)
leads = data.get("leads", [])
sector_insights = data.get("sector_insights") if isinstance(data.get("sector_insights"), dict) else {}
for lead in leads:
lead["source"] = "ai_generated"
lead["generated_at"] = datetime.utcnow().isoformat()
lead["status"] = "new"
return leads
return leads, sector_insights
async def bulk_generate(self, sectors: list = None, cities: list = None) -> dict:
"""Generate leads across multiple sectors and cities."""
@ -94,7 +97,10 @@ class GoogleMapsLeadScraper:
results = await asyncio.gather(*tasks, return_exceptions=True)
for result in results:
if isinstance(result, list):
if isinstance(result, tuple) and len(result) == 2:
chunk, _insights = result
all_leads.extend(chunk)
elif isinstance(result, list):
all_leads.extend(result)
return {

View File

@ -0,0 +1,269 @@
"""Revenue discovery: vertical playbooks + optional licensed search + structured LLM enrichment."""
from __future__ import annotations
import asyncio
import json
import logging
import os
from typing import Any
import httpx
from groq import AsyncGroq
from app.schemas.revenue_discovery import (
ExplorationEnrichment,
ICPBuyingCommitteeHint,
MarketSignalItem,
ProvenanceEntry,
)
from app.services.dealix_os.vertical_playbooks import get_playbook
logger = logging.getLogger("dealix.revenue_discovery")
SECTOR_TO_PLAYBOOK: dict[str, str] = {
"تقنية المعلومات": "saas_b2b",
"العقارات": "real_estate",
"الصحة": "healthcare",
"التعليم": "professional_services",
"التجزئة": "professional_services",
"المقاولات": "professional_services",
"الاستشارات": "professional_services",
"التصنيع": "professional_services",
"اللوجستيات": "professional_services",
"المالية": "professional_services",
}
def resolve_playbook_id(sector_ar: str) -> str:
return SECTOR_TO_PLAYBOOK.get(sector_ar.strip(), "saas_b2b")
async def _tavily_snippets(query: str, max_results: int = 3) -> tuple[list[dict[str, str]], list[ProvenanceEntry]]:
key = (os.getenv("TAVILY_API_KEY") or "").strip()
if not key:
return [], [
ProvenanceEntry(
field_path="market_signals",
source="unavailable",
detail="TAVILY_API_KEY not set — licensed web search skipped",
)
]
last_err: Exception | None = None
data: dict = {}
for attempt in range(3):
try:
async with httpx.AsyncClient(timeout=22.0) as client:
r = await client.post(
"https://api.tavily.com/search",
json={
"api_key": key,
"query": query,
"search_depth": "basic",
"max_results": max_results,
"include_answer": False,
},
)
r.raise_for_status()
data = r.json()
break
except Exception as e:
last_err = e
logger.warning("tavily attempt %s failed: %s", attempt + 1, e)
if attempt < 2:
await asyncio.sleep(0.4 * (attempt + 1))
else:
return [], [
ProvenanceEntry(
field_path="market_signals",
source="licensed_web_search",
detail=f"Tavily error after retries: {type(last_err).__name__ if last_err else 'unknown'}",
)
]
results = data.get("results") or []
snippets: list[dict[str, str]] = []
for item in results[:max_results]:
title = (item.get("title") or "")[:200]
content = (item.get("content") or "")[:400]
url = item.get("url") or ""
if title or content:
snippets.append({"title": title, "url": url, "content": content})
prov = [
ProvenanceEntry(
field_path="market_signals",
source="licensed_web_search",
detail="Tavily search API",
)
]
return snippets, prov
async def build_enrichment_for_lead(
*,
sector: str,
city: str,
lead: dict[str, Any],
icp_notes_ar: str = "",
icp_notes_en: str = "",
use_licensed_search: bool = True,
groq_model: str = "llama-3.1-8b-instant",
knowledge_chunks: list[dict[str, Any]] | None = None,
) -> ExplorationEnrichment:
playbook_id = resolve_playbook_id(sector)
pb = get_playbook(playbook_id) or {}
pb_label = pb.get("label_ar") or playbook_id
prov: list[ProvenanceEntry] = [
ProvenanceEntry(
field_path="vertical_playbook_id",
source="vertical_playbook_static",
detail=f"vertical_playbooks.{playbook_id}",
),
]
if icp_notes_ar or icp_notes_en:
prov.append(
ProvenanceEntry(field_path="icp_notes", source="user_input", detail="workspace ICP context")
)
k_chunks = knowledge_chunks or []
if k_chunks:
prov.append(
ProvenanceEntry(
field_path="knowledge_rag",
source="knowledge_rag",
detail=f"SectorAsset semantic search ({len(k_chunks)} chunks)",
)
)
search_snippets: list[dict[str, str]] = []
if use_licensed_search:
q = f"{lead.get('company_name','')} {sector} {city} Saudi Arabia business news"
snippets, sprov = await _tavily_snippets(q, max_results=3)
search_snippets = snippets
prov.extend(sprov)
groq_key = os.getenv("GROQ_API_KEY", "")
if not groq_key:
refs = [f"playbook:{playbook_id}"] + [f"knowledge:{c.get('title', '')}" for c in k_chunks if c.get("title")]
return ExplorationEnrichment(
vertical_playbook_id=playbook_id,
playbook_label_ar=pb_label,
icp_summary_ar="تعذر تشغيل نموذج الإثراء (GROQ_API_KEY غير مضبوط).",
partnership_angle_ar="",
rag_playbook_refs=refs,
provenance=prov
+ [ProvenanceEntry(field_path="llm", source="unavailable", detail="GROQ_API_KEY missing")],
feature_flags_used={"knowledge_rag": bool(k_chunks), "licensed_search": use_licensed_search},
)
client = AsyncGroq(api_key=groq_key)
pb_full = dict(pb) if pb else {}
pb_full["id"] = playbook_id
pb_blob = json.dumps(pb_full, ensure_ascii=False)
if len(pb_blob) > 8000:
pb_blob = pb_blob[:8000] + "\n…[truncated playbook JSON]"
search_blob = json.dumps(search_snippets, ensure_ascii=False) if search_snippets else "[]"
rag_blob = json.dumps(
[{"title": c.get("title"), "excerpt": (c.get("content") or "")[:500]} for c in k_chunks],
ensure_ascii=False,
)
prompt = f"""أنت محلل إيرادات B2B للسوق السعودي. أنتج JSON فقط حسب المخطط الذهني التالي (بالعربية حيث ينطبق).
قطاع: {sector}
مدينة: {city}
شركة مستهدفة: {json.dumps(lead, ensure_ascii=False)}
ملاحظات ICP من المستخدم (عربي): {icp_notes_ar or ""}
ملاحظات ICP (إنجليزي اختياري): {icp_notes_en or ""}
مقتطفات بحث مرخّص (قد تكون فارغة): {search_blob}
مقتطفات معرفة داخلية (RAG من أصول القطاع، قد تكون فارغة): {rag_blob}
معلومات playbook قطاعي كاملة (ثابتة من النظام، قد تُقتطع): {pb_blob}
أعد JSON بالشكل:
{{
"icp_summary_ar": "ملخص قصير",
"icp_summary_en": "Short EN summary for export",
"market_signals": [{{"title":"...", "summary":"...", "implication_ar":"..."}}],
"buying_committee_hints": [{{"role_ar":"...", "role_en":"...", "rationale_ar":"..."}}],
"partnership_angle_ar": "زاوية شراكة واقعية"
}}
قواعد: لا تدّعي أخباراً غير موجودة في المقتطفات؛ إن لم تتوفر أخبار، اذكر عبارات عامة قطاعية فقط. استخدم مقتطفات المعرفة الداخلية فقط كسياق إضافي عند وجودها. لا تذكر أسعاراً."""
response = await client.chat.completions.create(
model=groq_model,
messages=[{"role": "user", "content": prompt}],
temperature=0.25,
max_tokens=1200,
response_format={"type": "json_object"},
)
raw = json.loads(response.choices[0].message.content)
signals = []
for s in raw.get("market_signals") or []:
if isinstance(s, dict) and s.get("title"):
signals.append(MarketSignalItem.model_validate(s))
hints = []
for h in raw.get("buying_committee_hints") or []:
if isinstance(h, dict) and h.get("role_ar"):
hints.append(ICPBuyingCommitteeHint.model_validate(h))
prov.append(
ProvenanceEntry(
field_path="structured_enrichment",
source="llm_groq",
detail=f"model={groq_model}",
)
)
refs = [f"playbook:{playbook_id}"] + [
f"knowledge:{c.get('title', '')}" for c in k_chunks if c.get("title")
]
return ExplorationEnrichment(
vertical_playbook_id=playbook_id,
playbook_label_ar=pb_label,
icp_summary_ar=raw.get("icp_summary_ar") or "",
icp_summary_en=raw.get("icp_summary_en") or "",
market_signals=signals,
buying_committee_hints=hints,
partnership_angle_ar=raw.get("partnership_angle_ar") or "",
rag_playbook_refs=refs,
provenance=prov,
model_id=groq_model,
feature_flags_used={"knowledge_rag": bool(k_chunks), "licensed_search": use_licensed_search},
)
def attach_generation_provenance(leads: list[dict], sector: str, city: str) -> dict[str, Any]:
"""Manifest for the bulk generate response (per-field sources)."""
playbook_id = resolve_playbook_id(sector)
return {
"sector": sector,
"city": city,
"playbook_id": playbook_id,
"lead_field_sources": {
"company_name": "llm_groq",
"pain_point": "llm_groq",
"dealix_solution": "llm_groq",
"vertical_context": "vertical_playbook_static",
},
"provenance": [
ProvenanceEntry(
field_path="leads[]",
source="llm_groq",
detail="GoogleMapsLeadScraper.generate_leads_for_sector",
).model_dump(),
ProvenanceEntry(
field_path="sector_context",
source="vertical_playbook_static",
detail=f"vertical_playbooks.{playbook_id}",
).model_dump(),
],
}
def merge_persist_metadata(enrichment: ExplorationEnrichment) -> dict[str, Any]:
"""For Lead.extra_metadata merge via API metadata field."""
d = enrichment.model_dump()
return {"revenue_discovery": d, "provenance_index": [p.model_dump() for p in enrichment.provenance]}

View File

@ -151,7 +151,11 @@ async def main() -> int:
results.append(await check("health", "GET", "/api/v1/health"))
results.append(await check("ready (DB)", "GET", "/api/v1/ready"))
results.append(await check("marketing hub", "GET", "/api/v1/marketing/hub"))
mh = await check("marketing hub", "GET", "/api/v1/marketing/hub")
if not mh[1]:
await asyncio.sleep(0.85)
mh = await check("marketing hub", "GET", "/api/v1/marketing/hub")
results.append(mh)
results.append(await check("strategy summary", "GET", "/api/v1/strategy/summary"))
results.append(await check("value proposition", "GET", "/api/v1/value-proposition/"))
results.append(await check("customer onboarding journey", "GET", "/api/v1/customer-onboarding/journey"))

View File

@ -0,0 +1,183 @@
#!/usr/bin/env python3
"""
Revenue discovery E2E probe against a running API.
Flow:
1) create discovery leads
2) enrich one lead (async + poll)
3) generate governed channel drafts
4) optional strategic-deals create/link (when JWT is provided)
Usage:
py backend/scripts/revenue_discovery_e2e_probe.py
py backend/scripts/revenue_discovery_e2e_probe.py --base http://127.0.0.1:8000
py backend/scripts/revenue_discovery_e2e_probe.py --jwt <token>
"""
from __future__ import annotations
import argparse
import asyncio
import os
from typing import Any
import httpx
def _headers(jwt: str | None) -> dict[str, str]:
if not jwt:
return {}
return {"Authorization": f"Bearer {jwt}"}
async def _request(
client: httpx.AsyncClient,
method: str,
path: str,
*,
expected: tuple[int, ...] = (200,),
**kwargs: Any,
) -> tuple[bool, Any]:
res = await client.request(method, path, **kwargs)
ok = res.status_code in expected
try:
body = res.json()
except Exception:
body = {"raw": res.text[:500]}
return ok, {"status": res.status_code, "body": body}
async def main() -> int:
parser = argparse.ArgumentParser(description="Dealix revenue-discovery E2E probe")
parser.add_argument("--base", default=os.environ.get("DEALIX_BASE_URL", "http://127.0.0.1:8000"))
parser.add_argument("--jwt", default=os.environ.get("DEALIX_JWT", ""))
parser.add_argument("--sector", default="SaaS B2B")
parser.add_argument("--city", default="Riyadh")
parser.add_argument("--poll-seconds", type=float, default=1.2)
parser.add_argument("--poll-max", type=int, default=6)
args = parser.parse_args()
base = args.base.rstrip("/") + "/api/v1"
jwt = args.jwt or None
async with httpx.AsyncClient(base_url=base, timeout=30.0) as client:
h = _headers(jwt)
print("1) generate-leads")
ok, out = await _request(
client,
"POST",
"/dealix/generate-leads",
headers=h,
json={"sector": args.sector, "city": args.city, "limit": 3},
)
if not ok:
print("FAILED generate-leads:", out)
return 1
leads = out["body"].get("leads") or []
if not leads:
print("FAILED generate-leads: no leads in response")
return 1
lead = leads[0]
company_name = str(lead.get("company_name") or "Discovery Company")
print("2) enrich-exploration/async")
ok, out = await _request(
client,
"POST",
"/dealix/enrich-exploration/async",
headers=h,
json={
"sector": args.sector,
"city": args.city,
"lead": {"company_name": company_name},
"icp_notes_ar": "E2E probe",
},
)
if not ok:
print("FAILED async enqueue:", out)
return 1
job_id = out["body"].get("job_id")
if not job_id:
print("FAILED async enqueue: missing job_id")
return 1
enrich = None
for _ in range(args.poll_max):
await asyncio.sleep(args.poll_seconds)
ok, poll = await _request(
client,
"GET",
f"/dealix/enrich-exploration/jobs/{job_id}",
headers=h,
)
if not ok:
print("FAILED job poll:", poll)
return 1
status = poll["body"].get("status")
if status == "done":
enrich = poll["body"].get("result") or {}
break
if status == "error":
print("FAILED enrichment job:", poll["body"])
return 1
if enrich is None:
print("FAILED enrichment job did not finish in time")
return 1
print("3) channel-drafts")
angle = "شراكة نمو وتسويق مشترك"
if isinstance(enrich, dict):
angle = str(enrich.get("partnership_opportunity_ar") or angle)
ok, out = await _request(
client,
"POST",
"/dealix/channel-drafts",
headers=h,
json={"company_name": company_name, "partnership_angle_ar": angle},
)
if not ok:
print("FAILED channel-drafts:", out)
return 1
linkedin = out["body"].get("linkedin") or {}
if not bool(linkedin.get("human_in_loop_required")):
print("FAILED governance: linkedin human_in_loop_required != true")
return 1
if jwt:
print("4) strategic-deals create + links (JWT mode)")
ok, out = await _request(
client,
"POST",
"/strategic-deals",
headers=h,
expected=(200, 201),
json={
"title": f"E2E Probe: {company_name}",
"deal_type": "co_marketing",
"counterparty_name": company_name,
"status": "draft",
},
)
if not ok:
print("FAILED strategic-deals create:", out)
return 1
deal_id = out["body"].get("id")
if deal_id:
_, patch_out = await _request(
client,
"PATCH",
f"/strategic-deals/{deal_id}/links",
headers=h,
expected=(200, 204, 422),
json={"lead_id": None},
)
print("strategic-deals links check:", patch_out["status"])
else:
print("4) strategic-deals step skipped (no JWT provided)")
print("Revenue discovery E2E probe: OK")
return 0
if __name__ == "__main__":
raise SystemExit(asyncio.run(main()))

View File

@ -0,0 +1,81 @@
"""Revenue discovery / Dealix master paths used by the workspace UI."""
import pytest
from httpx import ASGITransport, AsyncClient
from app.main import app
@pytest.mark.asyncio
async def test_channel_drafts_governed_linkedin():
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
r = await client.post(
"/api/v1/dealix/channel-drafts",
json={"company_name": "شركة اختبار", "partnership_angle_ar": "تكامل تقني"},
)
assert r.status_code == 200
data = r.json()
assert data["linkedin"]["human_in_loop_required"] is True
assert "policy_note_ar" in data["linkedin"]
assert "لا يُرسل هذا النص تلقائياً" in data["linkedin"]["policy_note_ar"]
assert data["governance"]["approval_recommended"] is True
assert "whatsapp_draft_ar" in data
@pytest.mark.asyncio
async def test_ai_eval_golden_loads():
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
r = await client.get("/api/v1/dealix/ai-eval/golden")
assert r.status_code == 200
data = r.json()
assert "channel_drafts" in data or data.get("version") == 0
@pytest.mark.asyncio
async def test_enrich_async_returns_job():
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
r = await client.post(
"/api/v1/dealix/enrich-exploration/async",
json={"sector": "الصحة", "city": "الرياض", "lead": {"company_name": "اختبار مهمة"}},
)
assert r.status_code == 200
data = r.json()
assert "job_id" in data
jid = data["job_id"]
r2 = await client.get(f"/api/v1/dealix/enrich-exploration/jobs/{jid}")
assert r2.status_code == 200
body = r2.json()
assert body.get("status") in ("pending", "running", "done", "error")
@pytest.mark.asyncio
async def test_intelligence_flags_public():
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
r = await client.get("/api/v1/dealix/intelligence-flags")
assert r.status_code == 200
data = r.json()
assert "licensed_web_search_allowed" in data
assert "enrich_idempotent_daily" in data
@pytest.mark.asyncio
async def test_enrich_exploration_returns_provenance_shape():
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
r = await client.post(
"/api/v1/dealix/enrich-exploration",
json={
"sector": "الصحة",
"city": "الرياض",
"lead": {"company_name": "مختبر تجريبي"},
"icp_notes_ar": "اختبار",
},
)
assert r.status_code == 200
body = r.json()
assert "provenance" in body
assert isinstance(body["provenance"], list)

View File

@ -331,3 +331,20 @@ Prefix: `/ai`. Model routing policy per task category (no API keys in response).
|--------|-------|-------------|
| GET | `/ai/routing` | Effective routing map for current tenant |
| PUT | `/ai/routing` | Update tenant `settings.llm_routing` (owner/manager) |
## Dealix Master API (demo / internal widgets)
Prefix: `/dealix`. Several routes are `[public]` when `DEALIX_INTERNAL_API_TOKEN` is unset (see `internal_api.py`). Responses may include `discovery_manifest` / `sector_insights` for provenance and workspace UI.
| Method | Route | Description |
|--------|-------|-------------|
| POST | `/dealix/generate-leads` | Sector/city lead list + `discovery_manifest`, optional cache + rate limit |
| POST | `/dealix/enrich-exploration` | Single-lead structured enrichment + vertical playbook linkage + optional Tavily |
| POST | `/dealix/channel-drafts` | Governed WhatsApp/Email/LinkedIn (human-in-loop) draft templates `[public]` |
| GET | `/dealix/intelligence-flags` | Effective intel feature flags for tenant/session (no secrets) `[public]` |
| POST | `/dealix/enrich-exploration/async` | Queue enrichment; poll `GET .../jobs/{job_id}` `[public]` |
| GET | `/dealix/enrich-exploration/jobs/{job_id}` | Job status: `pending` / `running` / `done` / `error` `[public]` |
| GET | `/dealix/ai-eval/golden` | Golden rubric JSON for QA / regression `[public]` |
| POST | `/dealix/full-power` | Research + pipeline bundle (existing) |
| POST | `/dealix/research-company` | Company deep research |
| POST | `/dealix/daily-leads` | Hub batch generation |

View File

@ -0,0 +1,47 @@
# تقييم جودة مخرجات الذكاء — Dealix
**الغرض:** إطار عمل خفيف للمراجعة البشرية واختبارات الانحدار دون كشف أسرار أو PII في السجلات.
---
## 1) سجل التدقيق (`ai_audit`)
- تُسجَّل العمليات الحساسة عبر `dealix.intelligence_plane` بصيغة JSON: `op`, `tenant_id`, `user_id` (عند توفر JWT), `model_id`, وبيانات إضافية غير حساسة.
- **لا** تُسجَّل نصوص المسودات الكاملة أو مفاتيح API.
---
## 2) مجموعة ذهبية (Golden / Rubric)
- الملف المرجعي: `backend/app/data/ai_eval_golden.json`
- نقطة القراءة: `GET /api/v1/dealix/ai-eval/golden`
- يحدد مفاتيحاً مطلوبة في استجابات مثل `channel-drafts` و`enrich-exploration` لاستخدامها في اختبارات CI أو مراجعات يدوية.
- بوابة فحص سريعة: `py -3 scripts/ai_quality_gate.py` (تفشل عند غياب الملف/صيغة غير صالحة/فشل endpoint).
---
## 3) مراجعة بشرية مقترحة
- **التكرار:** أسبوعيًا لعينة عشوائية من مخرجات الإثراء ومسودات القنوات.
- **التركيز:** تطابق `provenance` مع الادعاء؛ عدم وجود وعود أسعار علنية؛ التزام مسار LinkedIn (موافقة بشرية).
- **المالكون:** RevOps + الامتثال — يُفضَّل ربط السجلات بأداة SIEM لاحقًا.
**لوحة جودة تشغيلية (أسبوعية):**
- `draft_accept_rate`
- `crm_sync_success_rate`
- `time_to_qualified_meeting`
- `opportunity_utility_score` (تقييم داخلي من الفريق)
> هذه المؤشرات هي بوابة القرار قبل التوسع القطاعي أو تفعيل أتمتة أعمق.
---
## 4) مهام الإثراء غير المتزامنة
- `POST /api/v1/dealix/enrich-exploration/async` ثم `GET .../jobs/{job_id}` لتفادي انقطاع HTTP الطويل.
- يُعطّل عبر `DEALIX_ASYNC_ENRICH_JOBS=false` عند الحاجة.
---
*مرافق لـ `docs/LAUNCH_CHECKLIST.md` و`verify-launch.ps1`.*

View File

@ -0,0 +1,63 @@
# تنفيذ سوقي — Dealix (ذكاء الإيرادات والشراكات)
**الغرض:** دليل تشغيل داخل المستودع لشركاء التصميم، الـ pilot، والقطاعات العمودية — متسق مع عدم إظهار تسعير علني وعدم أتمتة قنوات تخالف شروط المنصات.
---
## 1) شركاء تصميم (٣–٥)
**معايير القبول**
- فريق مبيعات أو شراكات نشط (٥+ أشخاص) مع قرار سريع نسبياً.
- استعداد لتسجيل الموافقات على المسودات قبل الإرسال عبر واتساب/إيميل/لينكدإن.
- وجود CRM أو على الأقل عملية تصدير/استيراد (CSV) مقبولة.
**مدة الـ pilot المقترحة:** ٦–٨ أسابيع.
**ما يُقاس**
- تغطية الفرص المُولَّدة مقابل الفرص المؤهَّلة يدوياً.
- زمن «أول اجتماع مؤهل» بعد أول مسودة معتمدة.
- نسبة استخدام مسار الموافقة (عدد المسودات المرسلة بعد موافقة مقابل إجمالي المسودات).
---
## 2) قطاعات عمودية أولى (٢–٣)
يُفضَّل البدء بقطاعات لها playbooks جاهزة في `vertical_playbooks`:
- **العقارات** (`real_estate`) — قنوات واتساب/إيميل، حوكمة عروض عالية القيمة.
- **الرعاية الصحية** (`healthcare`) — موافقة بشرية افتراضية، حساسية بيانات.
- **SaaS B2B** (`saas_b2b`) — تكاملات وتوجيه نماذج عبر `/api/v1/ai/routing`.
---
## 3) تدريب العميل
- شرح «مساحة استكشاف الإيرادات» كمسار **استكشاف + حوكمة** وليس بوت إرسال تلقائي.
- **LinkedIn:** مسودات فقط + موافقة بشرية؛ APIs الرسمية أو استيراد CSV؛ لا استخراج/إرسال يخالف ToS.
- **PDPL:** مراجعة `docs/legal/` وتسجيل الغرض عند جمع بيانات أفراد.
- ربط النتائج بـ **الصفقات الاستراتيجية** عبر `lead_id` بعد حفظ العميل في CRM.
- **إثراء غير متزامن:** استخدام `POST /api/v1/dealix/enrich-exploration/async` عند بطء الشبكة أو طلبات متكررة؛ مراقبة `GET .../jobs/{id}` حتى الاكتمال.
---
## 4) التسعير
يُدار عبر المبيعات والعقود فقط؛ لا أرقام علنية في الموقع — انظر واجهة الهبوط وإعدادات الفوترة المعروضة للمؤسسات.
---
## 5) العرض السيادي (Sovereign Enterprise Offer)
- **استضافة داخل بيئة العميل (اختياري):** نقل كامل للمنصة داخل VPC/سيرفرات العميل عند الحاجة.
- **مفاتيح API مملوكة للعميل:** فصل مفاتيح المزودات لكل tenant/عميل مؤسسي.
- **نماذج محلية داخل السيرفر:** تفعيل routing يختار بين مزود سحابي/محلي حسب سياسة الامتثال والتكلفة.
- **حوكمة نشر:** لا انتقال للإنتاج قبل اجتياز:
- `py -3 scripts/release_hardening_gate.py`
- `py -3 scripts/ai_quality_gate.py`
- `.\verify-launch.ps1 -WithOpenApiGate`
---
*مراجع تقنية: `docs/API-MAP.md` (مسارات `/dealix/*``docs/INTEGRATION_MASTER_AR.md` (Tavily والحدود)، `docs/DEALIX_AI_EVAL_AR.md` (تقييم الجودة)، `docs/DEALIX_POST_LAUNCH_OPS_AR.md` (التشغيل المستمر).*

View File

@ -0,0 +1,57 @@
# تشغيل ما بعد التدشين — Dealix (Post-Launch Ops)
هدف هذه الوثيقة: تحويل الإطلاق إلى تشغيل مؤسسي مستمر مع جودة قابلة للقياس، امتثال واضح، واستقرار إنتاجي.
## 1) دورة أسبوعية ثابتة
- **Triage حوادث/أخطاء:** مراجعة حوادث الأسبوع (P0/P1/P2) وتحديد سبب جذري وإجراء وقائي.
- **جودة الذكاء:** تشغيل `py -3 scripts/ai_quality_gate.py` ومراجعة عينة بشرية لمخرجات الإثراء والمسودات.
- **تعديل موجّه:** تحسين `routing/prompts` بناءً على ملاحظات RevOps وليس الانطباع.
- **playbooks قطاعية:** تحديثات صغيرة متكررة للقطاعات الأعلى استخدامًا.
## 2) دورة شهرية
- **امتثال وحوكمة:** تدقيق سجلات `ai_audit` ومسارات الموافقة الحساسة.
- **تكلفة وكفاءة:** مراجعة استهلاك المزودات (API calls/tokens/cache hit-rate) وتحديث حدود المعدل.
- **الأثر التجاري:** مقارنة KPI الشهر الحالي مقابل baseline:
- time_to_qualified_meeting
- draft_accept_rate
- crm_sync_success_rate
- opportunity_utility_score
## 3) SLA وتشغيل الدعم
- **P0 (إيقاف مسار أساسي):** أول استجابة <= 15 دقيقة، الاستعادة <= 4 ساعات.
- **P1 (تأثير مرتفع بدون توقف كامل):** أول استجابة <= 1 ساعة، الاستعادة <= 24 ساعة.
- **P2 (تدهور غير حرج):** أول استجابة <= 1 يوم عمل.
- قناة تصعيد تشغيلية موحدة:
1) تنبيه آلي
2) مالك incident
3) تحديث دوري حتى الإغلاق
4) postmortem قصير خلال 48 ساعة
## 4) إدارة التغييرات (Change Management)
- كل release يمر عبر:
- `verify-launch.ps1 -WithOpenApiGate`
- `py -3 scripts/release_hardening_gate.py`
- `py -3 scripts/ai_quality_gate.py`
- منع أي تغيير على مسارات حساسة بدون تحديث `docs/API-MAP.md` و`docs/LAUNCH_CHECKLIST.md`.
- توثيق نسخة prompt/policy المستخدمة في الإنتاج لمنع regressions صامتة.
## 5) قنوات النجاح المؤسسي
- تقرير readiness أسبوعي للفريق التنفيذي.
- تقرير pilot impact شهري للعميل:
- عدد الفرص المؤهلة
- زمن الوصول لأول اجتماع
- نسبة المسودات المقبولة بعد الموافقة
- ملاحظات الامتثال
---
مراجع:
- `docs/DEALIX_AI_EVAL_AR.md`
- `docs/LAUNCH_CHECKLIST.md`
- `docs/DEALIX_GTM_EXECUTION_AR.md`

View File

@ -39,6 +39,24 @@
**اختياري (لا يمنع الإطلاق):** `HUBSPOT_API_KEY`, `UNIFONIC_APP_SID`, `RAPIDAPI_KEY`, `ENVIRONMENT=production` (يُنصح)، `API_URL` / `FRONTEND_URL` للإنتاج.
**استكشاف الإيرادات / بحث مرخّص (اختياري):**
| المتغير | الفئة | ملاحظات |
|---------|--------|---------|
| `TAVILY_API_KEY` | ذكاء | بحث ويب عبر [Tavily](https://tavily.com) لمسار `POST /api/v1/dealix/enrich-exploration` عند التفعيل |
| `DEALIX_ALLOW_LICENSED_SEARCH` | ذكاء | `false` يعطّل Tavily حتى لو المفتاح موجود |
| `DEALIX_TAVILY_TENANT_ALLOWLIST` | ذكاء | قائمة `tenant_id` مفصولة بفواصل؛ إن وُجدت، فقط هذه المستأجرين يستخدمون Tavily (ضبط التكلفة) |
| `DEALIX_INTEL_RATE_LIMIT` | تشغيل | حد أقصى لطلبات الذكاء لكل نافذة زمنية (افتراضي ٦٠/ساعة لكل IP) |
| `DEALIX_INTEL_RATE_WINDOW_SEC` | تشغيل | عرض النافذة بالثواني |
| `DEALIX_INTEL_CACHE_TTL_SEC` | تشغيل | TTL للتخزين المؤقت لنتائج التوليد/الإثراء |
| `DEALIX_DEEP_ENRICH_DEFAULT` | ذكاء | توسيع ثقيل اختياري لاحقاً |
| `DEALIX_DEEP_ENRICH_TENANTS` | ذكاء | قائمة مستأجرين مسموح لهم بمسارات إثراء عميق عند التفعيل |
| `NEXT_PUBLIC_SALES_CONTACT_URL` | واجهة | رابط موحّد لـ CTA «تحدث مع المبيعات» في الواجهة العامة |
| `DEALIX_KNOWLEDGE_RAG_ENRICH` | ذكاء | `false` يعطّل حقن مقتطفات `SectorAsset` في مسار الإثراء |
| `DEALIX_ENRICH_IDEMPOTENT_DAILY` | تشغيل | تخزين مؤقت يومي لكل (شركة+قطاع+مدينة) لتقليل تكرار استدعاءات الإثراء |
| `DEALIX_ASYNC_ENRICH_JOBS` | تشغيل | `false` يعطّل `POST /dealix/enrich-exploration/async` |
| `SERPER_API_KEY` / `BRAVE_API_KEY` | ذكاء | بدائل محتملة لـ Tavily — تتطلب تكوين كود مخصص قبل الاستخدام |
---
## 3. ويبهوكات (Webhooks)

View File

@ -4,6 +4,9 @@
- [ ] **اختبارات الباكند:** من `backend/` شغّل `python -m pytest tests -q` (مثل CI على Linux) أو على ويندوز إذا الأمر `python` غير موجود: `py -3 -m pytest tests -q`.
- [ ] **بوابة موحّدة (موصى به):** من جذر `salesflow-saas`: `.\verify-launch.ps1` — يشغّل pytest + مزامنة التسويق + lint + build.
- [ ] **نفس البوابة + OpenAPI + go-live CLI:** `.\verify-launch.ps1 -WithOpenApiGate` — يضيف `scripts/verify_frontend_openapi_paths.py` و`scripts/check_go_live_gate.py` (بدون تشغيل uvicorn).
- [ ] **Release hardening (عقود الوثائق/البيئة):** `py -3 scripts/release_hardening_gate.py`.
- [ ] **AI quality gate (golden + endpoint):** `py -3 scripts/ai_quality_gate.py`.
- [ ] `cd frontend && npm run lint && npm run build` (أو تُغطّى بواسطة `verify-launch.ps1`).
- [ ] من جذر `salesflow-saas`: `node scripts/sync-marketing-to-public.cjs` (يُشغَّل أيضاً تلقائياً قبل `npm run build`).
- [ ] **E2E (Playwright):** بعد `npm run build`، حرّر المنفذ **3000** ثم من `frontend/`: `CI=true npm run test:e2e`. إن ظهر «port already in use» أو timeout على `webServer`: من جذر `salesflow-saas` شغّل `.\scripts\kill-port-3000.ps1` ثم أعد المحاولة.
@ -11,6 +14,7 @@
- [ ] مراجعة [`docs/API-MAP.md`](API-MAP.md) مقابل OpenAPI الفعلي (`/docs` على الخادم) بعد أي إصدار يضيف مسارات جديدة.
- [ ] قراءة سريعة لـ [`docs/DEALIX_OS_PRODUCT_GUIDE_AR.md`](DEALIX_OS_PRODUCT_GUIDE_AR.md) للتأكد من توافق قصة المنتج مع الداشبورد.
- [ ] (موصى به) تشغيل سيناريو محاكاة الإطلاق في [`docs/LAUNCH_SIMULATION.md`](LAUNCH_SIMULATION.md) وتسجيل نتيجة `go-live-gate` لكل بيئة.
- [ ] (موصى به) فحص أتمتة المسار الكامل على API حي: من `backend/` شغّل `py -3 scripts/revenue_discovery_e2e_probe.py` (أضف `--jwt` لمسار strategic-deals الكامل).
## 2. الخادم (API)
@ -50,6 +54,7 @@
- [ ] مراقبة `/api/v1/health` و `/api/v1/ready`.
- [ ] إعادة فحص **`go-live-gate`** بعد أي تغيير على أسرار الطرف الثالث (Stripe، البريد، CRM، إلخ).
- [ ] اعتماد دورة تشغيل أسبوعية/شهرية كما في [`docs/DEALIX_POST_LAUNCH_OPS_AR.md`](DEALIX_POST_LAUNCH_OPS_AR.md).
## 6. أمان `DEALIX_INTERNAL_API_TOKEN` (إنتاج)
@ -61,4 +66,4 @@
---
*سكربت موحّد (PowerShell): `verify-launch.ps1 -HttpCheck -SoftReady` — مع `-BaseUrl` إن لزم.*
*سكربت موحّد (PowerShell): `verify-launch.ps1 مع OpenAPI + go-live CLI: `-WithOpenApiGate`؛ مع API حي: `-HttpCheck -SoftReady` — مع `-BaseUrl` إن لزم.*

View File

@ -11,9 +11,12 @@
## 2. البناء والتحقق
1. `.\verify-launch.ps1` (أو pytest + lint + build يدوياً كما في قائمة الإطلاق).
2. `py -3 scripts/verify_frontend_openapi_paths.py`
3. تشغيل API: `py -3 -m uvicorn app.main:app --host 127.0.0.1 --port 8000` من `backend/`.
1. `.\verify-launch.ps1 -WithOpenApiGate` (أو `.\verify-launch.ps1` ثم الخطوتين 23 يدوياً).
2. إن لم تستخدم `-WithOpenApiGate`: `py -3 scripts/verify_frontend_openapi_paths.py`
3. إن لم تستخدم `-WithOpenApiGate`: `py -3 scripts/check_go_live_gate.py`
4. `py -3 scripts/release_hardening_gate.py`
5. `py -3 scripts/ai_quality_gate.py`
4. تشغيل API: `py -3 -m uvicorn app.main:app --host 127.0.0.1 --port 8000` من `backend/`.
## 3. بوابة go-live
@ -32,3 +35,4 @@
- وثّق التاريخ، البيئة (staging)، ونسخة الـ commit.
- أي فشل: أضف بنداً في `LAUNCH_CHECKLIST` أو issue مع `blocked_reasons` المنسوخة من الـ API.
- نفّذ من `backend/`: `py -3 scripts/revenue_discovery_e2e_probe.py` (ومع JWT عند اختبار الربط الاستراتيجي).

View File

@ -2,3 +2,5 @@
# Must match the FastAPI base URL the browser can reach (CORS + strategy panel fetch).
NEXT_PUBLIC_API_URL=http://127.0.0.1:8000
# Optional: mailto: or https:// form for unified enterprise CTA (landing).
# NEXT_PUBLIC_SALES_CONTACT_URL=mailto:sales@example.com

View File

@ -0,0 +1,18 @@
import { test, expect } from "@playwright/test";
/**
* Public / low-auth smoke for launch readiness (strategy docs shell, landing).
*/
test.describe("Launch smoke — public routes", () => {
test("strategy page loads without 5xx", async ({ page }) => {
const res = await page.goto("/strategy");
expect(res?.ok()).toBeTruthy();
await expect(page.locator("body")).toBeVisible();
});
test("landing or root resolves", async ({ page }) => {
const res = await page.goto("/landing");
expect(res?.ok()).toBeTruthy();
await expect(page.locator("body")).toBeVisible();
});
});

View File

@ -0,0 +1,15 @@
import { test, expect } from "@playwright/test";
test.describe("Revenue discovery — public marketing alignment", () => {
test("landing shows enterprise CTA (no public pricing section id)", async ({ page }) => {
const res = await page.goto("/landing");
expect(res?.ok()).toBeTruthy();
await expect(page.locator("#enterprise")).toBeVisible();
await expect(
page
.locator("#enterprise")
.getByRole("link", { name: /عرض مؤسسي|تحدث مع المبيعات|طلب عرض مؤسسي/ })
.first(),
).toBeVisible();
});
});

View File

@ -1,6 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference path="./.next/types/routes.d.ts" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

@ -39,6 +39,24 @@
**اختياري (لا يمنع الإطلاق):** `HUBSPOT_API_KEY`, `UNIFONIC_APP_SID`, `RAPIDAPI_KEY`, `ENVIRONMENT=production` (يُنصح)، `API_URL` / `FRONTEND_URL` للإنتاج.
**استكشاف الإيرادات / بحث مرخّص (اختياري):**
| المتغير | الفئة | ملاحظات |
|---------|--------|---------|
| `TAVILY_API_KEY` | ذكاء | بحث ويب عبر [Tavily](https://tavily.com) لمسار `POST /api/v1/dealix/enrich-exploration` عند التفعيل |
| `DEALIX_ALLOW_LICENSED_SEARCH` | ذكاء | `false` يعطّل Tavily حتى لو المفتاح موجود |
| `DEALIX_TAVILY_TENANT_ALLOWLIST` | ذكاء | قائمة `tenant_id` مفصولة بفواصل؛ إن وُجدت، فقط هذه المستأجرين يستخدمون Tavily (ضبط التكلفة) |
| `DEALIX_INTEL_RATE_LIMIT` | تشغيل | حد أقصى لطلبات الذكاء لكل نافذة زمنية (افتراضي ٦٠/ساعة لكل IP) |
| `DEALIX_INTEL_RATE_WINDOW_SEC` | تشغيل | عرض النافذة بالثواني |
| `DEALIX_INTEL_CACHE_TTL_SEC` | تشغيل | TTL للتخزين المؤقت لنتائج التوليد/الإثراء |
| `DEALIX_DEEP_ENRICH_DEFAULT` | ذكاء | توسيع ثقيل اختياري لاحقاً |
| `DEALIX_DEEP_ENRICH_TENANTS` | ذكاء | قائمة مستأجرين مسموح لهم بمسارات إثراء عميق عند التفعيل |
| `NEXT_PUBLIC_SALES_CONTACT_URL` | واجهة | رابط موحّد لـ CTA «تحدث مع المبيعات» في الواجهة العامة |
| `DEALIX_KNOWLEDGE_RAG_ENRICH` | ذكاء | `false` يعطّل حقن مقتطفات `SectorAsset` في مسار الإثراء |
| `DEALIX_ENRICH_IDEMPOTENT_DAILY` | تشغيل | تخزين مؤقت يومي لكل (شركة+قطاع+مدينة) لتقليل تكرار استدعاءات الإثراء |
| `DEALIX_ASYNC_ENRICH_JOBS` | تشغيل | `false` يعطّل `POST /dealix/enrich-exploration/async` |
| `SERPER_API_KEY` / `BRAVE_API_KEY` | ذكاء | بدائل محتملة لـ Tavily — تتطلب تكوين كود مخصص قبل الاستخدام |
---
## 3. ويبهوكات (Webhooks)

View File

@ -337,8 +337,10 @@ function BillingTab({ label }: { label: L }) {
<Section title={label('الباقة الحالية', 'Current Plan')} label={label}>
<div className="flex items-center justify-between p-4 rounded-xl bg-gradient-to-bl from-primary/10 via-transparent to-transparent border border-primary/20">
<div>
<p className="text-lg font-bold text-white">{label('الباقة الاحترافية', 'Professional Plan')}</p>
<p className="text-sm text-slate-400">{label('١٤٩ ر.س / شهرياً', 'SAR 149 / month')}</p>
<p className="text-lg font-bold text-white">{label('اشتراك مؤسسي', 'Enterprise subscription')}</p>
<p className="text-sm text-slate-400">
{label('التسعير حسب العقد — تواصل مع المبيعات للتفاصيل.', 'Pricing per contract — contact sales for details.')}
</p>
</div>
<button className="px-5 py-2 rounded-xl bg-primary/20 hover:bg-primary/30 text-primary border border-primary/30 text-sm font-semibold transition-all">
{label('ترقية', 'Upgrade')}

View File

@ -56,7 +56,7 @@ export function LandingView({ onEnterApp }: { onEnterApp: () => void }) {
</div>
<div className="hidden md:flex items-center gap-8 text-sm font-medium opacity-80">
<a href="#" className="hover:text-primary transition-colors">المميزات</a>
<a href="#" className="hover:text-primary transition-colors">الأسعار</a>
<a href="#" className="hover:text-primary transition-colors">عرض مؤسسي</a>
<a href="#" className="hover:text-primary transition-colors">عنا</a>
</div>
<button

View File

@ -116,36 +116,26 @@ const provenDifferentiators = [
},
] as const;
const pricingPlans = [
/** تسعير B2B المؤسسي يُدار عبر المبيعات والعقود — لا أرقام علنية في الموقع. */
const SALES_CONTACT_HREF =
typeof process !== "undefined" && process.env.NEXT_PUBLIC_SALES_CONTACT_URL
? process.env.NEXT_PUBLIC_SALES_CONTACT_URL
: "mailto:sales@dealix.com?subject=%D8%B7%D9%84%D8%A8%20%D8%B9%D8%B1%D8%B6%20%D9%85%D8%A4%D8%B3%D8%B3%D9%8A%20Dealix";
const enterprisePackages = [
{
name: "Starter",
nameAr: "الأساسية",
price: "٥٩",
period: "شهرياً",
features: ["٣ مستخدمين", "٥٠٠ عميل محتمل", "واتساب أساسي", "تقارير أساسية", "دعم بالإيميل"],
cta: "ابدأ مجاناً",
highlighted: false,
title: "استكشاف الإيرادات والشراكات",
bullets: ["مساحة عمل لسياق ICP وإشارات السوق", "ربط بمسارات الصفقات الاستراتيجية", "حوكمة وموافقات قبل الإرسال عبر القنوات"],
},
{
name: "Professional",
nameAr: "الاحترافية",
price: "١٤٩",
period: "شهرياً",
features: ["١٠ مستخدمين", "عملاء غير محدودين", "واتساب + إيميل + SMS", "تقييم AI للعملاء", "Pipeline بصري", "عروض أسعار", "تقارير متقدمة", "دعم أولوية"],
cta: "ابدأ التجربة المجانية",
highlighted: true,
badge: "الأكثر شعبية",
title: "تشغيل مؤسسي",
bullets: ["تكاملات CRM مرخّصة وقابلة للتدقيق", "PDPL وسياسات معالجة واضحة", "وثائق API واختبارات إطلاق"],
},
{
name: "Enterprise",
nameAr: "المؤسسية",
price: "٢٢٥",
period: "شهرياً",
features: ["مستخدمين غير محدودين", "كل مميزات الاحترافية", "PDPL كامل", "API مفتوح", "مدير حساب مخصص", "تدريب الفريق", "SLA ٩٩.٩٪"],
cta: "تواصل معنا",
highlighted: false,
title: "دعم وتفعيل",
bullets: ["مدير حساب وتفعيل فريق", "قطاعات عمودية وplaybooks", "تدريب على الاستخدام الآمن للذكاء الاصطناعي"],
},
];
] as const;
/* ───────────── main component ───────────── */
export function PremiumLanding() {
@ -168,11 +158,14 @@ export function PremiumLanding() {
{/* ═══════════ NAV ═══════════ */}
<nav className="relative z-10 flex items-center justify-between px-6 md:px-12 py-5 max-w-7xl mx-auto">
<Link href="/register" className="px-5 py-2 rounded-xl bg-teal-500 text-black font-bold text-sm hover:bg-teal-400 transition-colors shadow-lg shadow-teal-500/20">
ابدأ مجاناً
</Link>
<a
href={SALES_CONTACT_HREF}
className="px-5 py-2 rounded-xl bg-teal-500 text-black font-bold text-sm hover:bg-teal-400 transition-colors shadow-lg shadow-teal-500/20"
>
تحدث مع المبيعات
</a>
<div className="hidden md:flex items-center gap-8 text-sm font-medium text-white/60">
<a href="#pricing" className="hover:text-white transition-colors">الأسعار</a>
<a href="#enterprise" className="hover:text-white transition-colors">عرض مؤسسي</a>
<a href="#differentiators" className="hover:text-white transition-colors">لماذا Dealix</a>
<a href="#features" className="hover:text-white transition-colors">المميزات</a>
<a href="#how" className="hover:text-white transition-colors">كيف يعمل</a>
@ -213,10 +206,13 @@ export function PremiumLanding() {
وتشغيل القنوات الذكية عبر واتساب وإيميل ولينكدإن.
</motion.p>
<motion.div variants={fadeUp} custom={2} className="flex flex-wrap gap-4">
<Link href="/register" className="px-8 py-4 rounded-2xl bg-teal-500 text-black font-black text-base hover:bg-teal-400 transition-all shadow-xl shadow-teal-500/25 flex items-center gap-2">
ابدأ مجاناً
<a
href={SALES_CONTACT_HREF}
className="px-8 py-4 rounded-2xl bg-teal-500 text-black font-black text-base hover:bg-teal-400 transition-all shadow-xl shadow-teal-500/25 flex items-center gap-2"
>
طلب عرض مؤسسي
<ArrowLeft className="w-5 h-5" />
</Link>
</a>
<Link href="/dashboard" className="px-8 py-4 rounded-2xl bg-white/5 border border-white/10 font-bold text-base hover:bg-white/10 transition-all flex items-center gap-2">
<Play className="w-4 h-4" />
استكشف المنصة
@ -399,60 +395,42 @@ export function PremiumLanding() {
</motion.div>
</Section>
{/* ═══════════ PRICING ═══════════ */}
<Section id="pricing" className="max-w-7xl mx-auto px-6 md:px-12 py-20">
{/* ═══════════ ENTERPRISE (no public pricing) ═══════════ */}
<Section id="enterprise" className="max-w-7xl mx-auto px-6 md:px-12 py-20">
<motion.h2 variants={fadeUp} className="text-3xl md:text-4xl font-black text-center mb-4">
أسعار بسيطة وشفافة
عرض مؤسسي ذكاء الإيرادات والشراكات
</motion.h2>
<motion.p variants={fadeUp} custom={1} className="text-center text-white/50 mb-12 max-w-lg mx-auto">
ابدأ مجاناً لمدة ١٤ يوم بدون بطاقة ائتمانية
<motion.p variants={fadeUp} custom={1} className="text-center text-white/50 mb-4 max-w-2xl mx-auto leading-relaxed">
التسعير والعقود يُبنى حسب حجم الفريق، القطاع، والتكاملات نناقش احتياجك مع فريق المبيعات دون التزام.
</motion.p>
<motion.p variants={fadeUp} custom={2} className="text-center text-white/40 text-sm mb-12 max-w-xl mx-auto">
لينكدإن والقنوات الحساسة تمر عبر مسودات وموافقة بشرية؛ لا أتمتة تخالف شروط المنصات.
</motion.p>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 items-start">
{pricingPlans.map((plan, i) => (
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 items-stretch">
{enterprisePackages.map((pkg, i) => (
<motion.div
key={i}
key={pkg.title}
variants={fadeUp}
custom={i}
whileHover={{ y: -4 }}
className={`relative rounded-3xl p-7 text-right transition-all ${
plan.highlighted
? "bg-teal-500/10 border-2 border-teal-500/40 shadow-xl shadow-teal-500/10"
: "bg-white/[0.03] border border-white/[0.08]"
}`}
className="rounded-3xl p-7 text-right bg-white/[0.03] border border-white/[0.08] backdrop-blur-xl transition-all hover:border-teal-500/25"
>
{plan.badge && (
<span className="absolute -top-3 left-1/2 -translate-x-1/2 px-4 py-1 rounded-full bg-teal-500 text-black text-xs font-black">
{plan.badge}
</span>
)}
<p className="text-sm text-white/50 font-medium">{plan.nameAr}</p>
<h3 className="text-sm font-bold text-white/70 mt-1">{plan.name}</h3>
<div className="flex items-baseline gap-1 mt-4 mb-6">
<span className="text-4xl font-black text-white">{plan.price}</span>
<span className="text-sm text-white/40 font-medium">ر.س / {plan.period}</span>
</div>
<h3 className="text-lg font-black text-teal-400 mb-4">{pkg.title}</h3>
<ul className="space-y-3 mb-8">
{plan.features.map((f, j) => (
<li key={j} className="flex items-center gap-2 text-sm text-white/70">
<CheckCircle2 className={`w-4 h-4 shrink-0 ${plan.highlighted ? "text-teal-400" : "text-white/30"}`} />
<span>{f}</span>
{pkg.bullets.map((b) => (
<li key={b} className="flex items-start gap-2 text-sm text-white/70">
<CheckCircle2 className="w-4 h-4 shrink-0 text-teal-400 mt-0.5" />
<span>{b}</span>
</li>
))}
</ul>
<button
className={`w-full py-3 rounded-xl font-bold text-sm transition-all ${
plan.highlighted
? "bg-teal-500 text-black hover:bg-teal-400 shadow-lg shadow-teal-500/20"
: "bg-white/5 border border-white/10 hover:bg-white/10"
}`}
<a
href={SALES_CONTACT_HREF}
className="block w-full py-3 rounded-xl font-bold text-sm text-center bg-teal-500 text-black hover:bg-teal-400 shadow-lg shadow-teal-500/20 transition-all"
>
{plan.cta}
</button>
تحدث مع المبيعات
</a>
</motion.div>
))}
</div>
@ -469,12 +447,15 @@ export function PremiumLanding() {
جاهز تنقل مبيعاتك للمستوى التالي؟
</h2>
<p className="text-white/50 mb-8 max-w-md mx-auto">
انضم لأكثر من ٥٠٠ شركة سعودية حققت نمو في المبيعات مع Dealix
نرسم معك مسار الاستكشاف، الحوكمة، والتكاملات بما يتوافق مع PDPL ومسارات المنصة الموثقة.
</p>
<button className="px-10 py-4 rounded-2xl bg-teal-500 text-black font-black text-lg hover:bg-teal-400 transition-all shadow-xl shadow-teal-500/25 mb-4">
ابدأ مجاناً الآن
</button>
<p className="text-sm text-white/40">١٤ يوم تجربة مجانية بدون بطاقة</p>
<a
href={SALES_CONTACT_HREF}
className="inline-block px-10 py-4 rounded-2xl bg-teal-500 text-black font-black text-lg hover:bg-teal-400 transition-all shadow-xl shadow-teal-500/25 mb-4"
>
تحدث مع المبيعات
</a>
<p className="text-sm text-white/40">عرض مؤسسي مخصص بدون تسعير علني</p>
</motion.div>
</Section>
@ -496,7 +477,7 @@ export function PremiumLanding() {
{/* links */}
<div className="flex items-center gap-6 text-sm text-white/40">
<a href="#features" className="hover:text-white/70 transition-colors">المنتج</a>
<a href="#pricing" className="hover:text-white/70 transition-colors">الأسعار</a>
<a href="#enterprise" className="hover:text-white/70 transition-colors">عرض مؤسسي</a>
<a href="#" className="hover:text-white/70 transition-colors">عن Dealix</a>
<a href="#" className="hover:text-white/70 transition-colors">تواصل</a>
</div>

View File

@ -0,0 +1,67 @@
#!/usr/bin/env python3
"""
Dealix AI quality gate (lightweight).
Validates:
1) Golden rubric file exists and has expected top-level sections.
2) In-process API route /api/v1/dealix/ai-eval/golden returns JSON.
Optional:
--json prints the fetched payload.
"""
from __future__ import annotations
import json
import os
import sys
from pathlib import Path
def main() -> int:
root = Path(__file__).resolve().parent.parent
backend = root / "backend"
golden = backend / "app" / "data" / "ai_eval_golden.json"
if not golden.exists():
print(f"AI quality gate FAILED: missing {golden}")
return 1
try:
payload = json.loads(golden.read_text(encoding="utf-8"))
except Exception as exc: # pragma: no cover - defensive
print(f"AI quality gate FAILED: invalid JSON in golden file ({exc})")
return 1
expected_any = ("channel_drafts", "enrich_exploration", "version")
if not any(key in payload for key in expected_any):
print("AI quality gate FAILED: golden rubric missing expected sections")
print(f"Expected one of: {', '.join(expected_any)}")
return 1
os.environ.setdefault("DATABASE_URL", "sqlite+aiosqlite:///./ai_quality_gate.db")
os.environ.setdefault("DEALIX_INTERNAL_API_TOKEN", "")
sys.path.insert(0, str(backend))
os.chdir(backend)
from fastapi.testclient import TestClient
from app.main import app
client = TestClient(app)
res = client.get("/api/v1/dealix/ai-eval/golden")
if res.status_code != 200:
print(f"AI quality gate FAILED: GET /dealix/ai-eval/golden => {res.status_code}")
return 1
if "--json" in sys.argv:
try:
print(json.dumps(res.json(), ensure_ascii=False, indent=2))
except Exception:
print(res.text[:1200])
print("AI quality gate: OK")
print("Golden rubric exists and API endpoint is readable.")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -7,11 +7,13 @@
#
# -HttpOnly : only hit the API (py scripts/full_stack_launch_test.py --http-only); skips pytest/lint/build.
# -BaseUrl : sets DEALIX_BASE_URL for HTTP phase (e.g. http://127.0.0.1:8001 when 8000 runs an old build).
# -WithOpenApiGate : after lint/build, run OpenAPI + go-live + hardening + AI-quality gates (no uvicorn).
param(
[switch]$HttpCheck,
[switch]$SoftReady,
[switch]$HttpOnly,
[switch]$WithOpenApiGate,
[string]$BaseUrl = ""
)
@ -76,6 +78,41 @@ try {
Pop-Location
}
if ($WithOpenApiGate) {
Write-Host "== OpenAPI vs frontend paths ==" -ForegroundColor Cyan
Push-Location $root
try {
& py -3 scripts/verify_frontend_openapi_paths.py
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
} finally {
Pop-Location
}
Write-Host "== Go-live gate (in-process, no server) ==" -ForegroundColor Cyan
Push-Location $root
try {
& py -3 scripts/check_go_live_gate.py
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
} finally {
Pop-Location
}
Write-Host "== Release hardening gate (env/docs/api contracts) ==" -ForegroundColor Cyan
Push-Location $root
try {
& py -3 scripts/release_hardening_gate.py
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
} finally {
Pop-Location
}
Write-Host "== AI quality gate (golden + endpoint) ==" -ForegroundColor Cyan
Push-Location $root
try {
& py -3 scripts/ai_quality_gate.py
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
} finally {
Pop-Location
}
}
if ($HttpCheck) {
Write-Host "== HTTP: full_stack_launch_test ==" -ForegroundColor Cyan
Push-Location $backend

View File

@ -0,0 +1,112 @@
#!/usr/bin/env python3
"""
Dealix release hardening gate.
Checks only static assets/docs/env contracts (no network calls):
- Required env toggles exist in examples
- Public Dealix API routes are listed in docs/API-MAP.md
- Critical launch docs exist
Exit code:
- 0 => pass
- 1 => fail
"""
from __future__ import annotations
from pathlib import Path
import sys
ROOT = Path(__file__).resolve().parent.parent
def _read(path: Path) -> str:
return path.read_text(encoding="utf-8")
def _check_contains(content: str, needles: list[str], source: str) -> list[str]:
misses: list[str] = []
for needle in needles:
if needle not in content:
misses.append(f"{source}: missing `{needle}`")
return misses
def main() -> int:
issues: list[str] = []
backend_env = ROOT / "backend" / ".env.phase2.example"
frontend_env = ROOT / "frontend" / ".env.example"
api_map = ROOT / "docs" / "API-MAP.md"
required_docs = [
ROOT / "docs" / "LAUNCH_CHECKLIST.md",
ROOT / "docs" / "LAUNCH_SIMULATION.md",
ROOT / "docs" / "DEALIX_AI_EVAL_AR.md",
ROOT / "docs" / "DEALIX_GTM_EXECUTION_AR.md",
]
if not backend_env.exists():
issues.append(f"Missing file: {backend_env}")
else:
issues.extend(
_check_contains(
_read(backend_env),
[
"DEALIX_ASYNC_ENRICH_JOBS",
"DEALIX_TAVILY_TENANT_ALLOWLIST",
"DEALIX_INTEL_CACHE_TTL_SEC",
"DEALIX_ENRICH_IDEMPOTENT_DAILY",
],
"backend/.env.phase2.example",
)
)
if not frontend_env.exists():
issues.append(f"Missing file: {frontend_env}")
else:
issues.extend(
_check_contains(
_read(frontend_env),
[
"NEXT_PUBLIC_API_URL",
],
"frontend/.env.example",
)
)
if not api_map.exists():
issues.append(f"Missing file: {api_map}")
else:
issues.extend(
_check_contains(
_read(api_map),
[
"/dealix/enrich-exploration",
"/dealix/enrich-exploration/async",
"/dealix/enrich-exploration/jobs/{job_id}",
"/dealix/channel-drafts",
"/dealix/intelligence-flags",
"/dealix/ai-eval/golden",
],
"docs/API-MAP.md",
)
)
for doc in required_docs:
if not doc.exists():
issues.append(f"Missing file: {doc}")
if issues:
print("Release hardening gate: FAILED")
for item in issues:
print(f"- {item}")
return 1
print("Release hardening gate: OK")
print("Static contracts (env/docs/api-map) are present.")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -1,5 +1,7 @@
# Thin wrapper: always resolves paths from salesflow-saas root.
# Examples:
# .\verify-launch.ps1
# .\verify-launch.ps1 -WithOpenApiGate # includes hardening + AI quality gates
# .\verify-launch.ps1 -HttpCheck -SoftReady
# .\verify-launch.ps1 -HttpOnly -BaseUrl "http://127.0.0.1:8001"
& "$PSScriptRoot\scripts\grand_launch_verify.ps1" @args