diff --git a/.github/workflows/dealix-ci.yml b/.github/workflows/dealix-ci.yml index 2418ef8a..870cc57f 100644 --- a/.github/workflows/dealix-ci.yml +++ b/.github/workflows/dealix-ci.yml @@ -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 diff --git a/salesflow-saas/backend/.env.phase2.example b/salesflow-saas/backend/.env.phase2.example index 377c062f..d16d6ac8 100644 --- a/salesflow-saas/backend/.env.phase2.example +++ b/salesflow-saas/backend/.env.phase2.example @@ -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 diff --git a/salesflow-saas/backend/app/api/v1/master.py b/salesflow-saas/backend/app/api/v1/master.py index d3627df4..5865acef 100644 --- a/salesflow-saas/backend/app/api/v1/master.py +++ b/salesflow-saas/backend/app/api/v1/master.py @@ -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") diff --git a/salesflow-saas/backend/app/data/ai_eval_golden.json b/salesflow-saas/backend/app/data/ai_eval_golden.json new file mode 100644 index 00000000..be728d2b --- /dev/null +++ b/salesflow-saas/backend/app/data/ai_eval_golden.json @@ -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" + } +} diff --git a/salesflow-saas/backend/app/middleware/internal_api.py b/salesflow-saas/backend/app/middleware/internal_api.py index 695b9155..f397a41d 100644 --- a/salesflow-saas/backend/app/middleware/internal_api.py +++ b/salesflow-saas/backend/app/middleware/internal_api.py @@ -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 diff --git a/salesflow-saas/backend/app/schemas/dealix_master.py b/salesflow-saas/backend/app/schemas/dealix_master.py new file mode 100644 index 00000000..46e4e3ae --- /dev/null +++ b/salesflow-saas/backend/app/schemas/dealix_master.py @@ -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 = "" diff --git a/salesflow-saas/backend/app/schemas/revenue_discovery.py b/salesflow-saas/backend/app/schemas/revenue_discovery.py new file mode 100644 index 00000000..a32a55fe --- /dev/null +++ b/salesflow-saas/backend/app/schemas/revenue_discovery.py @@ -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) diff --git a/salesflow-saas/backend/app/services/dealix_enrichment_runner.py b/salesflow-saas/backend/app/services/dealix_enrichment_runner.py new file mode 100644 index 00000000..9886e88d --- /dev/null +++ b/salesflow-saas/backend/app/services/dealix_enrichment_runner.py @@ -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 diff --git a/salesflow-saas/backend/app/services/intel_async_jobs.py b/salesflow-saas/backend/app/services/intel_async_jobs.py new file mode 100644 index 00000000..2b000a5b --- /dev/null +++ b/salesflow-saas/backend/app/services/intel_async_jobs.py @@ -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 diff --git a/salesflow-saas/backend/app/services/intelligence_plane_control.py b/salesflow-saas/backend/app/services/intelligence_plane_control.py new file mode 100644 index 00000000..f3ec208e --- /dev/null +++ b/salesflow-saas/backend/app/services/intelligence_plane_control.py @@ -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 diff --git a/salesflow-saas/backend/app/services/lead_generation.py b/salesflow-saas/backend/app/services/lead_generation.py index 734fe78e..a59ecb0c 100644 --- a/salesflow-saas/backend/app/services/lead_generation.py +++ b/salesflow-saas/backend/app/services/lead_generation.py @@ -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 { diff --git a/salesflow-saas/backend/app/services/revenue_discovery_service.py b/salesflow-saas/backend/app/services/revenue_discovery_service.py new file mode 100644 index 00000000..729b4e3b --- /dev/null +++ b/salesflow-saas/backend/app/services/revenue_discovery_service.py @@ -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]} diff --git a/salesflow-saas/backend/scripts/full_stack_launch_test.py b/salesflow-saas/backend/scripts/full_stack_launch_test.py index e77c1d06..88da065a 100644 --- a/salesflow-saas/backend/scripts/full_stack_launch_test.py +++ b/salesflow-saas/backend/scripts/full_stack_launch_test.py @@ -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")) diff --git a/salesflow-saas/backend/scripts/revenue_discovery_e2e_probe.py b/salesflow-saas/backend/scripts/revenue_discovery_e2e_probe.py new file mode 100644 index 00000000..b0025cf1 --- /dev/null +++ b/salesflow-saas/backend/scripts/revenue_discovery_e2e_probe.py @@ -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 +""" +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())) diff --git a/salesflow-saas/backend/tests/test_revenue_discovery_api.py b/salesflow-saas/backend/tests/test_revenue_discovery_api.py new file mode 100644 index 00000000..a9439a54 --- /dev/null +++ b/salesflow-saas/backend/tests/test_revenue_discovery_api.py @@ -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) diff --git a/salesflow-saas/docs/API-MAP.md b/salesflow-saas/docs/API-MAP.md index b1280289..1ab5c32d 100644 --- a/salesflow-saas/docs/API-MAP.md +++ b/salesflow-saas/docs/API-MAP.md @@ -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 | diff --git a/salesflow-saas/docs/DEALIX_AI_EVAL_AR.md b/salesflow-saas/docs/DEALIX_AI_EVAL_AR.md new file mode 100644 index 00000000..5a1a02de --- /dev/null +++ b/salesflow-saas/docs/DEALIX_AI_EVAL_AR.md @@ -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`.* diff --git a/salesflow-saas/docs/DEALIX_GTM_EXECUTION_AR.md b/salesflow-saas/docs/DEALIX_GTM_EXECUTION_AR.md new file mode 100644 index 00000000..50b8a89c --- /dev/null +++ b/salesflow-saas/docs/DEALIX_GTM_EXECUTION_AR.md @@ -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` (التشغيل المستمر).* diff --git a/salesflow-saas/docs/DEALIX_POST_LAUNCH_OPS_AR.md b/salesflow-saas/docs/DEALIX_POST_LAUNCH_OPS_AR.md new file mode 100644 index 00000000..a57e8e94 --- /dev/null +++ b/salesflow-saas/docs/DEALIX_POST_LAUNCH_OPS_AR.md @@ -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` diff --git a/salesflow-saas/docs/INTEGRATION_MASTER_AR.md b/salesflow-saas/docs/INTEGRATION_MASTER_AR.md index fdf5027d..9087f118 100644 --- a/salesflow-saas/docs/INTEGRATION_MASTER_AR.md +++ b/salesflow-saas/docs/INTEGRATION_MASTER_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) diff --git a/salesflow-saas/docs/LAUNCH_CHECKLIST.md b/salesflow-saas/docs/LAUNCH_CHECKLIST.md index 046e7ff2..c083ec1f 100644 --- a/salesflow-saas/docs/LAUNCH_CHECKLIST.md +++ b/salesflow-saas/docs/LAUNCH_CHECKLIST.md @@ -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` إن لزم.* diff --git a/salesflow-saas/docs/LAUNCH_SIMULATION.md b/salesflow-saas/docs/LAUNCH_SIMULATION.md index d1e94497..94f0b958 100644 --- a/salesflow-saas/docs/LAUNCH_SIMULATION.md +++ b/salesflow-saas/docs/LAUNCH_SIMULATION.md @@ -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 عند اختبار الربط الاستراتيجي). diff --git a/salesflow-saas/frontend/.env.example b/salesflow-saas/frontend/.env.example index 8def7eac..ec5c468b 100644 --- a/salesflow-saas/frontend/.env.example +++ b/salesflow-saas/frontend/.env.example @@ -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 diff --git a/salesflow-saas/frontend/e2e/launch-smoke.spec.ts b/salesflow-saas/frontend/e2e/launch-smoke.spec.ts new file mode 100644 index 00000000..8de6219a --- /dev/null +++ b/salesflow-saas/frontend/e2e/launch-smoke.spec.ts @@ -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(); + }); +}); diff --git a/salesflow-saas/frontend/e2e/revenue-discovery-public.spec.ts b/salesflow-saas/frontend/e2e/revenue-discovery-public.spec.ts new file mode 100644 index 00000000..0e8e63ae --- /dev/null +++ b/salesflow-saas/frontend/e2e/revenue-discovery-public.spec.ts @@ -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(); + }); +}); diff --git a/salesflow-saas/frontend/next-env.d.ts b/salesflow-saas/frontend/next-env.d.ts index 830fb594..1b3be084 100644 --- a/salesflow-saas/frontend/next-env.d.ts +++ b/salesflow-saas/frontend/next-env.d.ts @@ -1,6 +1,5 @@ /// /// -/// // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/salesflow-saas/frontend/public/strategy/INTEGRATION_MASTER_AR.md b/salesflow-saas/frontend/public/strategy/INTEGRATION_MASTER_AR.md index fdf5027d..9087f118 100644 --- a/salesflow-saas/frontend/public/strategy/INTEGRATION_MASTER_AR.md +++ b/salesflow-saas/frontend/public/strategy/INTEGRATION_MASTER_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) diff --git a/salesflow-saas/frontend/src/app/settings/page.tsx b/salesflow-saas/frontend/src/app/settings/page.tsx index 50c760bf..35c8b12a 100644 --- a/salesflow-saas/frontend/src/app/settings/page.tsx +++ b/salesflow-saas/frontend/src/app/settings/page.tsx @@ -337,8 +337,10 @@ function BillingTab({ label }: { label: L }) {
-

