| from dotenv import load_dotenv |
| from pathlib import Path |
|
|
| ROOT_DIR = Path(__file__).parent |
| load_dotenv(ROOT_DIR / '.env') |
|
|
| import os |
| import io |
| import csv |
| import re |
| import uuid |
| import json |
| import asyncio |
| import logging |
| import base64 |
| import secrets |
| from datetime import datetime, timezone, timedelta |
| from time import monotonic |
| from typing import List, Optional, Dict, Any |
|
|
| import httpx |
| import numpy as np |
| from openai import OpenAI |
|
|
| import firebase_admin |
| from firebase_admin import credentials, auth, firestore, storage |
| from google.cloud.firestore_v1.base_query import FieldFilter |
| from fastapi import FastAPI, APIRouter, HTTPException, Depends, Request, Response, UploadFile, File, Form, BackgroundTasks |
| from fastapi.responses import StreamingResponse |
| from fastapi.security import HTTPBearer |
| from starlette.middleware.cors import CORSMiddleware |
| from pydantic import BaseModel, EmailStr, Field, ConfigDict |
|
|
| |
| logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s") |
| logger = logging.getLogger("scatter") |
|
|
| |
| if not firebase_admin._apps: |
| firebase_json = os.environ.get("FIREBASE_JSON") |
| firebase_json_path = os.environ.get("FIREBASE_JSON_PATH") |
| cred = None |
| if firebase_json: |
| try: |
| cred_dict = json.loads(firebase_json) |
| cred = credentials.Certificate(cred_dict) |
| except Exception as e: |
| logger.error(f"Error parsing FIREBASE_JSON: {e}") |
| cred = credentials.ApplicationDefault() |
| elif firebase_json_path: |
| try: |
| cred = credentials.Certificate(firebase_json_path) |
| except Exception as e: |
| logger.error(f"Error loading FIREBASE_JSON_PATH ({firebase_json_path}): {e}") |
| cred = credentials.ApplicationDefault() |
| else: |
| cred = credentials.ApplicationDefault() |
|
|
| firebase_admin.initialize_app(cred, { |
| 'projectId': os.environ.get("FIREBASE_PROJECT_ID", "scatter-studio-live-2026"), |
| 'storageBucket': os.environ.get("FIREBASE_STORAGE_BUCKET", "scatter-studio-live-2026.firebasestorage.app") |
| }) |
|
|
| db = firestore.client() |
| bucket = storage.bucket() |
|
|
| app = FastAPI(title="Scatter Studio API") |
|
|
| |
| |
| |
| |
| |
| _default_origins = [ |
| "https://scatter-studio-eight.vercel.app", |
| "http://localhost:3000", |
| "http://127.0.0.1:3000", |
| ] |
| _env_origins = (os.environ.get("ALLOWED_ORIGINS") or "").strip() |
| if _env_origins == "*": |
| _cors_origins = ["*"] |
| elif _env_origins: |
| _cors_origins = [o.strip() for o in _env_origins.split(",") if o.strip()] |
| else: |
| |
| |
| _cors_origins = _default_origins |
|
|
| app.add_middleware( |
| CORSMiddleware, |
| allow_origins=_cors_origins, |
| allow_origin_regex=r"https://.*\.vercel\.app" if _cors_origins != ["*"] else None, |
| allow_credentials=False, |
| allow_methods=["*"], |
| allow_headers=["*"], |
| expose_headers=["*"], |
| ) |
|
|
| |
| from fastapi.responses import JSONResponse |
| from fastapi.exceptions import RequestValidationError |
| from starlette.exceptions import HTTPException as StarletteHTTPException |
|
|
| @app.exception_handler(Exception) |
| async def global_exception_handler(request: Request, exc: Exception): |
| logger.error(f"Unhandled exception: {exc}", exc_info=True) |
| return JSONResponse( |
| status_code=500, |
| content={"detail": "Internal server error"}, |
| headers={"Access-Control-Allow-Origin": "*"}, |
| ) |
|
|
| @app.exception_handler(StarletteHTTPException) |
| async def http_exception_handler(request: Request, exc: StarletteHTTPException): |
| return JSONResponse( |
| status_code=exc.status_code, |
| content={"detail": exc.detail}, |
| headers={"Access-Control-Allow-Origin": "*"}, |
| ) |
|
|
| @app.exception_handler(RequestValidationError) |
| async def validation_exception_handler(request: Request, exc: RequestValidationError): |
| logger.error(f"Validation error: {exc}") |
| return JSONResponse( |
| status_code=422, |
| content={"detail": str(exc)}, |
| headers={"Access-Control-Allow-Origin": "*"}, |
| ) |
|
|
| api_router = APIRouter(prefix="/api") |
| security = HTTPBearer(auto_error=False) |
|
|
| _CACHE: Dict[str, tuple[float, Any]] = {} |
|
|
|
|
| def _cache_get(key: str): |
| hit = _CACHE.get(key) |
| if not hit: |
| return None |
| expires_at, value = hit |
| if expires_at <= monotonic(): |
| _CACHE.pop(key, None) |
| return None |
| return value |
|
|
|
|
| def _cache_set(key: str, value: Any, ttl: int = 20): |
| _CACHE[key] = (monotonic() + ttl, value) |
| return value |
|
|
|
|
| def _cache_invalidate_user(uid: str, *areas: str): |
| prefixes = tuple(f"{area}:{uid}" for area in areas) if areas else (f"user:{uid}",) |
| for key in list(_CACHE): |
| if key.startswith(prefixes): |
| _CACHE.pop(key, None) |
|
|
|
|
| |
| |
| |
| |
| _STALE: Dict[str, Any] = {} |
| _INFLIGHT: set = set() |
|
|
|
|
| def _swr(key: str, builder, fresh_ttl: int = 60): |
| """Return `builder()`'s result with stale-while-revalidate semantics. |
| - Fresh cache hit → return it. |
| - Stale value present → return it NOW, refresh in a background thread. |
| - Nothing at all → compute synchronously (first-ever load) and store.""" |
| fresh = _cache_get(key) |
| if fresh is not None: |
| return fresh |
|
|
| def _refresh(): |
| try: |
| val = builder() |
| _cache_set(key, val, ttl=fresh_ttl) |
| _STALE[key] = val |
| except Exception as e: |
| logger.warning(f"[SWR] refresh failed for {key}: {e}") |
| finally: |
| _INFLIGHT.discard(key) |
|
|
| stale = _STALE.get(key) |
| if stale is not None: |
| if key not in _INFLIGHT: |
| _INFLIGHT.add(key) |
| import threading as _th |
| _th.Thread(target=_refresh, daemon=True).start() |
| return stale |
|
|
| |
| val = builder() |
| _cache_set(key, val, ttl=fresh_ttl) |
| _STALE[key] = val |
| return val |
|
|
|
|
| |
| import hashlib as _hashlib |
|
|
| |
| |
| |
| |
| |
| _TOKEN_TTL_SEC = 120 |
|
|
|
|
| def _verify_token_cached(token: str) -> dict: |
| tkey = "tok:" + _hashlib.sha256(token.encode()).hexdigest()[:32] |
| hit = _cache_get(tkey) |
| if hit: |
| return hit |
| decoded = auth.verify_id_token(token) |
| |
| import time as _t |
| ttl = _TOKEN_TTL_SEC |
| exp = decoded.get("exp") |
| if exp: |
| ttl = max(5, min(_TOKEN_TTL_SEC, int(exp - _t.time() - 10))) |
| _cache_set(tkey, decoded, ttl=ttl) |
| return decoded |
|
|
|
|
| async def get_current_user(request: Request) -> dict: |
| token = None |
| auth_header = request.headers.get("Authorization", "") |
| if auth_header.startswith("Bearer "): |
| token = auth_header[7:] |
| if not token: |
| raise HTTPException(status_code=401, detail="Not authenticated") |
| try: |
| |
| |
| decoded_token = await asyncio.to_thread(_verify_token_cached, token) |
| uid = decoded_token['uid'] |
| cached_user = _cache_get(f"user:{uid}") |
| if cached_user: |
| return cached_user |
| user_doc = await asyncio.to_thread(lambda: db.collection("users").document(uid).get()) |
| if not user_doc.exists: |
| user_data = { |
| "id": uid, |
| "email": decoded_token.get("email"), |
| "name": decoded_token.get("name", "User"), |
| "role": "user", |
| "workspace": "Scatter Studio", |
| "created_at": datetime.now(timezone.utc).isoformat(), |
| "api_key": "sk-scatter-" + secrets.token_hex(20), |
| } |
| db.collection("users").document(uid).set(user_data) |
| return _cache_set(f"user:{uid}", user_data, ttl=600) |
| data = user_doc.to_dict() |
| |
| if not data.get("api_key"): |
| data["api_key"] = "sk-scatter-" + secrets.token_hex(20) |
| db.collection("users").document(uid).update({"api_key": data["api_key"]}) |
| return _cache_set(f"user:{uid}", data, ttl=600) |
| except HTTPException: |
| raise |
| except Exception as e: |
| logger.error(f"Auth error: {e}") |
| raise HTTPException(status_code=401, detail="Invalid or expired token") |
|
|
|
|
| def now_iso() -> str: |
| return datetime.now(timezone.utc).isoformat() |
|
|
|
|
| |
| class UserPublic(BaseModel): |
| model_config = ConfigDict(extra="ignore") |
| id: str |
| email: EmailStr |
| name: str |
| role: str = "user" |
| workspace: str = "Scatter Studio" |
| created_at: str |
| api_key: Optional[str] = None |
|
|
|
|
| class AgentIn(BaseModel): |
| model_config = ConfigDict(extra="ignore") |
| name: str |
| role: str = "Sales" |
| persona: str = "You are a friendly customer support agent." |
| welcome_message: str = "Hello! How can I help you today?" |
| language: str = "Hinglish" |
| voice_provider: str = "Sarvam" |
| voice_id: str = "shreya" |
| voice_speed: float = 1.0 |
| voice_pitch: float = 0.0 |
| llm_provider: str = "Groq" |
| llm_model: str = "llama-3.3-70b-versatile" |
| stt_provider: str = "Sarvam" |
| knowledge_base_ids: List[str] = [] |
| phone_number: Optional[str] = None |
| status: str = "active" |
| |
| silence_nudge_seconds: int = 15 |
| silence_disconnect_seconds: int = 30 |
| silence_nudge_message: str = "" |
| silence_goodbye_message: str = "" |
| |
| integrations: Dict[str, bool] = Field(default_factory=dict) |
| |
| crm_spreadsheet_id: str = "" |
|
|
|
|
| class Agent(AgentIn): |
| id: str |
| created_at: str |
| updated_at: str |
| calls_handled: int = 0 |
| avg_duration: float = 0.0 |
| last_active: Optional[str] = None |
|
|
|
|
| class CampaignIn(BaseModel): |
| model_config = ConfigDict(extra="ignore") |
| name: str |
| agent_id: str |
| pacing_per_minute: int = 10 |
| schedule: Optional[str] = None |
| contacts: List[Dict[str, Any]] = [] |
|
|
|
|
| class Campaign(CampaignIn): |
| id: str |
| status: str = "draft" |
| created_at: str |
| progress: Dict[str, int] = Field(default_factory=lambda: { |
| "total": 0, "dialed": 0, "connected": 0, "completed": 0, "failed": 0, "leads": 0, |
| }) |
|
|
|
|
| class CallLog(BaseModel): |
| model_config = ConfigDict(extra="ignore") |
| id: str |
| agent_id: Optional[str] = "" |
| agent_name: str = "" |
| direction: str = "inbound" |
| phone_number: str = "" |
| contact_name: Optional[str] = None |
| duration_seconds: int = 0 |
| status: str = "queued" |
| sentiment: str = "neutral" |
| lead_tag: str = "none" |
| intent: str = "" |
| summary: str = "" |
| analysis: str = "" |
| topics: List[str] = [] |
| transcript: List[Dict[str, Any]] = [] |
| started_at: str = "" |
| recording_url: Optional[str] = None |
| provider_call_id: Optional[str] = None |
| channel: str = "" |
| |
| lead_name: str = "" |
| lead_email: str = "" |
| lead_company: str = "" |
| lead_phone: str = "" |
| lead_score: int = 0 |
| |
| actions: List[Dict[str, Any]] = [] |
| |
| live: bool = False |
|
|
|
|
| class Lead(BaseModel): |
| model_config = ConfigDict(extra="ignore") |
| id: str = "" |
| agent_id: str = "" |
| agent_name: str = "" |
| name: str = "" |
| email: str = "" |
| company: str = "" |
| phone: str = "" |
| intent: str = "" |
| message: str = "" |
| source: str = "" |
| status: str = "new" |
| sentiment: str = "neutral" |
| score: int = 0 |
|
|
|
|
| class PhoneNumber(BaseModel): |
| id: str |
| number: str |
| provider: str = "Vobiz" |
| label: str |
| assigned_agent_id: Optional[str] = None |
| status: str = "active" |
| sip_domain: Optional[str] = None |
| sip_username: Optional[str] = None |
| sip_password: Optional[str] = None |
|
|
|
|
| class KnowledgeDoc(BaseModel): |
| model_config = ConfigDict(extra="ignore") |
| id: str |
| name: str = "" |
| size_kb: float = 0 |
| pages: int = 0 |
| status: str = "ready" |
| uploaded_at: str = "" |
| chunks: Optional[int] = 0 |
|
|
|
|
| |
| @api_router.get("/auth/me", response_model=UserPublic) |
| async def me(user: dict = Depends(get_current_user)): |
| return UserPublic(**user) |
|
|
|
|
| |
| from livekit import api as livekit_api |
|
|
|
|
| @api_router.get("/livekit/token") |
| async def get_livekit_token(agent_id: str, user: dict = Depends(get_current_user)): |
| agent_doc = db.collection("agents").document(agent_id).get() |
| if not agent_doc.exists or agent_doc.to_dict().get("owner_id") != user["id"]: |
| raise HTTPException(status_code=404, detail="Agent not found") |
|
|
| |
| room_name = f"agent_{agent_id}__user_{user['id']}__session_{uuid.uuid4().hex}" |
| participant_name = user.get("name") or "User" |
|
|
| api_key = os.environ.get("LIVEKIT_API_KEY") |
| api_secret = os.environ.get("LIVEKIT_API_SECRET") |
| livekit_url = os.environ.get("LIVEKIT_URL") |
| if not api_key or not api_secret: |
| raise HTTPException(status_code=500, detail="LIVEKIT_API_KEY/SECRET not configured") |
| if not livekit_url: |
| raise HTTPException(status_code=500, detail="LIVEKIT_URL not configured") |
|
|
| metadata = json.dumps({"agent_id": agent_id, "owner_id": user["id"]}) |
| agent_dispatch_name = os.environ.get("LIVEKIT_AGENT_NAME", "scatterstudio-agent") |
|
|
| token = livekit_api.AccessToken(api_key, api_secret) |
| token = ( |
| token.with_identity(user["id"]) |
| .with_name(participant_name) |
| .with_metadata(metadata) |
| .with_grants( |
| livekit_api.VideoGrants( |
| room_join=True, |
| room=room_name, |
| can_publish=True, |
| can_subscribe=True, |
| ) |
| ) |
| ) |
| lkapi = None |
| try: |
| lkapi = livekit_api.LiveKitAPI(url=livekit_url, api_key=api_key, api_secret=api_secret) |
| await lkapi.agent_dispatch.create_dispatch( |
| livekit_api.CreateAgentDispatchRequest( |
| room=room_name, |
| agent_name=agent_dispatch_name, |
| metadata=metadata, |
| ) |
| ) |
| except Exception as e: |
| logger.error(f"LiveKit agent dispatch failed for room {room_name}: {e}") |
| raise HTTPException(status_code=502, detail="Failed to dispatch LiveKit agent") |
| finally: |
| if lkapi: |
| await lkapi.aclose() |
| return {"token": token.to_jwt(), "url": livekit_url, "room": room_name, "agent_id": agent_id} |
|
|
|
|
| |
|
|
| def _lk_creds(): |
| """Return (api_key, api_secret, livekit_url) or raise.""" |
| api_key = os.environ.get("LIVEKIT_API_KEY", "") |
| api_secret = os.environ.get("LIVEKIT_API_SECRET", "") |
| url = os.environ.get("LIVEKIT_URL", "") |
| if not (api_key and api_secret and url): |
| raise HTTPException(status_code=500, detail="LIVEKIT_API_KEY/SECRET/URL not configured") |
| return api_key, api_secret, url |
|
|
|
|
| def _to_e164(num: str, default_cc: str = "91") -> str: |
| """Normalise a phone number to E.164 for the LiveKit→SIP outbound leg. |
| A bare 10-digit Indian number is rejected by the trunk (the call never rings |
| even though CreateSIPParticipant returns OK), so we always add +<cc>. |
| Indian-default: 10-digit → +91XXXXXXXXXX; 12-digit starting 91 → +91…; |
| already-+ → unchanged.""" |
| s = str(num or "").strip().replace(" ", "").replace("-", "") |
| if not s: |
| return s |
| if s.startswith("+"): |
| return s |
| s = s.lstrip("0") |
| if len(s) == 10: |
| return f"+{default_cc}{s}" |
| if s.startswith(default_cc) and len(s) >= (len(default_cc) + 10): |
| return f"+{s}" |
| return f"+{s}" |
|
|
|
|
| |
| _PHONE_LANG_MAP = [ |
| |
| |
| ("+9151", "hi-IN"), ("+9152", "hi-IN"), ("+9153", "hi-IN"), |
| ("+9155", "hi-IN"), ("+9156", "hi-IN"), ("+9157", "hi-IN"), |
| ("+9161", "hi-IN"), ("+9162", "hi-IN"), ("+9163", "hi-IN"), |
| |
| ("+9144", "ta-IN"), ("+9142", "ta-IN"), ("+9143", "ta-IN"), |
| ("+9145", "ta-IN"), |
| |
| ("+9140", "te-IN"), ("+9186", "te-IN"), ("+9187", "te-IN"), |
| ("+9188", "te-IN"), |
| |
| ("+9180", "kn-IN"), ("+9182", "kn-IN"), |
| |
| ("+9122", "mr-IN"), ("+9120", "mr-IN"), ("+9121", "mr-IN"), |
| |
| ("+9179", "gu-IN"), ("+9126", "gu-IN"), ("+9127", "gu-IN"), |
| |
| ("+9133", "bn-IN"), ("+9134", "bn-IN"), |
| |
| ("+9117", "pa-IN"), ("+9118", "pa-IN"), |
| |
| ("+9147", "ml-IN"), ("+9148", "ml-IN"), |
| |
| ("+91", "hi-IN"), |
| ] |
|
|
|
|
| def _detect_language_from_phone(phone: str) -> str: |
| """Detect likely language from phone number prefix.""" |
| phone = re.sub(r"[\s\-\(\)]", "", phone) |
| if not phone.startswith("+"): |
| if phone.startswith("0"): |
| phone = "+91" + phone[1:] |
| elif len(phone) == 10: |
| phone = "+91" + phone |
| for prefix, lang in _PHONE_LANG_MAP: |
| if phone.startswith(prefix): |
| return lang |
| return "en-IN" |
|
|
|
|
| _LANG_CODE_TO_NAME = { |
| "hi-IN": "Hindi", "en-IN": "English", "ta-IN": "Tamil", |
| "te-IN": "Telugu", "kn-IN": "Kannada", "mr-IN": "Marathi", |
| "gu-IN": "Gujarati", "bn-IN": "Bengali", "pa-IN": "Punjabi", |
| "ml-IN": "Malayalam", |
| } |
|
|
|
|
| async def _create_sip_outbound_call( |
| agent_id: str, owner_id: str, to_number: str, from_number: str, call_id: str, |
| ) -> Dict[str, Any]: |
| """Create a LiveKit room with an agent, then dial out via SIP participant. |
| |
| This bridges the AI voice agent (in LiveKit) to a real phone call (via SIP). |
| """ |
| api_key, api_secret, livekit_url = _lk_creds() |
|
|
| room_name = f"agent_{agent_id}__user_{owner_id}__sip_{call_id}" |
| agent_dispatch_name = os.environ.get("LIVEKIT_AGENT_NAME", "scatterstudio-agent") |
|
|
| |
| detected_lang = _detect_language_from_phone(to_number) |
|
|
| |
| |
| crm_hint = "" |
| try: |
| import integrations as _int |
| crm = _int.execute_tool(owner_id, "lookup_crm_contact", {"phone": to_number}) |
| if (crm or {}).get("ok") and crm.get("contact"): |
| c = crm["contact"] |
| nm = (c.get("firstname") or c.get("First_Name") or "").strip() |
| co = (c.get("company") or c.get("Account_Name") or "").strip() |
| parts = [f"{nm}".strip(), co] |
| summary = " from ".join(p for p in parts if p) |
| if summary: |
| crm_hint = summary |
| except Exception as e: |
| logger.warning(f"Pre-call CRM lookup soft-fail: {e}") |
|
|
| metadata = json.dumps({ |
| "agent_id": agent_id, |
| "owner_id": owner_id, |
| "call_id": call_id, |
| "direction": "outbound", |
| "phone_number": to_number, |
| "detected_language": detected_lang, |
| "crm_hint": crm_hint, |
| }) |
|
|
| lkapi = livekit_api.LiveKitAPI(url=livekit_url, api_key=api_key, api_secret=api_secret) |
| try: |
| |
| await lkapi.agent_dispatch.create_dispatch( |
| livekit_api.CreateAgentDispatchRequest( |
| room=room_name, |
| agent_name=agent_dispatch_name, |
| metadata=metadata, |
| ) |
| ) |
|
|
| |
| |
| sip_trunk_id = os.environ.get("LIVEKIT_SIP_TRUNK_ID", "") |
|
|
| |
| phone_docs = list( |
| db.collection("phone_numbers") |
| .where(filter=FieldFilter("owner_id", "==", owner_id)) |
| .where(filter=FieldFilter("assigned_agent_id", "==", agent_id)) |
| .limit(1) |
| .stream() |
| ) |
| phone_data = phone_docs[0].to_dict() if phone_docs else None |
|
|
| if not sip_trunk_id and phone_data and phone_data.get("sip_trunk_id"): |
| sip_trunk_id = phone_data["sip_trunk_id"] |
|
|
| if not sip_trunk_id: |
| |
| sip_domain = "" |
| sip_user = "" |
| sip_pass = "" |
| if phone_data: |
| sip_domain = phone_data.get("sip_domain") or "" |
| sip_user = phone_data.get("sip_username") or "" |
| sip_pass = phone_data.get("sip_password") or "" |
| if not sip_domain: |
| sip_domain = os.environ.get("VOBIZ_SIP_DOMAIN", "") |
| if not sip_user: |
| sip_user = os.environ.get("VOBIZ_SIP_USERNAME", os.environ.get("VOBIZ_AUTH_ID", "")) |
| if not sip_pass: |
| sip_pass = os.environ.get("VOBIZ_SIP_PASSWORD", os.environ.get("VOBIZ_AUTH_SECRET", "")) |
|
|
| if not sip_domain: |
| return {"ok": False, "error": "No SIP trunk configured. Add SIP domain in Phone Numbers or set VOBIZ_SIP_DOMAIN."} |
|
|
| |
| trunk = await lkapi.sip.create_sip_outbound_trunk( |
| livekit_api.CreateSIPOutboundTrunkRequest( |
| trunk=livekit_api.SIPOutboundTrunkInfo( |
| name=f"scatter-outbound-{agent_id[:8]}", |
| address=sip_domain, |
| numbers=[from_number], |
| auth_username=sip_user, |
| auth_password=sip_pass, |
| ) |
| ) |
| ) |
| sip_trunk_id = trunk.sip_trunk_id |
|
|
| |
| if phone_data: |
| db.collection("phone_numbers").document(phone_data["id"]).update({ |
| "sip_trunk_id": sip_trunk_id |
| }) |
|
|
| |
| |
| |
| |
| |
| |
| |
| if to_number.startswith("sip:"): |
| sip_call_to = to_number |
| else: |
| sip_call_to = _to_e164(to_number) |
|
|
| participant = await lkapi.sip.create_sip_participant( |
| livekit_api.CreateSIPParticipantRequest( |
| sip_trunk_id=sip_trunk_id, |
| sip_call_to=sip_call_to, |
| room_name=room_name, |
| participant_identity=f"phone_{to_number}", |
| participant_name=f"Phone {to_number}", |
| participant_metadata=json.dumps({"phone": to_number, "call_id": call_id}), |
| wait_until_answered=False, |
| ) |
| ) |
|
|
| logger.info(f"SIP outbound started: room={room_name} trunk={sip_trunk_id} to={sip_call_to}") |
| return {"ok": True, "room_name": room_name, "sip_trunk_id": sip_trunk_id, "participant_id": participant.participant_identity} |
|
|
| except Exception as e: |
| logger.error(f"SIP outbound call failed: {type(e).__name__}: {e}") |
| return {"ok": False, "error": str(e)} |
| finally: |
| await lkapi.aclose() |
|
|
|
|
| |
|
|
| class SIPTrunkIn(BaseModel): |
| name: str = "" |
| sip_domain: str |
| sip_username: str = "" |
| sip_password: str = "" |
| numbers: List[str] = [] |
| direction: str = "outbound" |
| agent_id: Optional[str] = None |
|
|
|
|
| @api_router.post("/sip/trunks") |
| async def create_sip_trunk(payload: SIPTrunkIn, user: dict = Depends(get_current_user)): |
| """Create a LiveKit SIP trunk (inbound or outbound) and persist it.""" |
| api_key, api_secret, livekit_url = _lk_creds() |
| lkapi = livekit_api.LiveKitAPI(url=livekit_url, api_key=api_key, api_secret=api_secret) |
| agent_dispatch_name = os.environ.get("LIVEKIT_AGENT_NAME", "scatterstudio-agent") |
|
|
| try: |
| if payload.direction == "inbound": |
| trunk = await lkapi.sip.create_sip_inbound_trunk( |
| livekit_api.CreateSIPInboundTrunkRequest( |
| trunk=livekit_api.SIPInboundTrunkInfo( |
| name=payload.name or f"scatter-inbound-{user['id'][:8]}", |
| numbers=payload.numbers, |
| auth_username=payload.sip_username or "", |
| auth_password=payload.sip_password or "", |
| ) |
| ) |
| ) |
|
|
| |
| if payload.agent_id: |
| metadata = json.dumps({ |
| "agent_id": payload.agent_id, |
| "owner_id": user["id"], |
| "direction": "inbound", |
| }) |
| await lkapi.sip.create_sip_dispatch_rule( |
| livekit_api.CreateSIPDispatchRuleRequest( |
| trunk_ids=[trunk.sip_trunk_id], |
| rule=livekit_api.SIPDispatchRule( |
| dispatch_rule_individual=livekit_api.SIPDispatchRuleIndividual( |
| room_prefix=f"agent_{payload.agent_id}__user_{user['id']}__sip_", |
| pin="", |
| ) |
| ), |
| metadata=metadata, |
| ) |
| ) |
| else: |
| trunk = await lkapi.sip.create_sip_outbound_trunk( |
| livekit_api.CreateSIPOutboundTrunkRequest( |
| trunk=livekit_api.SIPOutboundTrunkInfo( |
| name=payload.name or f"scatter-outbound-{user['id'][:8]}", |
| address=payload.sip_domain, |
| numbers=payload.numbers, |
| auth_username=payload.sip_username or "", |
| auth_password=payload.sip_password or "", |
| ) |
| ) |
| ) |
|
|
| |
| trunk_data = { |
| "id": trunk.sip_trunk_id, |
| "name": payload.name or trunk.name, |
| "direction": payload.direction, |
| "sip_domain": payload.sip_domain, |
| "numbers": payload.numbers, |
| "agent_id": payload.agent_id, |
| "owner_id": user["id"], |
| "created_at": now_iso(), |
| } |
| db.collection("sip_trunks").document(trunk.sip_trunk_id).set(trunk_data) |
|
|
| return {"ok": True, "trunk_id": trunk.sip_trunk_id, **trunk_data} |
| except Exception as e: |
| logger.error(f"SIP trunk creation failed: {e}") |
| raise HTTPException(status_code=500, detail=f"SIP trunk creation failed: {e}") |
| finally: |
| await lkapi.aclose() |
|
|
|
|
| @api_router.get("/sip/trunks") |
| async def list_sip_trunks(user: dict = Depends(get_current_user)): |
| """List SIP trunks for the current user.""" |
| docs = db.collection("sip_trunks").where(filter=FieldFilter("owner_id", "==", user["id"])).stream() |
| return [doc.to_dict() for doc in docs] |
|
|
|
|
| @api_router.delete("/sip/trunks/{trunk_id}") |
| async def delete_sip_trunk(trunk_id: str, user: dict = Depends(get_current_user)): |
| """Delete a SIP trunk.""" |
| doc = db.collection("sip_trunks").document(trunk_id).get() |
| if not doc.exists or doc.to_dict().get("owner_id") != user["id"]: |
| raise HTTPException(status_code=404, detail="Trunk not found") |
| api_key, api_secret, livekit_url = _lk_creds() |
| lkapi = livekit_api.LiveKitAPI(url=livekit_url, api_key=api_key, api_secret=api_secret) |
| try: |
| await lkapi.sip.delete_sip_trunk( |
| livekit_api.DeleteSIPTrunkRequest(sip_trunk_id=trunk_id) |
| ) |
| except Exception as e: |
| logger.warning(f"LiveKit SIP trunk delete failed (may already be gone): {e}") |
| finally: |
| await lkapi.aclose() |
| db.collection("sip_trunks").document(trunk_id).delete() |
| return {"ok": True} |
|
|
|
|
| |
| @api_router.get("/agents", response_model=List[Agent]) |
| def list_agents(user: dict = Depends(get_current_user)): |
| cached = _cache_get(f"agents:{user['id']}") |
| if cached is not None: |
| return cached |
| docs = db.collection("agents").where(filter=FieldFilter("owner_id", "==", user["id"])).stream() |
| return _cache_set(f"agents:{user['id']}", [doc.to_dict() for doc in docs], ttl=20) |
|
|
|
|
| @api_router.post("/agents", response_model=Agent) |
| def create_agent(payload: AgentIn, user: dict = Depends(get_current_user)): |
| if not payload.name.strip(): |
| raise HTTPException(status_code=400, detail="Agent name is required") |
| aid = str(uuid.uuid4()) |
| doc_data = { |
| "id": aid, |
| **payload.model_dump(), |
| "owner_id": user["id"], |
| "created_at": now_iso(), |
| "updated_at": now_iso(), |
| "calls_handled": 0, |
| "avg_duration": 0.0, |
| "last_active": None, |
| } |
| db.collection("agents").document(aid).set(doc_data) |
| _cache_invalidate_user(user["id"], "agents", "analytics", "leaderboard") |
| return {k: v for k, v in doc_data.items() if k != "owner_id"} |
|
|
|
|
| @api_router.get("/agents/{agent_id}", response_model=Agent) |
| def get_agent(agent_id: str, user: dict = Depends(get_current_user)): |
| agent_doc = db.collection("agents").document(agent_id).get() |
| if not agent_doc.exists or agent_doc.to_dict().get("owner_id") != user["id"]: |
| raise HTTPException(status_code=404, detail="Agent not found") |
| return agent_doc.to_dict() |
|
|
|
|
| |
| class BuilderMsg(BaseModel): |
| messages: List[Dict[str, Any]] = [] |
|
|
|
|
| _BUILDER_SYSTEM = ( |
| "You are ScatterAI, an expert assistant that helps a user design a voice AI agent by chatting. " |
| "Ask brief, friendly questions to learn: the agent's PURPOSE (sales/support/reception/etc.), the " |
| "BUSINESS it represents, its NAME, TONE, LANGUAGE, and the GREETING it should open with. Keep replies " |
| "short and conversational — ONE or TWO questions at a time.\n\n" |
| "When you have ENOUGH to build a great agent (usually after 2-4 exchanges, or immediately if the user " |
| "gave a full description), output the final agent as a JSON object on its OWN line, wrapped EXACTLY like:\n" |
| "<AGENT>{...}</AGENT>\n" |
| "The JSON MUST have these keys: name, role (one of Sales|Support|Reception|Outbound|Survey), language " |
| "(one of Hinglish|Hindi|English|Tamil|Telugu|Kannada|Marathi|Gujarati|Bengali|Punjabi|Malayalam), " |
| "persona (a detailed, vivid system prompt describing who the agent is, the business, what it does, its " |
| "tone, and how it should behave — write this richly, 3-6 sentences), welcome_message (the exact first " |
| "line it speaks), voice_id (pick a Sarvam voice: shreya/simran/priya/neha for female, shubh/aditya/" |
| "manan/rahul for male). Before the <AGENT> block, write ONE friendly sentence like 'Here's your agent — " |
| "review and create it!'. Do NOT include the JSON if you still need info; ask a question instead." |
| ) |
|
|
|
|
| @api_router.post("/agents/build") |
| async def scatterai_build(payload: BuilderMsg, user: dict = Depends(get_current_user)): |
| """ScatterAI chat: the user describes the agent they want; NVIDIA NIM drives |
| the conversation and, when ready, emits a full agent config (<AGENT>{...}</AGENT>) |
| the frontend can one-click create. Falls back to Groq if NVIDIA is unset.""" |
| msgs = [{"role": "system", "content": _BUILDER_SYSTEM}] |
| for m in (payload.messages or [])[-12:]: |
| r = m.get("role") |
| if r in ("user", "assistant") and m.get("content"): |
| msgs.append({"role": r, "content": str(m["content"])[:4000]}) |
| if len(msgs) == 1: |
| msgs.append({"role": "user", "content": "Hi! Help me build a voice agent."}) |
|
|
| nvidia_key = os.environ.get("NVIDIA_API_KEY") |
| groq_key = os.environ.get("GROQ_API_KEY") |
| providers = [] |
| if nvidia_key: |
| providers.append(("https://integrate.api.nvidia.com/v1", nvidia_key, |
| os.environ.get("BUILDER_MODEL", "nvidia/nemotron-3-ultra-550b-a55b"), True)) |
| if groq_key: |
| providers.append(("https://api.groq.com/openai/v1", groq_key, |
| os.environ.get("BUILDER_GROQ_MODEL", "openai/gpt-oss-120b"), False)) |
| if not providers: |
| raise HTTPException(status_code=500, detail="No LLM configured (set NVIDIA_API_KEY or GROQ_API_KEY)") |
|
|
| reply = "" |
| for base_url, key, model, is_nv in providers: |
| try: |
| body = {"model": model, "messages": msgs, "temperature": 0.6, "max_tokens": 900} |
| if is_nv and model.startswith(("nvidia/nemotron", "z-ai/glm")): |
| body["chat_template_kwargs"] = {"enable_thinking": False} |
| async with httpx.AsyncClient(timeout=40.0) as client: |
| r = await client.post(f"{base_url}/chat/completions", |
| headers={"Authorization": f"Bearer {key}", "Content-Type": "application/json"}, |
| json=body) |
| r.raise_for_status() |
| reply = r.json()["choices"][0]["message"]["content"] |
| break |
| except Exception as e: |
| logger.warning(f"[ScatterAI] provider failed: {e}") |
| reply = "" |
|
|
| if not reply: |
| return {"reply": "Sorry, I couldn't reach the model right now. Please try again.", "agent": None} |
|
|
| |
| agent = None |
| m = re.search(r"<AGENT>\s*(\{.*?\})\s*</AGENT>", reply, re.DOTALL) |
| if m: |
| try: |
| raw = json.loads(m.group(1)) |
| |
| agent = { |
| "name": (raw.get("name") or "My Agent")[:100], |
| "role": raw.get("role") if raw.get("role") in ("Sales", "Support", "Reception", "Outbound", "Survey") else "Sales", |
| "language": raw.get("language") or "Hinglish", |
| "persona": raw.get("persona") or "You are a helpful voice agent.", |
| "welcome_message": raw.get("welcome_message") or "Hello! How can I help you today?", |
| "voice_provider": "Sarvam", |
| "voice_id": raw.get("voice_id") or "shreya", |
| "llm_provider": "Gemini", |
| |
| |
| "llm_model": "gemini-3.1-flash-live-preview", |
| } |
| except Exception as e: |
| logger.warning(f"[ScatterAI] agent JSON parse failed: {e}") |
| agent = None |
| |
| clean_reply = re.sub(r"<AGENT>.*?</AGENT>", "", reply, flags=re.DOTALL).strip() |
| return {"reply": clean_reply or "Here's your agent — review and create it!", "agent": agent} |
|
|
|
|
| @api_router.get("/agents/{agent_id}/sheet") |
| def get_agent_sheet(agent_id: str, user: dict = Depends(get_current_user)): |
| """Return the agent's auto-created Google Sheet CRM link + a preview of the |
| latest rows, so the user can view captured caller data from inside the app.""" |
| agent_doc = db.collection("agents").document(agent_id).get() |
| if not agent_doc.exists or agent_doc.to_dict().get("owner_id") != user["id"]: |
| raise HTTPException(status_code=404, detail="Agent not found") |
| sid = (agent_doc.to_dict() or {}).get("crm_spreadsheet_id") or "" |
| if not sid: |
| return {"connected": False, "sheet_url": None, "rows": [], |
| "message": "No CRM sheet yet — connect Google Sheets and take a call; the agent auto-creates one."} |
| url = f"https://docs.google.com/spreadsheets/d/{sid}/edit" |
| rows = [] |
| try: |
| import integrations as _int |
| res = _int.execute_tool(user["id"], "read_sheet_rows", {"spreadsheet_id": sid, "sheet_name": "CRM"}) |
| if (res or {}).get("ok"): |
| rows = (res.get("rows") or [])[:50] |
| except Exception as e: |
| logger.warning(f"[Sheet] preview read failed: {e}") |
| return {"connected": True, "sheet_url": url, "spreadsheet_id": sid, "rows": rows} |
|
|
|
|
| @api_router.put("/agents/{agent_id}", response_model=Agent) |
| def update_agent(agent_id: str, payload: AgentIn, user: dict = Depends(get_current_user)): |
| agent_ref = db.collection("agents").document(agent_id) |
| agent_doc = agent_ref.get() |
| if not agent_doc.exists or agent_doc.to_dict().get("owner_id") != user["id"]: |
| raise HTTPException(status_code=404, detail="Agent not found") |
| update_data = {**payload.model_dump(), "updated_at": now_iso()} |
| agent_ref.update(update_data) |
| _cache_invalidate_user(user["id"], "agents", "analytics", "leaderboard") |
| return agent_ref.get().to_dict() |
|
|
|
|
| @api_router.delete("/agents/{agent_id}") |
| def delete_agent(agent_id: str, user: dict = Depends(get_current_user)): |
| agent_ref = db.collection("agents").document(agent_id) |
| agent_doc = agent_ref.get() |
| if not agent_doc.exists or agent_doc.to_dict().get("owner_id") != user["id"]: |
| raise HTTPException(status_code=404, detail="Agent not found") |
| agent_ref.delete() |
| _cache_invalidate_user(user["id"], "agents", "analytics", "leaderboard") |
| return {"ok": True} |
|
|
|
|
| class TestMsgIn(BaseModel): |
| message: str |
|
|
|
|
| class TestCallIn(BaseModel): |
| number: str |
|
|
| @api_router.post("/agents/{agent_id}/test") |
| async def test_agent_simulator(agent_id: str, payload: TestMsgIn, user: dict = Depends(get_current_user)): |
| """Text-based simulator that drives the agent's real LLM (Groq) with its persona + KB.""" |
| agent_doc = db.collection("agents").document(agent_id).get() |
| if not agent_doc.exists or agent_doc.to_dict().get("owner_id") != user["id"]: |
| raise HTTPException(status_code=404, detail="Agent not found") |
| agent = agent_doc.to_dict() |
|
|
| |
| system_prompt = agent.get("persona") or "You are a helpful AI voice assistant." |
| kb_ids = agent.get("knowledge_base_ids") or [] |
| if kb_ids: |
| kb_context = _fetch_kb_context(user["id"], kb_ids, payload.message) |
| if kb_context: |
| system_prompt += f"\n\nUse the following knowledge base context to answer when relevant:\n{kb_context}" |
|
|
| started = datetime.now(timezone.utc) |
| reply = await _llm_chat( |
| provider=agent.get("llm_provider", "Groq"), |
| model=agent.get("llm_model") or "llama-3.3-70b-versatile", |
| system=system_prompt, |
| user_msg=payload.message, |
| language=agent.get("language", "Hinglish"), |
| ) |
| latency = int((datetime.now(timezone.utc) - started).total_seconds() * 1000) |
| return {"reply": reply, "latency_ms": latency} |
|
|
|
|
| _recent_dials: Dict[str, tuple] = {} |
|
|
|
|
| @api_router.post("/agents/{agent_id}/test-call") |
| async def test_agent_call(agent_id: str, payload: TestCallIn, user: dict = Depends(get_current_user)): |
| """Initiates an outbound SIP call via LiveKit to the provided number for testing the agent. |
| |
| Flow: Creates a LiveKit room → dispatches the AI agent → adds a SIP participant |
| that dials the phone number. The agent and phone user talk through the LiveKit room. |
| """ |
| agent_doc = db.collection("agents").document(agent_id).get() |
| if not agent_doc.exists or agent_doc.to_dict().get("owner_id") != user["id"]: |
| raise HTTPException(status_code=404, detail="Agent not found") |
|
|
| |
| |
| |
| dedupe_key = f"{user['id']}:{agent_id}:{(payload.number or '').strip()}" |
| hit = _recent_dials.get(dedupe_key) |
| if hit and hit[1] > monotonic(): |
| return {"ok": True, "call_id": hit[0], "deduped": True} |
|
|
| |
| phone_docs = list( |
| db.collection("phone_numbers") |
| .where(filter=FieldFilter("assigned_agent_id", "==", agent_id)) |
| .limit(1) |
| .stream() |
| ) |
| from_number = None |
| if phone_docs: |
| from_number = phone_docs[0].to_dict().get("number") |
| if not from_number: |
| from_number = os.environ.get("VOBIZ_FROM_NUMBER", "").strip() |
| if not from_number: |
| raise HTTPException(status_code=400, detail="No phone number assigned to agent and no VOBIZ_FROM_NUMBER configured.") |
|
|
| call_id = str(uuid.uuid4()) |
| |
| |
| _recent_dials[dedupe_key] = (call_id, monotonic() + 8) |
| |
| for k in [k for k, v in _recent_dials.items() if v[1] <= monotonic()]: |
| _recent_dials.pop(k, None) |
| call_data = { |
| "id": call_id, |
| "direction": "outbound", |
| "owner_id": user["id"], |
| "agent_id": agent_id, |
| "agent_name": agent_doc.to_dict().get("name", "Agent"), |
| "phone_number": payload.number, |
| "contact_name": "Test User", |
| "started_at": datetime.now(timezone.utc).isoformat(), |
| "status": "queued", |
| "sentiment": "neutral", |
| "lead_tag": "none", |
| "intent": "", |
| "summary": "", |
| "analysis": "", |
| "transcript": [], |
| "duration_seconds": 0, |
| "provider_call_id": None, |
| } |
| db.collection("calls").document(call_id).set(call_data) |
|
|
| res = await _create_sip_outbound_call( |
| agent_id=agent_id, |
| owner_id=user["id"], |
| to_number=payload.number, |
| from_number=from_number, |
| call_id=call_id, |
| ) |
|
|
| if not res.get("ok"): |
| db.collection("calls").document(call_id).update({"status": "failed", "summary": res.get("error")}) |
| raise HTTPException(status_code=500, detail=res.get("error", "SIP dial failed")) |
|
|
| db.collection("calls").document(call_id).update({ |
| "status": "ringing", |
| "room_name": res.get("room_name"), |
| }) |
| _cache_invalidate_user(user["id"], "calls") |
| return {"ok": True, "call_id": call_id} |
|
|
|
|
| async def _llm_chat(provider: str, model: str, system: str, user_msg: str, language: str) -> str: |
| """Call the configured LLM provider. Falls back gracefully on errors.""" |
| lang_hint = { |
| "Hindi": "Reply in natural Hindi (Devanagari or romanised).", |
| "Hinglish": "Reply in natural Hinglish (mix Hindi+English casually).", |
| "English": "Reply in clear, friendly English.", |
| }.get(language, "") |
| full_system = f"{system}\n\n{lang_hint}".strip() |
|
|
| provider = (provider or "Groq").lower() |
| try: |
| if provider == "groq" or not provider: |
| key = os.environ.get("GROQ_API_KEY") |
| if not key: |
| return "(LLM not configured — set GROQ_API_KEY)" |
| if not model or "llama-3.3-70b" in model: |
| model = "llama-3.3-70b-versatile" |
| elif "llama-3.1-8b" in model: |
| model = "llama-3.1-8b-instant" |
| async with httpx.AsyncClient(timeout=30.0) as client: |
| r = await client.post( |
| "https://api.groq.com/openai/v1/chat/completions", |
| headers={"Authorization": f"Bearer {key}", "Content-Type": "application/json"}, |
| json={ |
| "model": model, |
| "messages": [ |
| {"role": "system", "content": full_system}, |
| {"role": "user", "content": user_msg}, |
| ], |
| "temperature": 0.6, |
| "max_tokens": 300, |
| }, |
| ) |
| r.raise_for_status() |
| return r.json()["choices"][0]["message"]["content"].strip() |
| if provider == "gemini": |
| key = os.environ.get("GEMINI_API_KEY") |
| if not key: |
| return "(LLM not configured — set GEMINI_API_KEY)" |
| model_id = model or "gemini-2.5-flash" |
| async with httpx.AsyncClient(timeout=30.0) as client: |
| r = await client.post( |
| f"https://generativelanguage.googleapis.com/v1beta/models/{model_id}:generateContent?key={key}", |
| json={ |
| "system_instruction": {"parts": [{"text": full_system}]}, |
| "contents": [{"parts": [{"text": user_msg}]}], |
| }, |
| ) |
| r.raise_for_status() |
| cand = r.json().get("candidates", []) |
| return cand[0]["content"]["parts"][0]["text"].strip() if cand else "" |
| if provider in ("nvidia nim", "nvidia"): |
| key = os.environ.get("NVIDIA_API_KEY") |
| if not key: |
| return "(LLM not configured — set NVIDIA_API_KEY)" |
| async with httpx.AsyncClient(timeout=30.0) as client: |
| r = await client.post( |
| "https://integrate.api.nvidia.com/v1/chat/completions", |
| headers={"Authorization": f"Bearer {key}", "Content-Type": "application/json"}, |
| json={ |
| "model": model or "meta/llama-3.1-70b-instruct", |
| "messages": [ |
| {"role": "system", "content": full_system}, |
| {"role": "user", "content": user_msg}, |
| ], |
| "max_tokens": 300, |
| }, |
| ) |
| r.raise_for_status() |
| return r.json()["choices"][0]["message"]["content"].strip() |
| if provider in ("azure openai", "azure"): |
| endpoint = os.environ.get("AZURE_OPENAI_ENDPOINT", "").rstrip("/") |
| key = os.environ.get("AZURE_OPENAI_API_KEY", "") |
| deployment = model or os.environ.get("AZURE_OPENAI_DEPLOYMENT", "gpt-4o") |
| api_version = os.environ.get("AZURE_OPENAI_API_VERSION", "2024-12-01-preview") |
| if not (endpoint and key): |
| return "(LLM not configured — set AZURE_OPENAI_ENDPOINT and AZURE_OPENAI_API_KEY)" |
| async with httpx.AsyncClient(timeout=30.0) as client: |
| r = await client.post( |
| f"{endpoint}/openai/deployments/{deployment}/chat/completions?api-version={api_version}", |
| headers={"api-key": key, "Content-Type": "application/json"}, |
| json={ |
| "messages": [ |
| {"role": "system", "content": full_system}, |
| {"role": "user", "content": user_msg}, |
| ], |
| "temperature": 0.6, |
| "max_tokens": 300, |
| }, |
| ) |
| r.raise_for_status() |
| return r.json()["choices"][0]["message"]["content"].strip() |
| except httpx.HTTPStatusError as e: |
| logger.error(f"LLM HTTP error: {e.response.status_code} {e.response.text[:200]}") |
| except Exception as e: |
| logger.error(f"LLM error: {type(e).__name__}: {e}") |
| return "Sorry, I'm having trouble reaching the model right now. Please try again." |
|
|
|
|
| def _get_embedding(text: str, input_type: str = "passage") -> List[float]: |
| """Generate embedding vector using NVIDIA NIM.""" |
| nvidia_key = os.getenv("NVIDIA_API_KEY") |
| if not nvidia_key: |
| logger.warning("NVIDIA_API_KEY not set. Cannot generate embeddings.") |
| return [] |
| |
| try: |
| client = OpenAI( |
| api_key=nvidia_key, |
| base_url="https://integrate.api.nvidia.com/v1" |
| ) |
| response = client.embeddings.create( |
| input=[text], |
| model="nvidia/llama-nemotron-embed-1b-v2", |
| encoding_format="float", |
| extra_body={"input_type": input_type, "truncate": "NONE"} |
| ) |
| return response.data[0].embedding |
| except Exception as e: |
| logger.error(f"Failed to generate embedding: {e}") |
| return [] |
|
|
| def _cosine_similarity(vec1: List[float], vec2: List[float]) -> float: |
| if not vec1 or not vec2: |
| return 0.0 |
| v1 = np.array(vec1) |
| v2 = np.array(vec2) |
| norm1 = np.linalg.norm(v1) |
| norm2 = np.linalg.norm(v2) |
| if norm1 == 0 or norm2 == 0: |
| return 0.0 |
| return float(np.dot(v1, v2) / (norm1 * norm2)) |
|
|
|
|
| def _fetch_kb_context(owner_id: str, kb_ids: List[str], query: str, max_chunks: int = 4) -> str: |
| """Vector search retrieval using NVIDIA NIM embeddings.""" |
| if not query.strip() or not kb_ids: |
| return "" |
| |
| query_vector = _get_embedding(query, input_type="query") |
| if not query_vector: |
| |
| query_terms = {w.lower() for w in re.findall(r"\w+", query) if len(w) > 2} |
| if not query_terms: |
| return "" |
| chunks: List[Dict[str, Any]] = [] |
| for kid in kb_ids: |
| kdoc = db.collection("knowledge").document(kid).get() |
| if not kdoc.exists or kdoc.to_dict().get("owner_id") != owner_id: |
| continue |
| for sub in db.collection("knowledge").document(kid).collection("chunks").stream(): |
| chunks.append(sub.to_dict()) |
| scored = [] |
| for c in chunks: |
| text = (c.get("text") or "").lower() |
| score = sum(1 for t in query_terms if t in text) |
| if score > 0: |
| scored.append((score, c.get("text", ""))) |
| scored.sort(key=lambda x: x[0], reverse=True) |
| top = [s[1] for s in scored[:max_chunks]] |
| return "\n\n".join(top) |
|
|
| chunks: List[Dict[str, Any]] = [] |
| for kid in kb_ids: |
| kdoc = db.collection("knowledge").document(kid).get() |
| if not kdoc.exists or kdoc.to_dict().get("owner_id") != owner_id: |
| continue |
| for sub in db.collection("knowledge").document(kid).collection("chunks").stream(): |
| chunks.append(sub.to_dict()) |
| |
| scored = [] |
| for c in chunks: |
| text = c.get("text", "") |
| vec = c.get("vector") |
| if vec and isinstance(vec, list): |
| score = _cosine_similarity(query_vector, vec) |
| else: |
| |
| score = 0.0 |
| scored.append((score, text)) |
| |
| scored.sort(key=lambda x: x[0], reverse=True) |
| top = [s[1] for s in scored[:max_chunks] if s[0] > 0.1] |
| return "\n\n".join(top) |
|
|
|
|
| |
| @api_router.get("/campaigns", response_model=List[Campaign]) |
| def list_campaigns(user: dict = Depends(get_current_user)): |
| cached = _cache_get(f"campaigns:{user['id']}") |
| if cached is not None: |
| return cached |
| docs = db.collection("campaigns").where(filter=FieldFilter("owner_id", "==", user["id"])).stream() |
| return _cache_set(f"campaigns:{user['id']}", [doc.to_dict() for doc in docs], ttl=15) |
|
|
|
|
| @api_router.post("/campaigns", response_model=Campaign) |
| def create_campaign(payload: CampaignIn, user: dict = Depends(get_current_user)): |
| if not payload.name.strip(): |
| raise HTTPException(status_code=400, detail="Campaign name is required") |
| cid = str(uuid.uuid4()) |
| total = len(payload.contacts) |
| doc_data = { |
| "id": cid, |
| **payload.model_dump(), |
| "owner_id": user["id"], |
| "status": "draft", |
| "created_at": now_iso(), |
| "progress": {"total": total, "dialed": 0, "connected": 0, "completed": 0, "failed": 0, "leads": 0}, |
| } |
| db.collection("campaigns").document(cid).set(doc_data) |
| _cache_invalidate_user(user["id"], "campaigns") |
| return {k: v for k, v in doc_data.items() if k != "owner_id"} |
|
|
|
|
| @api_router.post("/campaigns/upload-csv") |
| async def campaign_upload_csv(file: UploadFile = File(...), user: dict = Depends(get_current_user)): |
| if not file.filename.lower().endswith(".csv"): |
| raise HTTPException(status_code=400, detail="Only .csv files are supported") |
| content = (await file.read()).decode("utf-8", errors="ignore") |
| reader = csv.DictReader(io.StringIO(content)) |
| contacts = [] |
| if reader.fieldnames and any(("phone" in (h or "").lower()) for h in reader.fieldnames): |
| for row in reader: |
| phone = row.get("phone") or row.get("Phone") or row.get("number") or row.get("Number") or "" |
| name = row.get("name") or row.get("Name") or "Unknown" |
| if phone.strip(): |
| contacts.append({"name": name.strip(), "phone": phone.strip()}) |
| else: |
| |
| for line in content.splitlines(): |
| parts = [p.strip() for p in line.split(",")] |
| if len(parts) >= 2 and parts[1]: |
| contacts.append({"name": parts[0] or "Unknown", "phone": parts[1]}) |
| return {"contacts": contacts, "count": len(contacts)} |
|
|
|
|
| @api_router.post("/campaigns/{campaign_id}/start", response_model=Campaign) |
| def start_campaign(campaign_id: str, background: BackgroundTasks, user: dict = Depends(get_current_user)): |
| camp_ref = db.collection("campaigns").document(campaign_id) |
| camp_doc = camp_ref.get() |
| if not camp_doc.exists or camp_doc.to_dict().get("owner_id") != user["id"]: |
| raise HTTPException(status_code=404, detail="Campaign not found") |
| data = camp_doc.to_dict() |
| if data.get("status") == "running": |
| raise HTTPException(status_code=400, detail="Campaign already running") |
|
|
| camp_ref.update({"status": "running"}) |
| _cache_invalidate_user(user["id"], "campaigns") |
| background.add_task(_run_campaign_loop, campaign_id, user["id"]) |
| return camp_ref.get().to_dict() |
|
|
|
|
| @api_router.post("/campaigns/{campaign_id}/pause", response_model=Campaign) |
| def pause_campaign(campaign_id: str, user: dict = Depends(get_current_user)): |
| camp_ref = db.collection("campaigns").document(campaign_id) |
| camp_doc = camp_ref.get() |
| if not camp_doc.exists or camp_doc.to_dict().get("owner_id") != user["id"]: |
| raise HTTPException(status_code=404, detail="Campaign not found") |
| camp_ref.update({"status": "paused"}) |
| _cache_invalidate_user(user["id"], "campaigns") |
| return camp_ref.get().to_dict() |
|
|
|
|
| @api_router.delete("/campaigns/{campaign_id}") |
| def delete_campaign(campaign_id: str, user: dict = Depends(get_current_user)): |
| camp_ref = db.collection("campaigns").document(campaign_id) |
| camp_doc = camp_ref.get() |
| if not camp_doc.exists or camp_doc.to_dict().get("owner_id") != user["id"]: |
| raise HTTPException(status_code=404, detail="Campaign not found") |
| camp_ref.delete() |
| _cache_invalidate_user(user["id"], "campaigns") |
| return {"ok": True} |
|
|
|
|
| async def _run_campaign_loop(campaign_id: str, owner_id: str): |
| """Background dialing loop: iterates contacts, dials via Vobiz, creates call records, |
| respects pacing_per_minute, stops when campaign is paused/deleted.""" |
| try: |
| camp_ref = db.collection("campaigns").document(campaign_id) |
| snap = camp_ref.get() |
| if not snap.exists: |
| return |
| camp = snap.to_dict() |
| agent_doc = db.collection("agents").document(camp["agent_id"]).get() |
| agent = agent_doc.to_dict() if agent_doc.exists else {"name": "Agent", "id": camp["agent_id"]} |
| contacts = camp.get("contacts", []) |
| pacing = max(1, int(camp.get("pacing_per_minute", 10))) |
| interval = 60.0 / pacing |
|
|
| progress = dict(camp.get("progress") or {}) |
| for contact in contacts: |
| |
| cur = camp_ref.get() |
| if not cur.exists: |
| return |
| if cur.to_dict().get("status") != "running": |
| logger.info(f"Campaign {campaign_id} stopped ({cur.to_dict().get('status')})") |
| return |
|
|
| phone = (contact.get("phone") or "").strip() |
| name = (contact.get("name") or "Unknown").strip() |
| if not phone: |
| continue |
|
|
| |
| call_id = str(uuid.uuid4()) |
| db.collection("calls").document(call_id).set({ |
| "id": call_id, |
| "agent_id": agent.get("id"), |
| "agent_name": agent.get("name", "Agent"), |
| "direction": "outbound", |
| "phone_number": phone, |
| "contact_name": name, |
| "duration_seconds": 0, |
| "status": "queued", |
| "sentiment": "neutral", |
| "lead_tag": "none", |
| "intent": "", |
| "summary": "", |
| "transcript": [], |
| "started_at": now_iso(), |
| "campaign_id": campaign_id, |
| "owner_id": owner_id, |
| "provider_call_id": None, |
| "recording_url": None, |
| }) |
| _cache_invalidate_user(owner_id, "calls", "analytics", "leaderboard") |
|
|
| from_number = agent.get("phone_number") or os.environ.get("VOBIZ_FROM_NUMBER", "") |
|
|
| result = await _create_sip_outbound_call( |
| agent_id=agent.get("id", ""), |
| owner_id=owner_id, |
| to_number=phone, |
| from_number=from_number, |
| call_id=call_id, |
| ) |
|
|
| if result.get("ok"): |
| db.collection("calls").document(call_id).update({ |
| "status": "dialing", |
| "room_name": result.get("room_name"), |
| }) |
| progress["dialed"] = progress.get("dialed", 0) + 1 |
| else: |
| db.collection("calls").document(call_id).update({ |
| "status": "failed", |
| "summary": result.get("error", "Dial failed"), |
| }) |
| progress["failed"] = progress.get("failed", 0) + 1 |
| _cache_invalidate_user(owner_id, "calls", "analytics", "leaderboard") |
|
|
| camp_ref.update({"progress": progress}) |
| _cache_invalidate_user(owner_id, "campaigns") |
| await asyncio.sleep(interval) |
|
|
| camp_ref.update({"status": "completed"}) |
| _cache_invalidate_user(owner_id, "campaigns") |
| except Exception as e: |
| logger.exception(f"Campaign loop error: {e}") |
| try: |
| db.collection("campaigns").document(campaign_id).update({"status": "failed"}) |
| except Exception: |
| pass |
|
|
|
|
| async def _dial_via_vobiz(to_number: str, from_number: str, call_id: str) -> Dict[str, Any]: |
| """Place an outbound call via Vobiz REST API. |
| |
| Env vars used: |
| VOBIZ_AUTH_ID — API auth user ID |
| VOBIZ_AUTH_SECRET — API auth secret |
| VOBIZ_SIP_DOMAIN — API host, e.g. api.vobiz.in |
| VOBIZ_FROM_NUMBER — Caller-ID / DID number (fallback) |
| VOBIZ_CALLBACK_URL — Public base URL for status webhook (optional) |
| Returns: {ok, call_uuid} or {ok: False, error} |
| """ |
| auth_id = os.environ.get("VOBIZ_AUTH_ID", "").strip() |
| auth_secret = os.environ.get("VOBIZ_AUTH_SECRET", "").strip() |
| sip_domain = os.environ.get("VOBIZ_SIP_DOMAIN", "").strip().rstrip("/") |
| callback_base = os.environ.get("VOBIZ_CALLBACK_URL", "").strip().rstrip("/") |
|
|
| if not (auth_id and auth_secret and sip_domain): |
| logger.warning("Vobiz credentials not configured (VOBIZ_AUTH_ID/SECRET/SIP_DOMAIN)") |
| return {"ok": False, "error": "Vobiz not configured"} |
|
|
| if not from_number: |
| return {"ok": False, "error": "Caller number not set (add phone number to agent or set VOBIZ_FROM_NUMBER)"} |
|
|
| |
| url = f"https://{sip_domain}/api/v1/call/outbound/" |
| body: Dict[str, Any] = { |
| "from": from_number, |
| "to": to_number, |
| "caller_id": from_number, |
| "custom_data": call_id, |
| } |
| if callback_base: |
| body["answer_url"] = f"{callback_base}/api/webhooks/vobiz" |
| body["hangup_url"] = f"{callback_base}/api/webhooks/vobiz" |
|
|
| try: |
| async with httpx.AsyncClient(timeout=20.0) as client: |
| r = await client.post( |
| url, |
| json=body, |
| auth=(auth_id, auth_secret), |
| headers={"Content-Type": "application/json"}, |
| ) |
| if r.status_code >= 400: |
| logger.error(f"Vobiz dial {r.status_code}: {r.text[:300]}") |
| return {"ok": False, "error": f"Vobiz API {r.status_code}: {r.text[:200]}"} |
| data = r.json() |
| |
| call_uuid = ( |
| data.get("call_uuid") |
| or data.get("api_id") |
| or data.get("id") |
| or data.get("uuid") |
| ) |
| logger.info(f"Vobiz dial ok: to={to_number} uuid={call_uuid}") |
| return {"ok": True, "call_uuid": call_uuid} |
| except Exception as e: |
| logger.error(f"Vobiz dial exception: {type(e).__name__}: {e}") |
| return {"ok": False, "error": str(e)} |
|
|
|
|
| |
| @api_router.post("/webhooks/vobiz") |
| async def vobiz_webhook(request: Request): |
| """Receives call-status events from Vobiz. |
| |
| Vobiz sends JSON with these fields (varies by API version): |
| call_uuid / api_id — provider call identifier |
| custom_data — our call_id (set at dial time) |
| event / status — e.g. answered, completed, failed, no-answer, busy |
| duration / bill_duration — call duration in seconds |
| recording_url — URL of the recording (if enabled) |
| transcript — transcript text (if enabled) |
| """ |
| try: |
| payload = await request.json() |
| except Exception: |
| form = await request.form() |
| payload = dict(form) |
|
|
| logger.info(f"Vobiz webhook: {json.dumps(payload, default=str)[:500]}") |
|
|
| |
| provider_call_id = ( |
| payload.get("call_uuid") |
| or payload.get("api_id") |
| or payload.get("uuid") |
| or payload.get("call_id") |
| ) |
| |
| our_call_id = payload.get("custom_data") or payload.get("custom_field") |
|
|
| if not (provider_call_id or our_call_id): |
| return {"status": "ignored", "reason": "no identifiable call_id"} |
|
|
| |
| doc_ref = None |
| if our_call_id: |
| cand = db.collection("calls").document(our_call_id).get() |
| if cand.exists: |
| doc_ref = cand.reference |
| if not doc_ref and provider_call_id: |
| found = list( |
| db.collection("calls") |
| .where(filter=FieldFilter("provider_call_id", "==", provider_call_id)) |
| .limit(1) |
| .stream() |
| ) |
| if found: |
| doc_ref = found[0].reference |
| if not doc_ref: |
| logger.warning(f"Vobiz webhook — no matching call: uuid={provider_call_id} custom={our_call_id}") |
| return {"status": "no_match"} |
|
|
| update: Dict[str, Any] = {"updated_at": now_iso()} |
|
|
| |
| if provider_call_id: |
| update["provider_call_id"] = provider_call_id |
|
|
| |
| raw_status = ( |
| payload.get("event") |
| or payload.get("status") |
| or payload.get("call_status") |
| or "" |
| ).lower() |
| STATUS_MAP = { |
| "completed": "completed", |
| "answered": "completed", |
| "hangup": "completed", |
| "no-answer": "missed", |
| "noanswer": "missed", |
| "missed": "missed", |
| "busy": "failed", |
| "failed": "failed", |
| "rejected": "failed", |
| "canceled": "failed", |
| "cancelled": "failed", |
| } |
| if raw_status: |
| update["status"] = STATUS_MAP.get(raw_status, raw_status) |
|
|
| |
| for dur_key in ("bill_duration", "duration", "conversation_duration", "call_duration"): |
| raw_dur = payload.get(dur_key) |
| if raw_dur is not None: |
| try: |
| update["duration_seconds"] = int(float(raw_dur)) |
| except Exception: |
| pass |
| break |
|
|
| |
| for rec_key in ("recording_url", "RecordingUrl", "record_url"): |
| if payload.get(rec_key): |
| update["recording_url"] = payload[rec_key] |
| break |
|
|
| |
| for tr_key in ("transcript", "transcription", "transcription_text"): |
| if payload.get(tr_key): |
| update["transcript_text"] = payload[tr_key] |
| break |
|
|
| existing_call = doc_ref.get().to_dict() or {} |
| owner_id = existing_call.get("owner_id") |
| doc_ref.update(update) |
|
|
| |
| is_final = update.get("status") in ("completed", "missed", "failed") |
| transcript_text = update.get("transcript_text") |
|
|
| |
| if is_final and not transcript_text and existing_call.get("transcript"): |
| try: |
| turns = existing_call["transcript"] |
| if isinstance(turns, list): |
| transcript_text = "\n".join(f"{t.get('speaker', 'unknown')}: {t.get('text', '')}" for t in turns if isinstance(t, dict)) |
| except Exception as te: |
| logger.warning(f"Failed to rebuild transcript text from log: {te}") |
|
|
| if is_final and transcript_text and transcript_text.strip(): |
| try: |
| insights = await _analyze_transcript(transcript_text) |
| if insights: |
| lead = insights.get("lead") or {} |
| known_phone = (existing_call.get("phone_number") or "").strip() |
| lead_phone = (known_phone or str(lead.get("phone") or "")).strip().lstrip("+") |
| |
| analysis_updates = { |
| "sentiment": insights.get("sentiment", "neutral"), |
| "lead_tag": insights.get("lead_tag", "none"), |
| "intent": insights.get("intent", ""), |
| "summary": insights.get("summary", ""), |
| "analysis": insights.get("analysis", ""), |
| "outcome": insights.get("outcome", ""), |
| "topics": insights.get("topics", []), |
| "lead_name": lead.get("name", ""), |
| "lead_email": lead.get("email", ""), |
| "lead_company": lead.get("company", ""), |
| "lead_phone": lead_phone, |
| "lead_score": lead.get("score", 0), |
| "requested_materials": insights.get("requested_materials", []), |
| "action_items": insights.get("action_items", []), |
| } |
| update.update(analysis_updates) |
| doc_ref.update(update) |
| |
| |
| agent_id = existing_call.get("agent_id") or "" |
| agent_name = existing_call.get("agent_name") or "Agent" |
| log_data = { |
| "id": existing_call.get("id") or our_call_id or doc_ref.id, |
| "lead_phone": lead_phone, |
| "summary": insights.get("summary", ""), |
| "sentiment": insights.get("sentiment", "neutral"), |
| } |
| |
| _upsert_lead( |
| owner_id=owner_id, |
| agent_id=agent_id, |
| agent_name=agent_name, |
| log_data=log_data, |
| insights=insights, |
| source="voice_sip" |
| ) |
| except Exception as e: |
| logger.error(f"Transcript analysis failed: {e}") |
|
|
| if owner_id: |
| _cache_invalidate_user(owner_id, "calls", "analytics", "leaderboard") |
| return {"status": "ok"} |
|
|
|
|
| async def _analyze_transcript(text: str) -> Dict[str, Any]: |
| """Extract structured post-call intelligence: sentiment, lead fields, |
| follow-up, requested materials, action items. |
| |
| Uses Groq FIRST (fast, reliable); NVIDIA NIM (z-ai/glm-5.2, thinking OFF) as |
| fallback because integrate.api.nvidia.com throws intermittent 503s under load. |
| Both use JSON-mode + a low temperature.""" |
| if not text.strip(): |
| return {} |
| system = ( |
| "You are an expert call analyst. Analyze the transcript and return a STRICT JSON object with fields:\n" |
| '- sentiment: "positive" | "neutral" | "negative" (the CUSTOMER\'s attitude; when unsure use "neutral").\n' |
| '- lead_tag: "hot" | "warm" | "cold" | "none".\n' |
| "- intent: short string, the customer's primary goal.\n" |
| "- outcome: short string, the final resolution.\n" |
| "- summary: a 2-3 sentence summary.\n" |
| "- analysis: a detailed paragraph (mood, pain points, next steps).\n" |
| "- topics: array of up to 5 short topic strings.\n" |
| '- lead: {"name","email","company","phone","score" (0-10)} — "" for anything not mentioned; ' |
| "never invent email/phone digits (take them verbatim from what the caller said).\n" |
| '- follow_up: {"datetime": ISO8601 or "", "topic": ""}.\n' |
| '- requested_materials: array (e.g. "case_study","pricing","brochure","demo") or [].\n' |
| "- action_items: array of strings or [].\n" |
| ) |
| messages = [{"role": "system", "content": system}, |
| {"role": "user", "content": f"Transcript:\n{text[:10000]}"}] |
|
|
| |
| nvidia_key = os.environ.get("NVIDIA_API_KEY") |
| groq_key = os.environ.get("GROQ_API_KEY") |
| nim_model = os.environ.get("NIM_ANALYTICS_MODEL", "z-ai/glm-5.2") |
| groq_model = os.environ.get("ANALYTICS_MODEL", "llama-3.3-70b-versatile") |
| providers = [] |
| if nvidia_key: |
| providers.append(("NVIDIA", "https://integrate.api.nvidia.com/v1", nvidia_key, nim_model, True)) |
| if groq_key: |
| providers.append(("Groq", "https://api.groq.com/openai/v1", groq_key, groq_model, False)) |
|
|
| for label, base_url, api_key, model, is_nvidia in providers: |
| try: |
| body = {"model": model, "messages": messages, |
| "response_format": {"type": "json_object"}, "temperature": 0.1, |
| "max_tokens": 1024} |
| |
| |
| if is_nvidia and model.startswith(("nvidia/nemotron", "z-ai/glm")): |
| body["chat_template_kwargs"] = {"enable_thinking": False} |
| async with httpx.AsyncClient(timeout=30.0) as client: |
| r = await client.post( |
| f"{base_url}/chat/completions", |
| headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}, |
| json=body) |
| r.raise_for_status() |
| content = r.json()["choices"][0]["message"]["content"] |
| content = re.sub(r"<think>.*?</think>", "", content, flags=re.DOTALL).strip() |
| m = re.search(r"\{[\s\S]*\}", content) |
| parsed = json.loads(m.group(0) if m else content) |
| logger.info(f"[Analytics] webhook transcript analyzed via {label}") |
| lead = parsed.get("lead") if isinstance(parsed.get("lead"), dict) else {} |
| score_raw = str(lead.get("score") or "0") |
| return { |
| "sentiment": (parsed.get("sentiment") or "neutral").lower(), |
| "lead_tag": (parsed.get("lead_tag") or "none").lower(), |
| "intent": parsed.get("intent", ""), |
| "outcome": parsed.get("outcome", ""), |
| "summary": parsed.get("summary", ""), |
| "analysis": parsed.get("analysis", ""), |
| "topics": (parsed.get("topics") or [])[:5], |
| "lead": { |
| "name": str(lead.get("name") or "")[:120], |
| "email": str(lead.get("email") or "")[:200], |
| "company": str(lead.get("company") or "")[:160], |
| "phone": str(lead.get("phone") or "")[:40], |
| "score": int(score_raw) if score_raw.isdigit() else 0, |
| }, |
| "follow_up": parsed.get("follow_up") if isinstance(parsed.get("follow_up"), dict) else {}, |
| "requested_materials": parsed.get("requested_materials") or [], |
| "action_items": parsed.get("action_items") or [], |
| } |
| except Exception as e: |
| logger.warning(f"[Analytics] {label} failed ({e}) — trying next") |
| logger.error("Analyze transcript: all providers errored") |
| return {} |
|
|
|
|
| def _upsert_lead(owner_id: str, agent_id: str, agent_name: str, log_data: dict, |
| insights: dict, source: str): |
| """Create or refresh a lead (deduped by phone) so the Leads view is populated.""" |
| if not db or not owner_id: |
| return |
| try: |
| lead = insights.get("lead") or {} |
| phone = (log_data.get("lead_phone") or lead.get("phone") or "").strip().lstrip("+") |
| score = int(lead.get("score") or 0) |
| status = "hot" if score >= 7 else ("warm" if score >= 4 else "new") |
| payload = { |
| "owner_id": owner_id, "agent_id": agent_id, "agent_name": agent_name, |
| "name": lead.get("name", ""), "email": lead.get("email", ""), |
| "company": lead.get("company", ""), "phone": phone, |
| "intent": insights.get("intent", ""), "message": log_data.get("summary", ""), |
| "source": source, "status": status, "sentiment": log_data.get("sentiment", "neutral"), |
| "score": score, "last_call_id": log_data.get("id", ""), |
| "updated_at": firestore.SERVER_TIMESTAMP, |
| } |
| existing = None |
| if phone: |
| try: |
| existing = list(db.collection("leads") |
| .where(filter=FieldFilter("owner_id", "==", owner_id)) |
| .where(filter=FieldFilter("phone", "==", phone)).limit(1).stream()) |
| except Exception as qe: |
| logger.warning(f"[Lead] dedup query failed ({qe}); creating new") |
| existing = None |
| if existing: |
| db.collection("leads").document(existing[0].id).set(payload, merge=True) |
| logger.info(f"[Lead] updated for {phone}") |
| elif phone or lead.get("name") or lead.get("email"): |
| lid = "lead_" + uuid.uuid4().hex[:20] |
| payload["id"] = lid |
| payload["created_at"] = firestore.SERVER_TIMESTAMP |
| db.collection("leads").document(lid).set(payload) |
| logger.info(f"[Lead] captured {lid} for {phone or '(no phone)'}") |
| except Exception as e: |
| logger.warning(f"[Lead] upsert failed: {e}") |
|
|
|
|
| def _save_sheet_crm(owner_id: str, agent_data: dict, log_data: dict, insights: dict) -> bool: |
| """Write/refresh the caller's row in the agent's Google Sheet CRM, keyed by |
| phone (the caller's unique id). Returns True if written.""" |
| try: |
| import integrations as _integrations |
| except ImportError: |
| _integrations = None |
| if _integrations is None or not owner_id: |
| return False |
| crm_sheet_id = (agent_data or {}).get("crm_spreadsheet_id") or "" |
| phone = (log_data.get("lead_phone") or log_data.get("phone_number") or "").strip() |
| if not (crm_sheet_id and phone): |
| return False |
| if not _integrations.is_connected(owner_id, "google_sheets"): |
| return False |
| if _integrations.is_connected_any(owner_id, ["hubspot", "zoho"]): |
| return False |
| phone = phone.lstrip("+") |
| try: |
| |
| prev = _integrations.execute_tool(owner_id, "read_sheet_rows", { |
| "spreadsheet_id": crm_sheet_id, "sheet_name": "CRM", |
| "search_column": "A", "search_value": phone}) |
| count = 1 |
| rows = (prev or {}).get("rows") or [] |
| old = rows[0] if rows else [] |
| if rows: |
| |
| |
| try: |
| count = int(old[7]) if len(old) > 7 and str(old[7]).isdigit() else 1 |
| except Exception: |
| count = 1 |
|
|
| def _keep(new_val, idx): |
| |
| new_val = (new_val or "").strip() if isinstance(new_val, str) else (new_val or "") |
| if new_val: |
| return new_val |
| return old[idx] if len(old) > idx and old[idx] else "" |
|
|
| |
| row = [phone, |
| _keep(log_data.get("lead_email", ""), 1), |
| _keep(log_data.get("lead_name", ""), 2), |
| _keep(log_data.get("lead_company", ""), 3), |
| _keep((insights.get("intent") or "")[:200], 4), |
| _keep((log_data.get("summary") or "")[:500], 5), |
| datetime.now().isoformat()[:19], |
| str(count), |
| _keep(log_data.get("lead_tag", ""), 8) or "active"] |
| res = _integrations.execute_tool(owner_id, "update_sheet_row", { |
| "spreadsheet_id": crm_sheet_id, "key_column": "A", "key_value": phone, |
| "values": row, "sheet_name": "CRM"}) |
| ok = bool((res or {}).get("ok")) |
| logger.info(f"[SheetCRM] {'saved' if ok else 'FAILED to save'} caller {phone} " |
| f"(call #{count})") |
| return ok |
| except Exception as e: |
| logger.warning(f"[SheetCRM] save failed: {e}") |
| return False |
|
|
|
|
| async def _process_async_analysis(call_id: str, call_data: dict): |
| logger.info(f"Starting async post-call analysis for call {call_id}") |
| |
| |
| turns = call_data.get("transcript") or [] |
| transcript_text = "" |
| if isinstance(turns, list) and turns: |
| transcript_text = "\n".join(f"{t.get('speaker', 'unknown')}: {t.get('text', '')}" for t in turns if isinstance(t, dict)) |
| elif call_data.get("transcript_text"): |
| transcript_text = call_data["transcript_text"] |
| |
| if not transcript_text.strip(): |
| logger.warning(f"No transcript found for call {call_id}, skipping analysis") |
| return |
| |
| |
| insights = {} |
| for attempt in range(1, 6): |
| try: |
| |
| insights = await asyncio.wait_for(_analyze_transcript(transcript_text), timeout=25.0) |
| if insights: |
| break |
| except Exception as e: |
| logger.warning(f"Analysis attempt {attempt} failed: {e}") |
| await asyncio.sleep(3 * attempt) |
| |
| if not insights: |
| logger.error(f"Analysis failed for call {call_id} after 5 attempts") |
| return |
| |
| |
| lead = insights.get("lead") or {} |
| known_phone = (call_data.get("phone_number") or "").strip() |
| lead_phone = (known_phone or str(lead.get("phone") or "")).strip().lstrip("+") |
| |
| analysis_updates = { |
| "sentiment": insights.get("sentiment", "neutral"), |
| "lead_tag": insights.get("lead_tag", "none"), |
| "intent": insights.get("intent", ""), |
| "summary": insights.get("summary", ""), |
| "analysis": insights.get("analysis", ""), |
| "outcome": insights.get("outcome", ""), |
| "topics": insights.get("topics", []), |
| "lead_name": lead.get("name", ""), |
| "lead_email": lead.get("email", ""), |
| "lead_company": lead.get("company", ""), |
| "lead_phone": lead_phone, |
| "lead_score": lead.get("score", 0), |
| "requested_materials": insights.get("requested_materials", []), |
| "action_items": insights.get("action_items", []), |
| } |
| |
| try: |
| db.collection("calls").document(call_id).update(analysis_updates) |
| logger.info(f"Firestore call log {call_id} updated with insights") |
| except Exception as fe: |
| logger.error(f"Failed to update call log {call_id} with insights: {fe}") |
| |
| |
| owner_id = call_data.get("owner_id") |
| agent_id = call_data.get("agent_id") |
| agent_name = call_data.get("agent_name") or "Agent" |
| log_data = { |
| "id": call_id, |
| "lead_phone": lead_phone, |
| "summary": insights.get("summary", ""), |
| "sentiment": insights.get("sentiment", "neutral"), |
| } |
| |
| try: |
| _upsert_lead( |
| owner_id=owner_id, |
| agent_id=agent_id, |
| agent_name=agent_name, |
| log_data=log_data, |
| insights=insights, |
| source=call_data.get("channel", "voice_web") |
| ) |
| except Exception as le: |
| logger.warning(f"Async Lead upsert failed: {le}") |
| |
| |
| agent_data = {} |
| try: |
| if agent_id: |
| agent_doc = db.collection("agents").document(agent_id).get() |
| if agent_doc.exists: |
| agent_data = agent_doc.to_dict() or {} |
| _save_sheet_crm(owner_id, agent_data, {**call_data, **log_data, **analysis_updates}, insights) |
| except Exception as se: |
| logger.warning(f"Async Sheet CRM save failed: {se}") |
|
|
| |
| |
| |
| |
| try: |
| actions = await asyncio.to_thread( |
| _post_call_automation, owner_id, agent_data, call_data, analysis_updates, insights) |
| if actions: |
| db.collection("calls").document(call_id).set({"actions": actions}, merge=True) |
| logger.info(f"[PostCall] {len(actions)} action(s) recorded for {call_id}") |
| except Exception as pe: |
| logger.warning(f"[PostCall] async automation failed: {pe}") |
|
|
|
|
| def _post_call_automation(owner_id, agent_data, call_data, updates, insights): |
| """Fire the follow-up calendar invite + email from the async analysis path. |
| Calendar first (so the email can carry the meeting + Meet link); email carries |
| ONLY the meeting details and/or requested materials — never a summary recap.""" |
| import integrations as _int |
| actions = [] |
| if not owner_id: |
| return actions |
| agent_integrations = (agent_data or {}).get("integrations") or {} |
|
|
| def _allows(key): |
| return agent_integrations.get(key) is not False |
|
|
| lead_name = (updates.get("lead_name") or "").strip() |
| lead_email = (updates.get("lead_email") or "").strip() |
| follow_up = insights.get("follow_up") or {} |
| materials = [m for m in (insights.get("requested_materials") or []) if m] |
|
|
| |
| meeting = None |
| fu_dt = (follow_up.get("datetime") or "").strip() |
| fu_topic = (follow_up.get("topic") or "Follow-up call").strip() |
| action_items = insights.get("action_items") or updates.get("action_items") or [] |
| topics = insights.get("topics") or updates.get("topics") or [] |
| summary = updates.get("summary") or "It was great speaking with you." |
|
|
| if not fu_dt or len(fu_dt) <= 8: |
| combined_text = " ".join([ |
| str(updates.get("analysis") or ""), |
| str(summary), |
| str(updates.get("intent") or ""), |
| " ".join(str(x) for x in action_items), |
| " ".join(str(x) for x in topics) |
| ]).lower() |
| if any(w in combined_text for w in ("tomorrow", "calendar", "meeting", "reschedule", "demo", "invite", "follow-up call", "schedule")): |
| next_day = datetime.now(timezone.utc) + timedelta(days=1) |
| fu_dt = next_day.replace(hour=5, minute=30, second=0, microsecond=0).isoformat() |
| if not fu_topic or fu_topic == "Follow-up call": |
| fu_topic = "Follow-up Meeting & Discovery" |
|
|
| if fu_dt and len(fu_dt) > 8 and _allows("google_calendar") and _int.is_connected(owner_id, "google_calendar"): |
| try: |
| start = datetime.fromisoformat(fu_dt.replace("Z", "+00:00")) |
| start, end, shifted = _int._find_clash_free_slot(owner_id, start, 30) |
| invite = {"title": fu_topic, "start": start.isoformat(), "end": end.isoformat(), |
| "description": "Follow-up scheduled from your call.", |
| "tz": "Asia/Kolkata", "create_meet_link": True} |
| if lead_email and "@" in lead_email: |
| invite["attendees"] = [lead_email] |
| res = _int.execute_tool(owner_id, "create_calendar_event", invite) |
| if (res or {}).get("ok"): |
| meeting = {"when": start.strftime("%A, %d %b %Y at %I:%M %p"), |
| "meet_link": res.get("meet_link") or res.get("html_link") or "", |
| "topic": fu_topic} |
| detail = f"{fu_topic} @ {start.strftime('%d %b %H:%M')}" + (" (auto-shifted)" if shifted else "") |
| actions.append({"type": "calendar", "status": "ok" if (res or {}).get("ok") else "failed", |
| "label": "Scheduled follow-up meeting", "detail": detail}) |
| logger.info(f"[PostCall] calendar: {detail} ok={bool((res or {}).get('ok'))}") |
| except Exception as e: |
| logger.warning(f"[PostCall] calendar failed: {e}") |
| elif not (fu_dt and len(fu_dt) > 8): |
| logger.info("[PostCall] calendar skipped — no follow-up datetime found in the call " |
| "(the caller must agree a concrete day/time for an invite)") |
|
|
| |
| if not materials and topics: |
| materials = [m for m in topics if m and str(m).lower() not in ("none", "general", "other")] |
| has_actionable = bool(meeting or materials or action_items or updates.get("analysis")) |
| if lead_email and "@" in lead_email and _allows("email") and has_actionable: |
| try: |
| first = lead_name.split(" ")[0] if lead_name else "there" |
| blocks = [] |
| if meeting: |
| link = meeting.get("meet_link") or "" |
| link_html = (f"<p style='margin:6px 0 0;'><a href='{link}' " |
| f"style='color:#e0533d;font-weight:600;'>Join the meeting →</a></p>") if link else "" |
| blocks.append( |
| f"<div style='margin:0 0 18px;padding:14px 16px;background:#fff5f3;" |
| f"border:1px solid #f7c9bf;border-radius:10px;'>" |
| f"<p style='margin:0;font-size:13px;text-transform:uppercase;letter-spacing:.05em;" |
| f"color:#c2410c;font-weight:700;'>Your meeting is booked</p>" |
| f"<p style='margin:6px 0 0;font-size:15px;'><strong>{meeting.get('topic', 'Follow-up')}</strong></p>" |
| f"<p style='margin:2px 0 0;font-size:15px;color:#333;'>{meeting['when']}</p>{link_html}</div>") |
| if materials: |
| li = "".join(f"<li style='margin:4px 0;'>{m.replace('_', ' ').title()}</li>" for m in materials[:6]) |
| blocks.append( |
| f"<p style='font-size:15px;font-weight:600;margin:8px 0;'>The information you asked for:</p>" |
| f"<ul style='margin:0 0 8px;padding-left:20px;color:#333;'>{li}</ul>") |
| if action_items or summary: |
| ai_li = "".join(f"<li style='margin:4px 0;'>{a}</li>" for a in action_items[:5]) |
| blocks.append( |
| f"<div style='margin:14px 0;padding:14px 16px;background:#f8fafc;border:1px solid #e2e8f0;border-radius:10px;'>" |
| f"<p style='margin:0 0 6px;font-size:13px;text-transform:uppercase;letter-spacing:.05em;color:#475569;font-weight:700;'>Call Summary & Next Steps</p>" |
| f"{f'<p style=margin:0 0 8px;font-size:14px;color:#333;>{summary}</p>' if summary else ''}" |
| f"{f'<ul style=margin:0;padding-left:20px;color:#333;font-size:14px;>{ai_li}</ul>' if ai_li else ''}</div>") |
| body = ( |
| "<!doctype html><html><body style='margin:0;background:#f6f7f9;'>" |
| "<div style='max-width:560px;margin:24px auto;padding:24px;background:#fff;" |
| "border:1px solid #e6e8eb;border-radius:12px;font-family:-apple-system,Segoe UI,Roboto,Arial,sans-serif;color:#1a1a1a;'>" |
| f"<p style='font-size:16px;margin:0 0 16px;'>Hi {first},</p>" |
| "<p style='font-size:15px;line-height:1.6;margin:0 0 16px;'>Great speaking with you! " |
| "As promised, here's what you needed:</p>" |
| + "".join(blocks) + |
| "<p style='font-size:15px;line-height:1.6;margin:16px 0 0;'>Just reply if you have any questions.</p>" |
| f"<p style='font-size:15px;margin:12px 0 0;color:#555;'>— {(agent_data or {}).get('name', 'Team')}</p>" |
| "</div></body></html>") |
| subject = ("Your meeting details" if meeting and not materials else |
| "The information you asked for" if materials and not meeting else |
| f"Follow-up from {(agent_data or {}).get('name', 'ScatterStudio')} call") |
| res = _int.execute_tool(owner_id, "send_email", |
| {"to": lead_email, "subject": subject, "body": body}) |
| ok = bool((res or {}).get("ok")) |
| bits = [] |
| if meeting: |
| bits.append("meeting invite") |
| if materials: |
| bits.append("requested materials") |
| if action_items: |
| bits.append("action items") |
| actions.append({"type": "email", "status": "ok" if ok else "failed", |
| "label": f"Emailed {lead_email}", |
| "detail": (res or {}).get("error", "") if not ok else (" + ".join(bits) or "summary")}) |
| logger.info(f"[PostCall] email to {lead_email}: ok={ok} ({' + '.join(bits)})") |
| except Exception as e: |
| logger.warning(f"[PostCall] email failed: {e}") |
| elif not lead_email: |
| logger.info("[PostCall] email skipped — no lead email captured") |
| elif not has_actionable: |
| logger.info(f"[PostCall] email skipped — no actionable items to deliver to {lead_email}") |
| return actions |
|
|
|
|
| @api_router.post("/calls/{call_id}/analyze") |
| async def analyze_call_endpoint(call_id: str, background_tasks: BackgroundTasks): |
| doc_ref = db.collection("calls").document(call_id) |
| doc = doc_ref.get() |
| if not doc.exists: |
| raise HTTPException(status_code=404, detail="Call not found") |
| |
| call_data = doc.to_dict() or {} |
| owner_id = call_data.get("owner_id") |
| if not owner_id: |
| raise HTTPException(status_code=400, detail="Owner ID missing in call log") |
| |
| background_tasks.add_task(_process_async_analysis, call_id, call_data) |
| return {"status": "queued"} |
|
|
|
|
| |
| |
| |
| |
| |
| |
|
|
| class WhisperIn(BaseModel): |
| text: str |
| mode: str = "coach" |
|
|
|
|
| @api_router.get("/calls/live") |
| def list_live_calls(user: dict = Depends(get_current_user)): |
| docs = (db.collection("calls") |
| .where(filter=FieldFilter("owner_id", "==", user["id"])) |
| .where(filter=FieldFilter("live", "==", True)) |
| .limit(50).stream()) |
| out = [] |
| stale_cutoff = datetime.now(timezone.utc) - timedelta(seconds=45) |
| for d in docs: |
| c = d.to_dict() or {} |
| |
| try: |
| hb = datetime.fromisoformat((c.get("live_updated_at") or "").replace("Z", "+00:00")) |
| if hb < stale_cutoff: |
| continue |
| except Exception: |
| continue |
| c.pop("transcript", None) |
| out.append(c) |
| out.sort(key=lambda x: x.get("started_at", ""), reverse=True) |
| return out |
|
|
|
|
| @api_router.post("/calls/{call_id}/whisper") |
| def whisper_to_call(call_id: str, payload: WhisperIn, user: dict = Depends(get_current_user)): |
| doc = db.collection("calls").document(call_id).get() |
| if not doc.exists or doc.to_dict().get("owner_id") != user["id"]: |
| raise HTTPException(status_code=404, detail="Call not found") |
| if not doc.to_dict().get("live"): |
| raise HTTPException(status_code=400, detail="Call is no longer live") |
| text = (payload.text or "").strip() |
| if not text: |
| raise HTTPException(status_code=400, detail="Whisper text is required") |
| wid = uuid.uuid4().hex |
| db.collection("calls").document(call_id).collection("whispers").document(wid).set({ |
| "id": wid, "text": text[:500], |
| "mode": payload.mode if payload.mode in ("coach", "say") else "coach", |
| "delivered": False, "created_at": now_iso(), |
| "by": user.get("name") or user["id"], |
| }) |
| return {"ok": True, "id": wid} |
|
|
|
|
| @api_router.get("/calls/{call_id}/listen-token") |
| def call_listen_token(call_id: str, user: dict = Depends(get_current_user)): |
| """Mint a HIDDEN, subscribe-only LiveKit token so the owner can silently |
| listen to a live call from the dashboard. The agent worker ignores |
| supervisor_* identities, so joining/leaving never disturbs the call.""" |
| doc = db.collection("calls").document(call_id).get() |
| if not doc.exists or doc.to_dict().get("owner_id") != user["id"]: |
| raise HTTPException(status_code=404, detail="Call not found") |
| c = doc.to_dict() |
| room_name = c.get("room_name") |
| if not room_name: |
| raise HTTPException(status_code=400, detail="This call has no live room") |
| api_key = os.environ.get("LIVEKIT_API_KEY") |
| api_secret = os.environ.get("LIVEKIT_API_SECRET") |
| livekit_url = os.environ.get("LIVEKIT_URL") |
| if not (api_key and api_secret and livekit_url): |
| raise HTTPException(status_code=500, detail="LiveKit not configured") |
| token = ( |
| livekit_api.AccessToken(api_key, api_secret) |
| .with_identity(f"supervisor_{user['id'][:12]}_{uuid.uuid4().hex[:6]}") |
| .with_name("Supervisor") |
| .with_grants(livekit_api.VideoGrants( |
| room_join=True, room=room_name, |
| can_publish=False, can_subscribe=True, hidden=True, |
| )) |
| ) |
| return {"token": token.to_jwt(), "url": livekit_url, "room": room_name} |
|
|
|
|
| |
| @api_router.get("/calls", response_model=List[CallLog]) |
| def list_calls( |
| user: dict = Depends(get_current_user), |
| agent_id: Optional[str] = None, |
| sentiment: Optional[str] = None, |
| lead_tag: Optional[str] = None, |
| search: Optional[str] = None, |
| ): |
| cache_key = f"calls:{user['id']}:{agent_id or ''}:{sentiment or ''}:{lead_tag or ''}:{search or ''}" |
| cached = _cache_get(cache_key) |
| if cached is not None: |
| return cached |
| query = db.collection("calls").where(filter=FieldFilter("owner_id", "==", user["id"])) |
| if agent_id: |
| query = query.where(filter=FieldFilter("agent_id", "==", agent_id)) |
| if sentiment: |
| query = query.where(filter=FieldFilter("sentiment", "==", sentiment)) |
| if lead_tag: |
| query = query.where(filter=FieldFilter("lead_tag", "==", lead_tag)) |
| |
| |
| query = query.limit(500) |
| docs = query.stream() |
| results = [] |
| for doc in docs: |
| d = doc.to_dict() |
| d.pop("transcript", None) |
| results.append(d) |
| if search: |
| s = search.lower() |
| results = [ |
| r for r in results |
| if s in (r.get("phone_number") or "").lower() |
| or s in (r.get("contact_name") or "").lower() |
| or s in (r.get("summary") or "").lower() |
| ] |
| results.sort(key=lambda x: x.get("started_at", ""), reverse=True) |
| |
| return _cache_set(cache_key, results, ttl=15) |
|
|
|
|
| @api_router.get("/calls/{call_id}", response_model=CallLog) |
| def get_call(call_id: str, user: dict = Depends(get_current_user)): |
| doc = db.collection("calls").document(call_id).get() |
| if not doc.exists or doc.to_dict().get("owner_id") != user["id"]: |
| raise HTTPException(status_code=404, detail="Call not found") |
| return doc.to_dict() |
|
|
|
|
| |
| @api_router.get("/leads", response_model=List[Lead]) |
| def list_leads( |
| user: dict = Depends(get_current_user), |
| agent_id: Optional[str] = None, |
| status: Optional[str] = None, |
| search: Optional[str] = None, |
| ): |
| q = db.collection("leads").where(filter=FieldFilter("owner_id", "==", user["id"])) |
| if agent_id: |
| q = q.where(filter=FieldFilter("agent_id", "==", agent_id)) |
| if status: |
| q = q.where(filter=FieldFilter("status", "==", status)) |
| results = [d.to_dict() for d in q.stream()] |
| if search: |
| s = search.lower() |
| results = [ |
| r for r in results |
| if s in (r.get("name") or "").lower() |
| or s in (r.get("phone") or "").lower() |
| or s in (r.get("email") or "").lower() |
| or s in (r.get("company") or "").lower() |
| ] |
| |
| results.sort(key=lambda x: x.get("score", 0), reverse=True) |
| return results |
|
|
|
|
| @api_router.delete("/leads/{lead_id}") |
| def delete_lead(lead_id: str, user: dict = Depends(get_current_user)): |
| doc = db.collection("leads").document(lead_id).get() |
| if doc.exists and doc.to_dict().get("owner_id") != user["id"]: |
| raise HTTPException(status_code=403, detail="Not allowed") |
| db.collection("leads").document(lead_id).delete() |
| return {"ok": True} |
|
|
|
|
| |
| @api_router.get("/notifications") |
| def get_notifications(user: dict = Depends(get_current_user)): |
| """Urgent items for the bell: HOT leads to call now, ANGRY/negative-sentiment |
| calls to review, and post-call actions (emails sent, meetings booked + |
| upcoming-meeting reminders). Cached briefly per user.""" |
| uid = user["id"] |
| cached = _cache_get(f"notifs:{uid}") |
| if cached is not None: |
| return cached |
|
|
| notes = [] |
| now = datetime.now(timezone.utc) |
|
|
| |
| try: |
| for d in (db.collection("leads").where(filter=FieldFilter("owner_id", "==", uid)) |
| .where(filter=FieldFilter("status", "==", "hot")).limit(15).stream()): |
| l = d.to_dict() |
| notes.append({ |
| "id": f"lead_{d.id}", "type": "hot_lead", "severity": "high", |
| "icon": "🔥", "title": f"Hot lead: {l.get('name') or l.get('phone') or 'Unknown'}", |
| "body": (l.get("intent") or l.get("message") or "")[:120], |
| "phone": l.get("phone", ""), "link": "/app/leads", |
| "ts": l.get("updated_at"), |
| }) |
| except Exception as e: |
| logger.warning(f"[Notif] leads query: {e}") |
|
|
| |
| try: |
| for d in (db.collection("calls").where(filter=FieldFilter("owner_id", "==", uid)) |
| .where(filter=FieldFilter("sentiment", "==", "negative")).limit(15).stream()): |
| c = d.to_dict() |
| notes.append({ |
| "id": f"call_{d.id}", "type": "angry_call", "severity": "high", |
| "icon": "⚠️", "title": f"Unhappy caller: {c.get('contact_name') or c.get('phone_number') or 'Unknown'}", |
| "body": (c.get("summary") or "Negative sentiment detected on this call.")[:120], |
| "phone": c.get("phone_number", ""), "link": "/app/call-logs", |
| "ts": c.get("started_at"), |
| }) |
| except Exception as e: |
| logger.warning(f"[Notif] calls query: {e}") |
|
|
| |
| try: |
| recent = sorted( |
| [c.to_dict() for c in db.collection("calls").where(filter=FieldFilter("owner_id", "==", uid)).limit(80).stream()], |
| key=lambda x: x.get("started_at", ""), reverse=True)[:30] |
| for c in recent: |
| for a in (c.get("actions") or []): |
| if a.get("status") != "ok": |
| continue |
| if a.get("type") == "email": |
| notes.append({"id": f"email_{c.get('id')}", "type": "email_sent", "severity": "info", |
| "icon": "📧", "title": a.get("label", "Email sent"), |
| "body": a.get("detail", ""), "link": "/app/call-logs", "ts": c.get("started_at")}) |
| elif a.get("type") == "calendar": |
| notes.append({"id": f"cal_{c.get('id')}", "type": "meeting", "severity": "info", |
| "icon": "📅", "title": a.get("label", "Meeting booked"), |
| "body": a.get("detail", ""), "link": "/app/call-logs", "ts": c.get("started_at")}) |
| except Exception as e: |
| logger.warning(f"[Notif] actions query: {e}") |
|
|
| |
| high = [n for n in notes if n.get("severity") == "high"] |
| info = [n for n in notes if n.get("severity") != "high"] |
| out = {"count": len(high), "total": len(notes), "items": (high + info)[:25], |
| "generated_at": now.isoformat()} |
| return _cache_set(f"notifs:{uid}", out, ttl=20) |
|
|
|
|
| |
| @api_router.get("/telephony/diagnostic") |
| def telephony_diagnostic(user: dict = Depends(get_current_user)): |
| """Report which env vars / SIP resources are configured so the user can |
| self-diagnose 'calls fail before audio' without server access. No secrets.""" |
| env = {k: bool(os.environ.get(k)) for k in [ |
| "LIVEKIT_URL", "LIVEKIT_API_KEY", "LIVEKIT_API_SECRET", |
| "LIVEKIT_SIP_TRUNK_ID", "VOBIZ_SIP_DOMAIN", "VOBIZ_SIP_USERNAME", |
| "VOBIZ_AUTH_ID", "VOBIZ_FROM_NUMBER", |
| "GROQ_API_KEY", "SARVAM_API_KEY", "NVIDIA_API_KEY", "GEMINI_API_KEY", |
| ]} |
| trunks = list(db.collection("sip_trunks").where(filter=FieldFilter("owner_id", "==", user["id"])).stream()) |
| numbers = list(db.collection("phone_numbers").where(filter=FieldFilter("owner_id", "==", user["id"])).stream()) |
| issues = [] |
| if not all([env["LIVEKIT_URL"], env["LIVEKIT_API_KEY"], env["LIVEKIT_API_SECRET"]]): |
| issues.append("LiveKit env vars missing — web + phone calls will fail.") |
| if not env["LIVEKIT_SIP_TRUNK_ID"] and not trunks and not (env["VOBIZ_SIP_DOMAIN"]): |
| issues.append("No SIP trunk configured — outbound phone calls cannot dial. " |
| "Set VOBIZ_SIP_DOMAIN + creds or create a trunk in Phone Numbers.") |
| if not numbers and not env["VOBIZ_FROM_NUMBER"]: |
| issues.append("No caller-ID number — assign a phone number to an agent or set VOBIZ_FROM_NUMBER.") |
| if not env["GROQ_API_KEY"]: |
| issues.append("GROQ_API_KEY missing — the agent's LLM and post-call analysis will not work.") |
| if not env["SARVAM_API_KEY"]: |
| issues.append("SARVAM_API_KEY missing — Sarvam STT/TTS will not work.") |
| return { |
| "env": env, |
| "sip_trunks": len(trunks), |
| "phone_numbers": len(numbers), |
| "issues": issues, |
| "status": "ok" if not issues else "degraded", |
| } |
|
|
|
|
| @api_router.get("/gemini/live-models") |
| async def gemini_live_models(): |
| """PUBLIC — list the Gemini models that support realtime (bidiGenerateContent) |
| for the configured GEMINI_API_KEY, so we can pick a model name that actually |
| exists. Open this URL in a browser to see the valid Gemini Live model ids.""" |
| key = os.environ.get("GEMINI_API_KEY") or os.environ.get("GOOGLE_API_KEY", "") |
| if not key: |
| return {"error": "GEMINI_API_KEY not set on the server"} |
| try: |
| async with httpx.AsyncClient(timeout=20.0) as client: |
| r = await client.get( |
| f"https://generativelanguage.googleapis.com/v1beta/models?key={key}&pageSize=1000") |
| r.raise_for_status() |
| models = r.json().get("models", []) |
| except Exception as e: |
| return {"error": f"query failed: {e}"} |
| live, flash = [], [] |
| for m in models: |
| name = m.get("name", "").replace("models/", "") |
| methods = m.get("supportedGenerationMethods", []) |
| if "bidiGenerateContent" in methods: |
| live.append(name) |
| if "live" in name or "flash" in name: |
| flash.append(name) |
| return {"realtime_bidi_models": sorted(live), |
| "current_env_default": os.environ.get("GEMINI_LIVE_MODEL", "(unset)"), |
| "hint": "Set GEMINI_LIVE_MODEL to one of realtime_bidi_models on the Space, then Restart."} |
|
|
|
|
| @api_router.get("/integrations/diagnostic") |
| def integrations_diagnostic(): |
| """PUBLIC (no auth) OAuth self-check — open this URL in a browser to debug |
| 'Error 401: invalid_client'. Reports which OAuth client IDs the server sees |
| (masked, no secrets), the OAuth redirect base it will use, and the exact |
| redirect URIs to register in each provider console. If a client_id shows |
| present=false, that provider's Secret isn't set on the Space → that's the 401. |
| """ |
| import integrations as _int |
|
|
| def _probe(env_key): |
| cid = (os.environ.get(env_key) or "").strip() |
| return {"present": bool(cid), |
| "value_preview": (cid[:14] + "…" + cid[-6:]) if len(cid) > 24 else cid, |
| "looks_trimmed": cid == (os.environ.get(env_key) or "")} |
|
|
| base = _int._redirect_base() |
| google_cid = _probe("GOOGLE_CLIENT_ID") |
| providers = {} |
| for pid in ("gmail", "google_calendar", "google_sheets", "hubspot", "zoho", "outlook"): |
| |
| try: |
| cid, sec = _int._client_creds(pid, {}) |
| except Exception: |
| cid, sec = None, None |
| providers[pid] = { |
| "client_id_present": bool(cid), |
| "client_secret_present": bool(sec), |
| "redirect_uri": f"{base}/api/integrations/{pid}/callback" if base else "(OAUTH_REDIRECT_BASE not set)", |
| } |
|
|
| return { |
| "oauth_redirect_base": base or "(EMPTY — set OAUTH_REDIRECT_BASE)", |
| "GOOGLE_CLIENT_ID": google_cid, |
| "GOOGLE_CLIENT_SECRET_present": bool((os.environ.get("GOOGLE_CLIENT_SECRET") or "").strip()), |
| "providers": providers, |
| "hint": ("If a Google provider shows client_id_present=false, add " |
| "GOOGLE_CLIENT_ID + GOOGLE_CLIENT_SECRET to the Space Secrets and Restart. " |
| "Then register the shown redirect_uri values in the Google console."), |
| } |
|
|
|
|
| |
| @api_router.get("/phone-numbers", response_model=List[PhoneNumber]) |
| def list_numbers(user: dict = Depends(get_current_user)): |
| cached = _cache_get(f"phone_numbers:{user['id']}") |
| if cached is not None: |
| return cached |
| docs = db.collection("phone_numbers").where(filter=FieldFilter("owner_id", "==", user["id"])).stream() |
| return _cache_set(f"phone_numbers:{user['id']}", [doc.to_dict() for doc in docs], ttl=30) |
|
|
|
|
| @api_router.post("/phone-numbers", response_model=PhoneNumber) |
| def add_number(payload: dict, user: dict = Depends(get_current_user)): |
| number = (payload.get("number") or "").strip() |
| if not number: |
| raise HTTPException(status_code=400, detail="Phone number is required") |
| pid = str(uuid.uuid4()) |
| doc_data = { |
| "id": pid, |
| "number": number, |
| "provider": "Vobiz", |
| "label": payload.get("label", "New Number"), |
| "assigned_agent_id": payload.get("assigned_agent_id"), |
| "status": "active", |
| "sip_domain": payload.get("sip_domain"), |
| "sip_username": payload.get("sip_username"), |
| "sip_password": payload.get("sip_password"), |
| "owner_id": user["id"], |
| } |
| db.collection("phone_numbers").document(pid).set(doc_data) |
| _cache_invalidate_user(user["id"], "phone_numbers") |
| return {k: v for k, v in doc_data.items() if k != "owner_id"} |
|
|
|
|
| @api_router.delete("/phone-numbers/{pid}") |
| def delete_number(pid: str, user: dict = Depends(get_current_user)): |
| doc = db.collection("phone_numbers").document(pid).get() |
| if doc.exists and doc.to_dict().get("owner_id") != user["id"]: |
| raise HTTPException(status_code=403, detail="Not allowed") |
| db.collection("phone_numbers").document(pid).delete() |
| _cache_invalidate_user(user["id"], "phone_numbers") |
| return {"ok": True} |
|
|
|
|
| |
| @api_router.get("/knowledge", response_model=List[KnowledgeDoc]) |
| def list_knowledge(user: dict = Depends(get_current_user)): |
| cached = _cache_get(f"knowledge:{user['id']}") |
| if cached is not None: |
| return cached |
| docs = db.collection("knowledge").where(filter=FieldFilter("owner_id", "==", user["id"])).stream() |
| results = [] |
| for doc in docs: |
| try: |
| d = doc.to_dict() or {} |
| |
| d.setdefault("name", "") |
| d.setdefault("size_kb", 0) |
| d.setdefault("pages", 0) |
| d.setdefault("status", "ready") |
| d.setdefault("uploaded_at", "") |
| d.setdefault("chunks", 0) |
| d["size_kb"] = float(d["size_kb"] or 0) |
| d["pages"] = int(d["pages"] or 0) |
| d["chunks"] = int(d["chunks"] or 0) |
| results.append(d) |
| except Exception as e: |
| logger.warning(f"Skipping malformed KB doc {doc.id}: {e}") |
| return _cache_set(f"knowledge:{user['id']}", results, ttl=30) |
|
|
|
|
| @api_router.post("/knowledge/upload", response_model=KnowledgeDoc) |
| async def upload_knowledge(file: UploadFile = File(...), user: dict = Depends(get_current_user)): |
| content = await file.read() |
| size_kb = max(1, len(content) // 1024) |
| name = file.filename or "Document" |
| ext = (name.rsplit(".", 1)[-1] or "").lower() |
|
|
| text = "" |
| pages = 1 |
| try: |
| if ext == "pdf": |
| text, pages = _extract_pdf_text(content) |
| elif ext in ("txt", "md", "csv"): |
| text = content.decode("utf-8", errors="ignore") |
| pages = max(1, len(text) // 3000) |
| elif ext == "docx": |
| text, pages = _extract_docx_text(content) |
| else: |
| try: |
| text = content.decode("utf-8", errors="ignore") |
| except Exception: |
| text = "" |
| except Exception as e: |
| logger.error(f"KB parse failed: {e}") |
|
|
| kid = str(uuid.uuid4()) |
| chunks = _chunk_text(text, target=900) |
| doc_data = { |
| "id": kid, |
| "name": name, |
| "size_kb": size_kb, |
| "pages": pages, |
| "status": "ready" if chunks else "failed", |
| "uploaded_at": now_iso(), |
| "owner_id": user["id"], |
| "chunks": len(chunks), |
| } |
| db.collection("knowledge").document(kid).set(doc_data) |
| |
| for i, ch in enumerate(chunks): |
| vector = _get_embedding(ch, input_type="passage") |
| db.collection("knowledge").document(kid).collection("chunks").document(str(i)).set({ |
| "i": i, |
| "text": ch, |
| "vector": vector |
| }) |
|
|
| _cache_invalidate_user(user["id"], "knowledge") |
| return {k: v for k, v in doc_data.items() if k != "owner_id"} |
|
|
| class URLUploadIn(BaseModel): |
| url: str |
|
|
| import urllib.parse |
| import requests |
| from bs4 import BeautifulSoup |
|
|
| def _crawl_and_embed(start_url: str, doc_id: str, owner_id: str, max_pages: int = 20): |
| visited = set() |
| queue = [start_url] |
| base_domain = urllib.parse.urlparse(start_url).netloc |
| |
| total_chunks = 0 |
| pages_crawled = 0 |
| total_bytes = 0 |
| |
| while queue and pages_crawled < max_pages: |
| current_url = queue.pop(0) |
| if current_url in visited: |
| continue |
| |
| visited.add(current_url) |
| pages_crawled += 1 |
| |
| try: |
| r = requests.get(current_url, timeout=10) |
| if r.status_code != 200: |
| continue |
| |
| soup = BeautifulSoup(r.text, 'html.parser') |
| |
| for script in soup(["script", "style"]): |
| script.decompose() |
| text = soup.get_text(separator=' ', strip=True) |
| |
| if text: |
| total_bytes += len(text.encode("utf-8")) |
| chunks = _chunk_text(text, target=900) |
| for ch in chunks: |
| vector = _get_embedding(ch, input_type="passage") |
| db.collection("knowledge").document(doc_id).collection("chunks").document(str(total_chunks)).set({ |
| "i": total_chunks, |
| "text": ch, |
| "vector": vector, |
| "source": current_url |
| }) |
| total_chunks += 1 |
| |
| for link in soup.find_all('a', href=True): |
| href = link['href'] |
| absolute_url = urllib.parse.urljoin(current_url, href) |
| parsed = urllib.parse.urlparse(absolute_url) |
| |
| clean_url = urllib.parse.urlunparse((parsed.scheme, parsed.netloc, parsed.path, parsed.params, parsed.query, '')) |
| |
| if parsed.netloc == base_domain and clean_url not in visited and clean_url not in queue: |
| if parsed.scheme in ("http", "https"): |
| queue.append(clean_url) |
| |
| except Exception as e: |
| logger.error(f"Crawler failed on {current_url}: {e}") |
| |
| db.collection("knowledge").document(doc_id).update({ |
| "status": "ready" if total_chunks > 0 else "failed", |
| "chunks": total_chunks, |
| "pages": pages_crawled, |
| "size_kb": round(total_bytes / 1024, 1) |
| }) |
| _cache_invalidate_user(owner_id, "knowledge") |
|
|
| @api_router.post("/knowledge/upload-url", response_model=KnowledgeDoc) |
| def upload_url_knowledge(payload: URLUploadIn, background_tasks: BackgroundTasks, user: dict = Depends(get_current_user)): |
| url = payload.url.strip() |
| if not url.startswith("http"): |
| url = "https://" + url |
| |
| kid = str(uuid.uuid4()) |
| doc_data = { |
| "id": kid, |
| "name": url, |
| "size_kb": 0, |
| "pages": 0, |
| "status": "indexing", |
| "uploaded_at": now_iso(), |
| "owner_id": user["id"], |
| "chunks": 0, |
| } |
| db.collection("knowledge").document(kid).set(doc_data) |
| _cache_invalidate_user(user["id"], "knowledge") |
| |
| background_tasks.add_task(_crawl_and_embed, url, kid, user["id"], 20) |
| return {k: v for k, v in doc_data.items() if k != "owner_id"} |
|
|
|
|
| @api_router.post("/knowledge", response_model=KnowledgeDoc) |
| def add_knowledge_meta(payload: dict, user: dict = Depends(get_current_user)): |
| """Metadata-only entry (legacy). Prefer /knowledge/upload for actual content.""" |
| kid = str(uuid.uuid4()) |
| doc_data = { |
| "id": kid, |
| "name": payload.get("name", "Document"), |
| "size_kb": int(payload.get("size_kb", 0)), |
| "pages": int(payload.get("pages", 1)), |
| "status": "processing", |
| "uploaded_at": now_iso(), |
| "owner_id": user["id"], |
| "chunks": 0, |
| } |
| db.collection("knowledge").document(kid).set(doc_data) |
| _cache_invalidate_user(user["id"], "knowledge") |
| return {k: v for k, v in doc_data.items() if k != "owner_id"} |
|
|
|
|
| @api_router.get("/knowledge/{kid}/chunks") |
| def get_knowledge_chunks(kid: str, user: dict = Depends(get_current_user)): |
| """Return all text chunks for a knowledge document (without vectors) for preview.""" |
| doc = db.collection("knowledge").document(kid).get() |
| if not doc.exists: |
| raise HTTPException(status_code=404, detail="Document not found") |
| if doc.to_dict().get("owner_id") != user["id"]: |
| raise HTTPException(status_code=403, detail="Not allowed") |
| chunks = [] |
| for c in db.collection("knowledge").document(kid).collection("chunks").order_by("i").stream(): |
| d = c.to_dict() |
| chunks.append({"i": d.get("i", 0), "text": d.get("text", ""), "source": d.get("source", "")}) |
| return chunks |
|
|
|
|
| @api_router.delete("/knowledge/{kid}") |
| def delete_knowledge(kid: str, user: dict = Depends(get_current_user)): |
| doc = db.collection("knowledge").document(kid).get() |
| if not doc.exists: |
| return {"ok": True} |
| if doc.to_dict().get("owner_id") != user["id"]: |
| raise HTTPException(status_code=403, detail="Not allowed") |
| |
| for sub in db.collection("knowledge").document(kid).collection("chunks").stream(): |
| sub.reference.delete() |
| db.collection("knowledge").document(kid).delete() |
| _cache_invalidate_user(user["id"], "knowledge") |
| return {"ok": True} |
|
|
|
|
| def _extract_pdf_text(data: bytes) -> tuple[str, int]: |
| try: |
| from pypdf import PdfReader |
| except ImportError: |
| try: |
| from PyPDF2 import PdfReader |
| except ImportError: |
| logger.warning("pypdf not installed; PDF parsing skipped") |
| return "", 1 |
| reader = PdfReader(io.BytesIO(data)) |
| pages = len(reader.pages) |
| out = [] |
| for p in reader.pages: |
| try: |
| out.append(p.extract_text() or "") |
| except Exception: |
| pass |
| return "\n".join(out), pages |
|
|
|
|
| def _extract_docx_text(data: bytes) -> tuple[str, int]: |
| try: |
| from docx import Document |
| except ImportError: |
| logger.warning("python-docx not installed") |
| return "", 1 |
| d = Document(io.BytesIO(data)) |
| text = "\n".join(p.text for p in d.paragraphs if p.text) |
| return text, max(1, len(text) // 3000) |
|
|
|
|
| def _chunk_text(text: str, target: int = 900) -> List[str]: |
| text = (text or "").strip() |
| if not text: |
| return [] |
| paras = re.split(r"\n\s*\n", text) |
| chunks: List[str] = [] |
| buf = "" |
| for p in paras: |
| if len(buf) + len(p) + 2 > target and buf: |
| chunks.append(buf.strip()) |
| buf = p |
| else: |
| buf += ("\n\n" + p) if buf else p |
| if buf.strip(): |
| chunks.append(buf.strip()) |
| return chunks |
|
|
|
|
| |
| VOICE_CATALOG = { |
| "Sarvam": [ |
| {"id": "shreya", "label": "Shreya (Female)", "gender": "female"}, |
| {"id": "ishita", "label": "Ishita (Female)", "gender": "female"}, |
| {"id": "priya", "label": "Priya (Female)", "gender": "female"}, |
| {"id": "suhani", "label": "Suhani (Female)", "gender": "female"}, |
| {"id": "ritu", "label": "Ritu (Female)", "gender": "female"}, |
| {"id": "pooja", "label": "Pooja (Female)", "gender": "female"}, |
| {"id": "simran", "label": "Simran (Female)", "gender": "female"}, |
| {"id": "kavya", "label": "Kavya (Female)", "gender": "female"}, |
| {"id": "neha", "label": "Neha (Female)", "gender": "female"}, |
| {"id": "roopa", "label": "Roopa (Female)", "gender": "female"}, |
| {"id": "tanya", "label": "Tanya (Female)", "gender": "female"}, |
| {"id": "shruti", "label": "Shruti (Female)", "gender": "female"}, |
| {"id": "kavitha", "label": "Kavitha (Female)", "gender": "female"}, |
| {"id": "rupali", "label": "Rupali (Female)", "gender": "female"}, |
| {"id": "shubh", "label": "Shubh (Male)", "gender": "male"}, |
| {"id": "manan", "label": "Manan (Male)", "gender": "male"}, |
| {"id": "amit", "label": "Amit (Male)", "gender": "male"}, |
| {"id": "rahul", "label": "Rahul (Male)", "gender": "male"}, |
| {"id": "rohan", "label": "Rohan (Male)", "gender": "male"}, |
| {"id": "aditya", "label": "Aditya (Male)", "gender": "male"}, |
| {"id": "dev", "label": "Dev (Male)", "gender": "male"}, |
| {"id": "ratan", "label": "Ratan (Male)", "gender": "male"}, |
| {"id": "varun", "label": "Varun (Male)", "gender": "male"}, |
| {"id": "sumit", "label": "Sumit (Male)", "gender": "male"}, |
| {"id": "kabir", "label": "Kabir (Male)", "gender": "male"}, |
| {"id": "aayan", "label": "Aayan (Male)", "gender": "male"}, |
| {"id": "ashutosh", "label": "Ashutosh (Male)", "gender": "male"}, |
| {"id": "advait", "label": "Advait (Male)", "gender": "male"}, |
| {"id": "anand", "label": "Anand (Male)", "gender": "male"}, |
| {"id": "tarun", "label": "Tarun (Male)", "gender": "male"}, |
| {"id": "sunny", "label": "Sunny (Male)", "gender": "male"}, |
| {"id": "mani", "label": "Mani (Male)", "gender": "male"}, |
| {"id": "gokul", "label": "Gokul (Male)", "gender": "male"}, |
| {"id": "vijay", "label": "Vijay (Male)", "gender": "male"}, |
| {"id": "mohit", "label": "Mohit (Male)", "gender": "male"}, |
| {"id": "rehan", "label": "Rehan (Male)", "gender": "male"}, |
| {"id": "soham", "label": "Soham (Male)", "gender": "male"}, |
| ], |
| "ElevenLabs": [ |
| {"id": "21m00Tcm4TlvDq8ikWAM", "label": "Rachel (Female)", "gender": "female"}, |
| {"id": "pNInz6obpgDQGcFmaJgB", "label": "Adam (Male)", "gender": "male"}, |
| {"id": "EXAVITQu4vr4xnSDxMaL", "label": "Bella (Female)", "gender": "female"}, |
| {"id": "TxGEqnHWrfWFTfGW9XjX", "label": "Josh (Male)", "gender": "male"}, |
| ], |
| "OpenAI": [ |
| {"id": "alloy", "label": "Alloy (Neutral)", "gender": "neutral"}, |
| {"id": "echo", "label": "Echo (Male)", "gender": "male"}, |
| {"id": "fable", "label": "Fable (Male)", "gender": "male"}, |
| {"id": "onyx", "label": "Onyx (Male)", "gender": "male"}, |
| {"id": "nova", "label": "Nova (Female)", "gender": "female"}, |
| {"id": "shimmer", "label": "Shimmer (Female)", "gender": "female"}, |
| ], |
| } |
|
|
|
|
| @api_router.get("/voices") |
| def list_voices(): |
| return VOICE_CATALOG |
|
|
|
|
| class TTSPreviewIn(BaseModel): |
| voice_id: str = "shreya" |
| text: str = "Namaste! Main aapki kaise madad kar sakti hoon?" |
| speed: float = 1.0 |
| provider: str = "Sarvam" |
|
|
|
|
| @api_router.post("/tts/preview") |
| async def tts_preview(payload: TTSPreviewIn, user: dict = Depends(get_current_user)): |
| return await _route_tts(payload) |
|
|
|
|
| @api_router.post("/tts/test") |
| async def tts_test(payload: TTSPreviewIn, user: dict = Depends(get_current_user)): |
| return await _route_tts(payload) |
|
|
|
|
| async def _route_tts(payload: TTSPreviewIn): |
| provider = (payload.provider or "Sarvam").lower() |
| if provider in ("elevenlabs", "eleven"): |
| return await _call_elevenlabs_tts(payload) |
| if provider in ("openai", "whisper"): |
| return await _call_openai_tts(payload) |
| return await _call_sarvam_tts(payload) |
|
|
|
|
| async def _call_sarvam_tts(payload: TTSPreviewIn): |
| key = os.environ.get("SARVAM_API_KEY") |
| if not key: |
| raise HTTPException(status_code=500, detail="SARVAM_API_KEY not configured") |
| body = { |
| "inputs": [payload.text], |
| "target_language_code": "hi-IN", |
| "speaker": payload.voice_id, |
| "model": "bulbul:v3", |
| "speed": float(payload.speed), |
| "enable_preprocessing": True, |
| } |
| try: |
| async with httpx.AsyncClient(timeout=20.0) as client: |
| r = await client.post( |
| "https://api.sarvam.ai/text-to-speech", |
| headers={"api-subscription-key": key, "Content-Type": "application/json"}, |
| json=body, |
| ) |
| if r.status_code != 200: |
| logger.error(f"Sarvam TTS error {r.status_code}: {r.text[:200]}") |
| raise HTTPException(status_code=502, detail=f"Sarvam {r.status_code}: {r.text[:200]}") |
| audios = r.json().get("audios") or [] |
| except HTTPException: |
| raise |
| except Exception as e: |
| raise HTTPException(status_code=502, detail=f"TTS error: {type(e).__name__}: {e}") |
| if not audios: |
| raise HTTPException(status_code=502, detail="No audio returned") |
| return StreamingResponse(io.BytesIO(base64.b64decode(audios[0])), media_type="audio/wav") |
|
|
|
|
| async def _call_elevenlabs_tts(payload: TTSPreviewIn): |
| key = os.environ.get("ELEVENLABS_API_KEY") |
| if not key: |
| raise HTTPException(status_code=500, detail="ELEVENLABS_API_KEY not configured") |
| try: |
| async with httpx.AsyncClient(timeout=30.0) as client: |
| r = await client.post( |
| f"https://api.elevenlabs.io/v1/text-to-speech/{payload.voice_id}", |
| headers={"xi-api-key": key, "Content-Type": "application/json", "Accept": "audio/mpeg"}, |
| json={ |
| "text": payload.text, |
| "model_id": "eleven_multilingual_v2", |
| "voice_settings": {"stability": 0.5, "similarity_boost": 0.75}, |
| }, |
| ) |
| if r.status_code != 200: |
| raise HTTPException(status_code=502, detail=f"ElevenLabs {r.status_code}: {r.text[:200]}") |
| return StreamingResponse(io.BytesIO(r.content), media_type="audio/mpeg") |
| except HTTPException: |
| raise |
| except Exception as e: |
| raise HTTPException(status_code=502, detail=f"ElevenLabs error: {e}") |
|
|
|
|
| async def _call_openai_tts(payload: TTSPreviewIn): |
| key = os.environ.get("OPENAI_API_KEY") |
| if not key: |
| raise HTTPException(status_code=500, detail="OPENAI_API_KEY not configured") |
| try: |
| o_vid = payload.voice_id.lower() if payload.voice_id.lower() in ("alloy", "echo", "fable", "onyx", "nova", "shimmer") else "alloy" |
| async with httpx.AsyncClient(timeout=30.0) as client: |
| r = await client.post( |
| "https://api.openai.com/v1/audio/speech", |
| headers={"Authorization": f"Bearer {key}", "Content-Type": "application/json"}, |
| json={ |
| "model": "tts-1", |
| "input": payload.text, |
| "voice": o_vid, |
| "speed": float(payload.speed), |
| }, |
| ) |
| if r.status_code != 200: |
| raise HTTPException(status_code=502, detail=f"OpenAI TTS error {r.status_code}: {r.text[:200]}") |
| return StreamingResponse(io.BytesIO(r.content), media_type="audio/mpeg") |
| except HTTPException: |
| raise |
| except Exception as e: |
| raise HTTPException(status_code=502, detail=f"OpenAI TTS error: {e}") |
|
|
|
|
| |
| class ProfileUpdateIn(BaseModel): |
| name: Optional[str] = None |
| workspace: Optional[str] = None |
|
|
|
|
| @api_router.put("/settings/profile") |
| def update_profile(payload: ProfileUpdateIn, user: dict = Depends(get_current_user)): |
| update_data: Dict[str, Any] = {} |
| if payload.name is not None: |
| n = payload.name.strip() |
| if not (1 <= len(n) <= 100): |
| raise HTTPException(status_code=400, detail="Name must be 1-100 characters") |
| update_data["name"] = n |
| if payload.workspace is not None: |
| w = payload.workspace.strip() |
| if not (1 <= len(w) <= 60): |
| raise HTTPException(status_code=400, detail="Workspace must be 1-60 characters") |
| update_data["workspace"] = w |
| if update_data: |
| db.collection("users").document(user["id"]).update(update_data) |
| _cache_invalidate_user(user["id"]) |
| return {"ok": True, **update_data} |
|
|
|
|
| class ProviderConnectIn(BaseModel): |
| provider: str |
| api_key: Optional[str] = None |
| api_secret: Optional[str] = None |
| enabled: bool = True |
|
|
|
|
| @api_router.get("/settings/providers") |
| def list_provider_integrations(user: dict = Depends(get_current_user)): |
| cached = _cache_get(f"providers:{user['id']}") |
| if cached is not None: |
| return cached |
| docs = db.collection("provider_integrations").where(filter=FieldFilter("owner_id", "==", user["id"])).stream() |
| out = {d.id.split("__")[-1]: d.to_dict() for d in docs} |
| return _cache_set(f"providers:{user['id']}", out, ttl=60) |
|
|
|
|
| @api_router.put("/settings/providers") |
| def upsert_provider_integration(payload: ProviderConnectIn, user: dict = Depends(get_current_user)): |
| if not payload.provider: |
| raise HTTPException(status_code=400, detail="provider is required") |
| doc_id = f"{user['id']}__{payload.provider}" |
| data = { |
| "provider": payload.provider, |
| "enabled": payload.enabled, |
| "owner_id": user["id"], |
| "updated_at": now_iso(), |
| |
| "has_key": bool(payload.api_key), |
| "has_secret": bool(payload.api_secret), |
| } |
| if payload.api_key: |
| data["api_key_masked"] = "•" * max(0, len(payload.api_key) - 4) + payload.api_key[-4:] |
| data["api_key"] = payload.api_key |
| if payload.api_secret: |
| data["api_secret"] = payload.api_secret |
| db.collection("provider_integrations").document(doc_id).set(data, merge=True) |
| _cache_invalidate_user(user["id"], "providers") |
| return {"ok": True, "provider": payload.provider, "enabled": payload.enabled} |
|
|
|
|
| @api_router.post("/settings/api-key/rotate") |
| def rotate_api_key(user: dict = Depends(get_current_user)): |
| new_key = "sk-scatter-" + secrets.token_hex(20) |
| db.collection("users").document(user["id"]).update({"api_key": new_key}) |
| _cache_invalidate_user(user["id"]) |
| return {"api_key": new_key} |
|
|
|
|
| |
| import integrations as _integrations |
| from fastapi.responses import HTMLResponse |
|
|
|
|
| @api_router.get("/integrations") |
| def list_integrations_route(user: dict = Depends(get_current_user)): |
| """Catalog with per-provider connection status for the current user.""" |
| return {"integrations": _integrations.list_integrations(user["id"])} |
|
|
|
|
| @api_router.get("/integrations/{provider}/connect") |
| def integration_connect(provider: str, request: Request, user: dict = Depends(get_current_user)): |
| """Begin OAuth — returns the provider consent URL for the frontend to open.""" |
| res = _integrations.build_authorize_url(user["id"], provider, str(request.base_url)) |
| if not res.get("ok"): |
| raise HTTPException(status_code=400, detail=res.get("error", "Cannot start OAuth")) |
| return {"redirect_url": res["redirect_url"]} |
|
|
|
|
| @api_router.get("/integrations/{provider}/callback") |
| def integration_callback(provider: str, code: str = "", state: str = ""): |
| """OAuth redirect target (no auth — uid is carried in `state`). Exchanges the |
| code for tokens, then returns a tiny page that notifies the opener tab.""" |
| res = _integrations.exchange_code(provider, code, state) |
| ok = bool(res.get("ok")) |
| msg = "Connected" if ok else (res.get("error") or "Connection failed") |
| color = "#10b981" if ok else "#ef4444" |
| html = ( |
| "<!doctype html><meta charset='utf-8'><title>" + msg + "</title>" |
| "<style>body{font-family:Inter,system-ui,sans-serif;background:#0B0B10;color:#F5F5F7;" |
| "display:grid;place-items:center;height:100vh;margin:0}</style>" |
| "<div style='text-align:center'><h2 style='color:" + color + "'>" |
| + ("✓ " if ok else "✗ ") + msg + "</h2>" |
| "<p style='color:#A8A8B3'>You can close this tab.</p></div>" |
| "<script>try{window.opener&&window.opener.postMessage({scatter_integration:'" |
| + provider + "',connected:" + ("true" if ok else "false") + "},'*')}catch(e){};" |
| "setTimeout(function(){window.close()},1200)</script>" |
| ) |
| return HTMLResponse(content=html) |
|
|
|
|
| @api_router.post("/integrations/{provider}/disconnect") |
| def integration_disconnect(provider: str, user: dict = Depends(get_current_user)): |
| _integrations.delete_cfg(user["id"], provider) |
| return {"ok": True} |
|
|
|
|
| @api_router.post("/integrations/{provider}/save") |
| async def integration_save(provider: str, request: Request, user: dict = Depends(get_current_user)): |
| """Save non-OAuth credentials (SMTP host/port/user/pass) or a power user's own |
| OAuth client_id/secret.""" |
| data = await request.json() |
| res = _integrations.save_manual(user["id"], provider, data or {}) |
| if not res.get("ok"): |
| raise HTTPException(status_code=400, detail=res.get("error", "Save failed")) |
| return res |
|
|
|
|
| |
| @api_router.get("/billing/usage") |
| def billing_usage(user: dict = Depends(get_current_user)): |
| cached = _cache_get(f"billing:{user['id']}") |
| if cached is not None: |
| return cached |
| |
| plan = {"name": "Growth", "agents_limit": 5, "minutes_limit": 5000, "docs_limit": 50, "mrr": "₹24,999"} |
| user_doc = db.collection("users").document(user["id"]).get() |
| if user_doc.exists: |
| ud = user_doc.to_dict() |
| if ud.get("plan"): |
| plan.update(ud["plan"]) |
|
|
| agents_count = len(list(db.collection("agents").where(filter=FieldFilter("owner_id", "==", user["id"])).stream())) |
| docs_count = len(list(db.collection("knowledge").where(filter=FieldFilter("owner_id", "==", user["id"])).stream())) |
|
|
| |
| month_start = datetime.now(timezone.utc).replace(day=1, hour=0, minute=0, second=0, microsecond=0) |
| calls = db.collection("calls").where(filter=FieldFilter("owner_id", "==", user["id"])).stream() |
| total_seconds = 0 |
| for c in calls: |
| cd = c.to_dict() |
| ts = cd.get("started_at", "") |
| try: |
| if datetime.fromisoformat(ts.replace("Z", "+00:00")) >= month_start: |
| total_seconds += int(cd.get("duration_seconds", 0) or 0) |
| except Exception: |
| continue |
| minutes_used = round(total_seconds / 60.0, 1) |
|
|
| invoices_docs = db.collection("invoices").where(filter=FieldFilter("owner_id", "==", user["id"])).stream() |
| invoices = [d.to_dict() for d in invoices_docs] |
| invoices.sort(key=lambda x: x.get("date", ""), reverse=True) |
|
|
| return _cache_set(f"billing:{user['id']}", { |
| "plan": plan, |
| "usage": { |
| "agents": agents_count, |
| "agents_limit": plan["agents_limit"], |
| "minutes": minutes_used, |
| "minutes_limit": plan["minutes_limit"], |
| "docs": docs_count, |
| "docs_limit": plan["docs_limit"], |
| }, |
| "invoices": invoices, |
| }, ttl=30) |
|
|
|
|
| |
|
|
| def _build_dashboard_data(uid: str) -> dict: |
| """Single Firestore scan to build all dashboard data. Shared by /dashboard, |
| /analytics/overview, and /analytics/agent-leaderboard. |
| |
| Performance: we only fetch the SLIM fields the dashboard needs via .select() |
| — critically NOT `transcript` (which can be huge and dominated download time), |
| and we cap to the most-recent N calls so the scan stays bounded as history |
| grows. The agents + calls reads run in parallel threads.""" |
| from concurrent.futures import ThreadPoolExecutor as _TPE |
|
|
| _CALL_FIELDS = ["owner_id", "agent_id", "agent_name", "started_at", |
| "duration_seconds", "status", "sentiment", "lead_tag", |
| "phone_number", "contact_name", "summary", "intent"] |
| _MAX_CALLS = 1000 |
|
|
| def _load_calls(): |
| try: |
| q = (db.collection("calls").where(filter=FieldFilter("owner_id", "==", uid)) |
| .order_by("started_at", direction=firestore.Query.DESCENDING) |
| .limit(_MAX_CALLS)) |
| try: |
| q = q.select(_CALL_FIELDS) |
| except Exception: |
| pass |
| return [d.to_dict() for d in q.stream()] |
| except Exception: |
| |
| return [d.to_dict() for d in |
| db.collection("calls").where(filter=FieldFilter("owner_id", "==", uid)).limit(_MAX_CALLS).stream()] |
|
|
| def _load_agents(): |
| return [d.to_dict() for d in |
| db.collection("agents").where(filter=FieldFilter("owner_id", "==", uid)).stream()] |
|
|
| with _TPE(max_workers=2) as ex: |
| f_calls = ex.submit(_load_calls) |
| f_agents = ex.submit(_load_agents) |
| calls = f_calls.result() |
| agents = f_agents.result() |
|
|
| total_calls = len(calls) |
| today = datetime.now(timezone.utc).date().isoformat() |
| today_calls = [c for c in calls if c.get("started_at", "").startswith(today)] |
| avg_dur = sum(c.get("duration_seconds", 0) for c in calls) / total_calls if total_calls else 0 |
|
|
| def count(field, val): return sum(1 for c in calls if c.get(field) == val) |
| pos = count("sentiment", "positive") |
| neu = count("sentiment", "neutral") |
| neg = count("sentiment", "negative") |
| hot = count("lead_tag", "hot") |
| warm = count("lead_tag", "warm") |
| cold = count("lead_tag", "cold") |
| success_rate = (count("status", "completed") / total_calls * 100) if total_calls else 0 |
|
|
| series = [] |
| for i in range(13, -1, -1): |
| day = (datetime.now(timezone.utc) - timedelta(days=i)).date().isoformat() |
| day_calls = [c for c in calls if c.get("started_at", "").startswith(day)] |
| series.append({"date": day, "calls": len(day_calls), "leads": sum(1 for c in day_calls if c.get("lead_tag") == "hot")}) |
|
|
| lang_counts: Dict[str, int] = {} |
| for a in agents: |
| lang = a.get("language", "English") |
| lang_counts[lang] = lang_counts.get(lang, 0) + 1 |
|
|
| |
| agent_stats: Dict[str, Dict[str, Any]] = {} |
| for c in calls: |
| aid = c.get("agent_id") |
| if not aid: |
| continue |
| s = agent_stats.setdefault(aid, { |
| "agent_id": aid, "agent_name": c.get("agent_name", "Unknown"), |
| "calls": 0, "total_duration": 0, "hot_leads": 0, |
| }) |
| s["calls"] += 1 |
| s["total_duration"] += c.get("duration_seconds", 0) or 0 |
| if c.get("lead_tag") == "hot": |
| s["hot_leads"] += 1 |
| leaderboard = sorted([ |
| { |
| "agent_id": s["agent_id"], "agent_name": s["agent_name"], |
| "calls": s["calls"], "avg_duration": round(s["total_duration"] / s["calls"], 1) if s["calls"] else 0, |
| "hot_leads": s["hot_leads"], |
| } |
| for s in agent_stats.values() |
| ], key=lambda x: x["calls"], reverse=True)[:10] |
|
|
| |
| recent_calls = sorted(calls, key=lambda x: x.get("started_at", ""), reverse=True)[:20] |
|
|
| overview = { |
| "kpis": { |
| "active_agents": sum(1 for a in agents if a.get("status") == "active"), |
| "calls_today": len(today_calls), |
| "calls_total": total_calls, |
| "avg_duration": round(avg_dur, 1), |
| "success_rate": round(success_rate, 1), |
| "hot_leads": hot, |
| }, |
| "sentiment": [{"name": "Positive", "value": pos}, {"name": "Neutral", "value": neu}, {"name": "Negative", "value": neg}], |
| "leads": [{"name": "Hot", "value": hot}, {"name": "Warm", "value": warm}, {"name": "Cold", "value": cold}], |
| "series": series, |
| "language_usage": [{"name": k, "value": v} for k, v in lang_counts.items()], |
| } |
| return {"overview": overview, "leaderboard": leaderboard, "recent_calls": recent_calls} |
|
|
|
|
| @api_router.get("/dashboard") |
| def get_dashboard(user: dict = Depends(get_current_user)): |
| """Single endpoint returning all dashboard data with one Firestore scan. |
| Stale-while-revalidate: returns instantly from the last value while a fresh |
| scan runs in the background, so the dashboard never blocks on Firestore.""" |
| uid = user["id"] |
|
|
| def _build(): |
| data = _build_dashboard_data(uid) |
| _cache_set(f"analytics:{uid}:overview", data["overview"], ttl=60) |
| _cache_set(f"leaderboard:{uid}", data["leaderboard"], ttl=60) |
| return data |
|
|
| return _swr(f"dashboard:{uid}", _build, fresh_ttl=60) |
|
|
|
|
| @api_router.get("/analytics/overview") |
| def analytics_overview(user: dict = Depends(get_current_user)): |
| uid = user["id"] |
| cached = _cache_get(f"analytics:{uid}:overview") |
| if cached is not None: |
| return cached |
| data = _build_dashboard_data(uid) |
| _cache_set(f"leaderboard:{uid}", data["leaderboard"], ttl=60) |
| _cache_set(f"dashboard:{uid}", data, ttl=60) |
| return _cache_set(f"analytics:{uid}:overview", data["overview"], ttl=60) |
|
|
|
|
| @api_router.get("/analytics/agent-leaderboard") |
| def agent_leaderboard(user: dict = Depends(get_current_user)): |
| uid = user["id"] |
| cached = _cache_get(f"leaderboard:{uid}") |
| if cached is not None: |
| return cached |
| data = _build_dashboard_data(uid) |
| _cache_set(f"analytics:{uid}:overview", data["overview"], ttl=60) |
| _cache_set(f"dashboard:{uid}", data, ttl=60) |
| return _cache_set(f"leaderboard:{uid}", data["leaderboard"], ttl=60) |
|
|
|
|
| |
| @app.on_event("startup") |
| async def on_startup(): |
| logger.info("Scatter Studio API starting (Firebase + LiveKit)") |
|
|
|
|
| @app.on_event("shutdown") |
| async def shutdown_db_client(): |
| pass |
|
|
|
|
| @api_router.get("/") |
| async def root(): |
| return {"service": "Scatter Studio API", "status": "ok"} |
|
|
|
|
| @app.get("/") |
| async def app_root(): |
| return {"service": "Scatter Studio API", "status": "ok"} |
|
|
|
|
| @app.head("/") |
| async def app_root_head(): |
| return Response(status_code=200) |
|
|
|
|
| app.include_router(api_router) |
|
|