mirror of
https://github.com/x1xhlol/system-prompts-and-models-of-ai-tools.git
synced 2026-06-17 23:09:35 +00:00
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:
parent
07557c4be9
commit
d8bb836614
12
.github/workflows/dealix-ci.yml
vendored
12
.github/workflows/dealix-ci.yml
vendored
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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")
|
||||
|
||||
16
salesflow-saas/backend/app/data/ai_eval_golden.json
Normal file
16
salesflow-saas/backend/app/data/ai_eval_golden.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
15
salesflow-saas/backend/app/schemas/dealix_master.py
Normal file
15
salesflow-saas/backend/app/schemas/dealix_master.py
Normal 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 = ""
|
||||
61
salesflow-saas/backend/app/schemas/revenue_discovery.py
Normal file
61
salesflow-saas/backend/app/schemas/revenue_discovery.py
Normal 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)
|
||||
@ -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
|
||||
60
salesflow-saas/backend/app/services/intel_async_jobs.py
Normal file
60
salesflow-saas/backend/app/services/intel_async_jobs.py
Normal 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
|
||||
@ -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
|
||||
@ -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 {
|
||||
|
||||
269
salesflow-saas/backend/app/services/revenue_discovery_service.py
Normal file
269
salesflow-saas/backend/app/services/revenue_discovery_service.py
Normal 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]}
|
||||
@ -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"))
|
||||
|
||||
183
salesflow-saas/backend/scripts/revenue_discovery_e2e_probe.py
Normal file
183
salesflow-saas/backend/scripts/revenue_discovery_e2e_probe.py
Normal 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()))
|
||||
81
salesflow-saas/backend/tests/test_revenue_discovery_api.py
Normal file
81
salesflow-saas/backend/tests/test_revenue_discovery_api.py
Normal 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)
|
||||
@ -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 |
|
||||
|
||||
47
salesflow-saas/docs/DEALIX_AI_EVAL_AR.md
Normal file
47
salesflow-saas/docs/DEALIX_AI_EVAL_AR.md
Normal 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`.*
|
||||
63
salesflow-saas/docs/DEALIX_GTM_EXECUTION_AR.md
Normal file
63
salesflow-saas/docs/DEALIX_GTM_EXECUTION_AR.md
Normal 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` (التشغيل المستمر).*
|
||||
57
salesflow-saas/docs/DEALIX_POST_LAUNCH_OPS_AR.md
Normal file
57
salesflow-saas/docs/DEALIX_POST_LAUNCH_OPS_AR.md
Normal 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`
|
||||
@ -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)
|
||||
|
||||
@ -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` إن لزم.*
|
||||
|
||||
@ -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` ثم الخطوتين 2–3 يدوياً).
|
||||
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 عند اختبار الربط الاستراتيجي).
|
||||
|
||||
@ -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
|
||||
|
||||
18
salesflow-saas/frontend/e2e/launch-smoke.spec.ts
Normal file
18
salesflow-saas/frontend/e2e/launch-smoke.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
15
salesflow-saas/frontend/e2e/revenue-discovery-public.spec.ts
Normal file
15
salesflow-saas/frontend/e2e/revenue-discovery-public.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
1
salesflow-saas/frontend/next-env.d.ts
vendored
1
salesflow-saas/frontend/next-env.d.ts
vendored
@ -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.
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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')}
|
||||
|
||||
@ -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
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -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>
|
||||
|
||||
67
salesflow-saas/scripts/ai_quality_gate.py
Normal file
67
salesflow-saas/scripts/ai_quality_gate.py
Normal 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())
|
||||
@ -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
|
||||
|
||||
112
salesflow-saas/scripts/release_hardening_gate.py
Normal file
112
salesflow-saas/scripts/release_hardening_gate.py
Normal 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())
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user