{label('الباقة الاحترافية', 'Professional Plan')}

-

{label('١٤٩ ر.س / شهرياً', 'SAR 149 / month')}

+

{label('اشتراك مؤسسي', 'Enterprise subscription')}

+

+ {label('التسعير حسب العقد — تواصل مع المبيعات للتفاصيل.', 'Pricing per contract — contact sales for details.')} +

- - - {/* Stats */} - {leads.length > 0 && ( -
- {[ - { label: "إجمالي Leads", value: leads.length, color: "#00D4FF" }, - { label: "🔥 ساخن", value: leads.filter(l => l.urgency === "high").length, color: "#22c55e" }, - { label: "⚡ دافئ", value: leads.filter(l => l.urgency === "medium").length, color: "#f59e0b" }, - ].map(stat => ( -
-
{stat.label}
-
{stat.value}
+
+
+
+
+
+ +

+ {L("مساحة استكشاف الإيرادات", "Revenue discovery workspace")} +

- ))} -
- )} +

+ {L( + "خطوات: سياق ICP، مسح السوق، اختيار فرصة، ثم مسودات قنوات بحوكمة وربط CRM/الصفقات الاستراتيجية.", + "ICP context → market scan → opportunities → governed channel drafts and CRM / strategic links." + )} +

+
+
+ + +
+ -
0 ? "1fr 1.2fr" : "1fr", gap: 20 }}> - {/* Leads List */} - {leads.length > 0 && ( -
- {leads.map((lead, i) => ( -
setSelected(lead)} - style={{ - background: selected === lead ? "#0f2040" : "#0f1729", - border: `1px solid ${selected === lead ? "#F5A623" : "#1e3a5f"}`, - borderRadius: 10, padding: 16, cursor: "pointer", transition: "all 0.2s" - }}> -
-
-
{lead.company_name}
-
{lead.estimated_size} • {lead.contact_approach}
-
- - {urgencyLabel[lead.urgency] || lead.urgency} - -
-
💡 {lead.pain_point}
-
- {lead.estimated_deal_value} - -
-
- ))} + {(governanceSnap || aiRouting) && ( +
+
+

+ {L("حوكمة الصفقات وتوجيه النماذج", "Deals governance & model routing")} +

+ {governanceSnap && ( +

+ governance: {JSON.stringify(governanceSnap).slice(0, 220)}… +

+ )} + {aiRouting && ( +

+ ai/routing discovery:{" "} + {JSON.stringify( + (aiRouting as { effective?: { discovery?: unknown } }).effective?.discovery ?? {} + ).slice(0, 180)} + … +

+ )} +
+
)} - {/* Side Panel */} - {(selected || pipelineResult) && ( -
- {selected && !pipelineResult && ( -
-

{selected.company_name}

- {[ - { label: "الحل المقترح", value: selected.dealix_solution }, - { label: "سبب الملاءمة", value: selected.why_good_fit }, - { label: "قيمة الصفقة", value: selected.estimated_deal_value }, - { label: "أسلوب التواصل", value: selected.contact_approach }, - ].map(item => ( -
-
{item.label}
-
{item.value}
-
- ))} + {/* Stepper */} + + + {actionMsg && ( +
{actionMsg}
+ )} + + {/* Step 0 — ICP */} + {step === 0 && ( +
+

+ + {L("سياق العميل المثالي (ICP)", "Ideal customer profile")} +

+
+ + + +
+