Spaces:
Running
Running
Commit Β·
3d1332e
0
Parent(s):
Deploy RevAI API
Browse filesThis view is limited to 50 files because it contains too many changes. Β See raw diff
- .dockerignore +12 -0
- Dockerfile +17 -0
- README.md +34 -0
- app/__init__.py +0 -0
- app/__pycache__/__init__.cpython-312.pyc +0 -0
- app/__pycache__/config.cpython-312.pyc +0 -0
- app/__pycache__/database.cpython-312.pyc +0 -0
- app/__pycache__/dependencies.cpython-312.pyc +0 -0
- app/__pycache__/main.cpython-312.pyc +0 -0
- app/config.py +43 -0
- app/database.py +24 -0
- app/dependencies.py +219 -0
- app/main.py +80 -0
- app/models/__init__.py +8 -0
- app/models/__pycache__/__init__.cpython-312.pyc +0 -0
- app/models/__pycache__/apikey.cpython-312.pyc +0 -0
- app/models/__pycache__/benchmark.cpython-312.pyc +0 -0
- app/models/__pycache__/mlmodel.cpython-312.pyc +0 -0
- app/models/__pycache__/usage.cpython-312.pyc +0 -0
- app/models/__pycache__/user.cpython-312.pyc +0 -0
- app/models/apikey.py +20 -0
- app/models/benchmark.py +29 -0
- app/models/mlmodel.py +34 -0
- app/models/usage.py +18 -0
- app/models/user.py +23 -0
- app/routers/__init__.py +0 -0
- app/routers/__pycache__/__init__.cpython-312.pyc +0 -0
- app/routers/__pycache__/audio.cpython-312.pyc +0 -0
- app/routers/__pycache__/auth.cpython-312.pyc +0 -0
- app/routers/__pycache__/benchmarks.cpython-312.pyc +0 -0
- app/routers/__pycache__/predict.cpython-312.pyc +0 -0
- app/routers/__pycache__/training.cpython-312.pyc +0 -0
- app/routers/__pycache__/usage.cpython-312.pyc +0 -0
- app/routers/audio.py +114 -0
- app/routers/auth.py +116 -0
- app/routers/benchmarks.py +18 -0
- app/routers/predict.py +186 -0
- app/routers/training.py +120 -0
- app/routers/usage.py +29 -0
- app/schemas/__init__.py +124 -0
- app/schemas/__pycache__/__init__.cpython-312.pyc +0 -0
- app/services/__init__.py +0 -0
- app/services/__pycache__/__init__.cpython-312.pyc +0 -0
- app/services/__pycache__/audio.cpython-312.pyc +0 -0
- app/services/__pycache__/benchmarking.cpython-312.pyc +0 -0
- app/services/__pycache__/scoring.cpython-312.pyc +0 -0
- app/services/__pycache__/training.cpython-312.pyc +0 -0
- app/services/audio.py +105 -0
- app/services/benchmarking.py +192 -0
- app/services/scoring.py +196 -0
.dockerignore
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
__pycache__
|
| 2 |
+
*.pyc
|
| 3 |
+
.env
|
| 4 |
+
*.db
|
| 5 |
+
revai.db
|
| 6 |
+
*.log
|
| 7 |
+
tmp/
|
| 8 |
+
tests/
|
| 9 |
+
test_*.py
|
| 10 |
+
postman_collection.json
|
| 11 |
+
sample_data/
|
| 12 |
+
models/
|
Dockerfile
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.12-slim
|
| 2 |
+
WORKDIR /app
|
| 3 |
+
|
| 4 |
+
RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/*
|
| 5 |
+
|
| 6 |
+
COPY requirements.txt .
|
| 7 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 8 |
+
|
| 9 |
+
COPY app/ app/
|
| 10 |
+
|
| 11 |
+
ENV RAPIDAPI_PROXY_SECRET=18bb8fa3b320c4e75d58116528f45f9e580a67970fbef4e390d9729842a155ca
|
| 12 |
+
ENV DATABASE_URL=sqlite:///./revai.db
|
| 13 |
+
ENV SECRET_KEY=revai-production-hf-secret-key-2026
|
| 14 |
+
|
| 15 |
+
EXPOSE 7860
|
| 16 |
+
HEALTHCHECK --interval=30s --timeout=5s CMD curl -f http://localhost:7860/v1/health || exit 1
|
| 17 |
+
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7860"]
|
README.md
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: RevAI API
|
| 3 |
+
emoji: π
|
| 4 |
+
colorFrom: indigo
|
| 5 |
+
colorTo: blue
|
| 6 |
+
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
+
pinned: false
|
| 9 |
+
license: mit
|
| 10 |
+
---
|
| 11 |
+
|
| 12 |
+
# RevAI β Churn Prediction & Lead Scoring API
|
| 13 |
+
|
| 14 |
+
AI-powered churn prediction and lead scoring API.
|
| 15 |
+
|
| 16 |
+
### Quick Start
|
| 17 |
+
```bash
|
| 18 |
+
curl https://<username>-revai-api.hf.space/v1/health
|
| 19 |
+
```
|
| 20 |
+
|
| 21 |
+
### Features
|
| 22 |
+
- Churn prediction with explainable heuristic rules
|
| 23 |
+
- Lead scoring with priority tiers (Hot/Warm/Cold)
|
| 24 |
+
- Custom XGBoost model training
|
| 25 |
+
- Anonymized industry benchmarks
|
| 26 |
+
- Support call audio analysis (Whisper + NLP sentiment)
|
| 27 |
+
|
| 28 |
+
### Pricing
|
| 29 |
+
Free tier: 100 predictions/month on RapidAPI.
|
| 30 |
+
See full pricing at rapidapi.com.
|
| 31 |
+
|
| 32 |
+
---
|
| 33 |
+
|
| 34 |
+
Built with FastAPI + XGBoost + VADER Sentiment
|
app/__init__.py
ADDED
|
File without changes
|
app/__pycache__/__init__.cpython-312.pyc
ADDED
|
Binary file (133 Bytes). View file
|
|
|
app/__pycache__/config.cpython-312.pyc
ADDED
|
Binary file (1.96 kB). View file
|
|
|
app/__pycache__/database.cpython-312.pyc
ADDED
|
Binary file (1.35 kB). View file
|
|
|
app/__pycache__/dependencies.cpython-312.pyc
ADDED
|
Binary file (11.2 kB). View file
|
|
|
app/__pycache__/main.cpython-312.pyc
ADDED
|
Binary file (3.1 kB). View file
|
|
|
app/config.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pydantic_settings import BaseSettings
|
| 2 |
+
from functools import lru_cache
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
class Settings(BaseSettings):
|
| 6 |
+
app_name: str = "RevAI API"
|
| 7 |
+
version: str = "1.0.0"
|
| 8 |
+
debug: bool = True
|
| 9 |
+
|
| 10 |
+
database_url: str = "sqlite:///./revai.db"
|
| 11 |
+
secret_key: str = "change-me-in-production-use-a-long-random-string"
|
| 12 |
+
algorithm: str = "HS256"
|
| 13 |
+
access_token_expire_minutes: int = 1440
|
| 14 |
+
|
| 15 |
+
stripe_secret_key: str = ""
|
| 16 |
+
stripe_webhook_secret: str = ""
|
| 17 |
+
|
| 18 |
+
# RapidAPI integration
|
| 19 |
+
rapidapi_proxy_secret: str = ""
|
| 20 |
+
|
| 21 |
+
# Tier limits
|
| 22 |
+
free_predictions: int = 100
|
| 23 |
+
maker_predictions: int = 5000
|
| 24 |
+
growth_predictions: int = 50000
|
| 25 |
+
scale_predictions: int = 500000
|
| 26 |
+
|
| 27 |
+
free_models: int = 1
|
| 28 |
+
maker_models: int = 3
|
| 29 |
+
growth_models: int = 10
|
| 30 |
+
scale_models: int = 999
|
| 31 |
+
|
| 32 |
+
free_rpm: int = 60
|
| 33 |
+
maker_rpm: int = 100
|
| 34 |
+
growth_rpm: int = 500
|
| 35 |
+
scale_rpm: int = 2000
|
| 36 |
+
|
| 37 |
+
class Config:
|
| 38 |
+
env_file = ".env"
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
@lru_cache()
|
| 42 |
+
def get_settings() -> Settings:
|
| 43 |
+
return Settings()
|
app/database.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from sqlalchemy import create_engine
|
| 2 |
+
from sqlalchemy.orm import sessionmaker, DeclarativeBase
|
| 3 |
+
from app.config import get_settings
|
| 4 |
+
|
| 5 |
+
settings = get_settings()
|
| 6 |
+
engine = create_engine(settings.database_url, connect_args={"check_same_thread": False})
|
| 7 |
+
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class Base(DeclarativeBase):
|
| 11 |
+
pass
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def get_db():
|
| 15 |
+
db = SessionLocal()
|
| 16 |
+
try:
|
| 17 |
+
yield db
|
| 18 |
+
finally:
|
| 19 |
+
db.close()
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def init_db():
|
| 23 |
+
from app.models import user, apikey, usage, mlmodel, benchmark # noqa: F401
|
| 24 |
+
Base.metadata.create_all(bind=engine)
|
app/dependencies.py
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import hashlib
|
| 2 |
+
import time
|
| 3 |
+
import datetime
|
| 4 |
+
import uuid
|
| 5 |
+
from fastapi import Depends, HTTPException, Header, Security, Request
|
| 6 |
+
from fastapi.security import APIKeyHeader
|
| 7 |
+
from sqlalchemy.orm import Session
|
| 8 |
+
from sqlalchemy import func
|
| 9 |
+
from app.database import get_db
|
| 10 |
+
from app.models.user import User
|
| 11 |
+
from app.models.apikey import APIKey
|
| 12 |
+
from app.models.usage import UsageRecord
|
| 13 |
+
from app.config import get_settings
|
| 14 |
+
|
| 15 |
+
settings = get_settings()
|
| 16 |
+
api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)
|
| 17 |
+
|
| 18 |
+
RAPIDAPI_PLAN_MAP = {
|
| 19 |
+
"BASIC": "free", "FREE": "free",
|
| 20 |
+
"MAKER": "maker", "PRO": "maker",
|
| 21 |
+
"GROWTH": "growth",
|
| 22 |
+
"SCALE": "scale", "ULTRA": "scale", "MEGA": "scale",
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def hash_api_key(key: str) -> str:
|
| 27 |
+
return hashlib.sha256(key.encode()).hexdigest()
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def get_current_user(
|
| 31 |
+
request: Request,
|
| 32 |
+
x_api_key: str = Security(api_key_header),
|
| 33 |
+
db: Session = Depends(get_db),
|
| 34 |
+
) -> User:
|
| 35 |
+
# ββ RapidAPI proxy auth ββ
|
| 36 |
+
proxy_secret = request.headers.get("X-RapidAPI-Proxy-Secret", "")
|
| 37 |
+
rapidapi_user_id = request.headers.get("X-RapidAPI-User", "")
|
| 38 |
+
rapidapi_plan = request.headers.get("X-RapidAPI-Subscription", "FREE").upper()
|
| 39 |
+
|
| 40 |
+
if proxy_secret and settings.rapidapi_proxy_secret:
|
| 41 |
+
if proxy_secret != settings.rapidapi_proxy_secret:
|
| 42 |
+
raise HTTPException(status_code=401, detail="Invalid RapidAPI proxy secret")
|
| 43 |
+
if not rapidapi_user_id:
|
| 44 |
+
raise HTTPException(status_code=401, detail="Missing X-RapidAPI-User header")
|
| 45 |
+
|
| 46 |
+
user = db.query(User).filter(User.email == f"rapidapi:{rapidapi_user_id}").first()
|
| 47 |
+
if not user:
|
| 48 |
+
tier = RAPIDAPI_PLAN_MAP.get(rapidapi_plan, "free")
|
| 49 |
+
user = User(
|
| 50 |
+
email=f"rapidapi:{rapidapi_user_id}",
|
| 51 |
+
hashed_password="rapidapi-proxy-auth",
|
| 52 |
+
name=f"RapidAPI User {rapidapi_user_id[:12]}",
|
| 53 |
+
tier=tier,
|
| 54 |
+
)
|
| 55 |
+
db.add(user)
|
| 56 |
+
db.commit()
|
| 57 |
+
db.refresh(user)
|
| 58 |
+
else:
|
| 59 |
+
new_tier = RAPIDAPI_PLAN_MAP.get(rapidapi_plan, "free")
|
| 60 |
+
if user.tier != new_tier:
|
| 61 |
+
user.tier = new_tier
|
| 62 |
+
db.commit()
|
| 63 |
+
return user
|
| 64 |
+
|
| 65 |
+
# ββ Direct API key auth ββ
|
| 66 |
+
if not x_api_key:
|
| 67 |
+
raise HTTPException(status_code=401, detail="Missing X-API-Key header")
|
| 68 |
+
|
| 69 |
+
key_hash = hash_api_key(x_api_key)
|
| 70 |
+
api_key = db.query(APIKey).filter(
|
| 71 |
+
APIKey.key_hash == key_hash,
|
| 72 |
+
APIKey.is_active == True
|
| 73 |
+
).first()
|
| 74 |
+
|
| 75 |
+
if not api_key:
|
| 76 |
+
raise HTTPException(status_code=401, detail="Invalid or inactive API key")
|
| 77 |
+
|
| 78 |
+
api_key.last_used_at = datetime.datetime.utcnow()
|
| 79 |
+
db.commit()
|
| 80 |
+
|
| 81 |
+
user = db.query(User).filter(User.id == api_key.user_id).first()
|
| 82 |
+
if not user or not user.is_active:
|
| 83 |
+
raise HTTPException(status_code=403, detail="Account is inactive")
|
| 84 |
+
|
| 85 |
+
return user
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def check_rate_limit(user: User, db: Session, endpoint: str, request_count: int = 1):
|
| 89 |
+
"""Check if user has exceeded rate limits for their tier."""
|
| 90 |
+
tier_limits = {
|
| 91 |
+
"free": settings.free_rpm, "maker": settings.maker_rpm,
|
| 92 |
+
"growth": settings.growth_rpm, "scale": settings.scale_rpm, "enterprise": 10000
|
| 93 |
+
}
|
| 94 |
+
per_minute_limit = tier_limits.get(user.tier, 10)
|
| 95 |
+
|
| 96 |
+
# Check recent requests in last 60 seconds
|
| 97 |
+
cutoff = datetime.datetime.utcnow() - datetime.timedelta(seconds=60)
|
| 98 |
+
recent = db.query(func.sum(UsageRecord.count)).filter(
|
| 99 |
+
UsageRecord.user_id == user.id,
|
| 100 |
+
UsageRecord.created_at >= cutoff
|
| 101 |
+
).scalar() or 0
|
| 102 |
+
|
| 103 |
+
if recent + request_count > per_minute_limit:
|
| 104 |
+
raise HTTPException(
|
| 105 |
+
status_code=429,
|
| 106 |
+
detail=f"Rate limit exceeded β quota reached. Tier '{user.tier}' allows {per_minute_limit} requests/minute."
|
| 107 |
+
)
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def check_prediction_quota(user: User, db: Session, count: int = 1):
|
| 111 |
+
"""Check if user has remaining predictions this month."""
|
| 112 |
+
tier_limits = {
|
| 113 |
+
"free": settings.free_predictions,
|
| 114 |
+
"maker": settings.maker_predictions,
|
| 115 |
+
"growth": settings.growth_predictions,
|
| 116 |
+
"scale": settings.scale_predictions,
|
| 117 |
+
"enterprise": 10_000_000,
|
| 118 |
+
}
|
| 119 |
+
limit = tier_limits.get(user.tier, 100)
|
| 120 |
+
month_str = datetime.datetime.utcnow().strftime("%Y-%m")
|
| 121 |
+
|
| 122 |
+
used = db.query(func.sum(UsageRecord.count)).filter(
|
| 123 |
+
UsageRecord.user_id == user.id,
|
| 124 |
+
UsageRecord.month == month_str,
|
| 125 |
+
UsageRecord.endpoint.in_(["predict/churn", "predict/lead", "analyze/call"])
|
| 126 |
+
).scalar() or 0
|
| 127 |
+
|
| 128 |
+
if used + count > limit:
|
| 129 |
+
raise HTTPException(
|
| 130 |
+
status_code=429,
|
| 131 |
+
detail=f"Monthly prediction quota exceeded. Tier '{user.tier}': {used}/{limit} predictions used this month."
|
| 132 |
+
)
|
| 133 |
+
|
| 134 |
+
return used, limit
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
def check_model_quota(user: User, db: Session):
|
| 138 |
+
"""Check if user can create more custom models."""
|
| 139 |
+
tier_limits = {
|
| 140 |
+
"free": settings.free_models,
|
| 141 |
+
"maker": settings.maker_models,
|
| 142 |
+
"growth": settings.growth_models,
|
| 143 |
+
"scale": settings.scale_models,
|
| 144 |
+
"enterprise": 10000,
|
| 145 |
+
}
|
| 146 |
+
limit = tier_limits.get(user.tier, 0)
|
| 147 |
+
current = db.query(MLModel).filter(MLModel.user_id == user.id).count()
|
| 148 |
+
|
| 149 |
+
if current >= limit:
|
| 150 |
+
raise HTTPException(
|
| 151 |
+
status_code=429,
|
| 152 |
+
detail=f"Model quota exceeded. Tier '{user.tier}': {current}/{limit} models used."
|
| 153 |
+
)
|
| 154 |
+
|
| 155 |
+
return current, limit
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
def track_usage(user: User, db: Session, endpoint: str, count: int = 1):
|
| 159 |
+
month_str = datetime.datetime.utcnow().strftime("%Y-%m")
|
| 160 |
+
record = UsageRecord(
|
| 161 |
+
user_id=user.id,
|
| 162 |
+
endpoint=endpoint,
|
| 163 |
+
count=count,
|
| 164 |
+
month=month_str,
|
| 165 |
+
)
|
| 166 |
+
db.add(record)
|
| 167 |
+
db.commit()
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
def get_usage_summary(user: User, db: Session) -> dict:
|
| 171 |
+
month_str = datetime.datetime.utcnow().strftime("%Y-%m")
|
| 172 |
+
tier_limits = {
|
| 173 |
+
"free": settings.free_predictions,
|
| 174 |
+
"maker": settings.maker_predictions,
|
| 175 |
+
"growth": settings.growth_predictions,
|
| 176 |
+
"scale": settings.scale_predictions,
|
| 177 |
+
"enterprise": 10_000_000,
|
| 178 |
+
}
|
| 179 |
+
model_limits = {
|
| 180 |
+
"free": settings.free_models,
|
| 181 |
+
"maker": settings.maker_models,
|
| 182 |
+
"growth": settings.growth_models,
|
| 183 |
+
"scale": settings.scale_models,
|
| 184 |
+
"enterprise": 10000,
|
| 185 |
+
}
|
| 186 |
+
|
| 187 |
+
predictions_used = db.query(func.sum(UsageRecord.count)).filter(
|
| 188 |
+
UsageRecord.user_id == user.id,
|
| 189 |
+
UsageRecord.month == month_str,
|
| 190 |
+
UsageRecord.endpoint.in_(["predict/churn", "predict/lead", "analyze/call"])
|
| 191 |
+
).scalar() or 0
|
| 192 |
+
|
| 193 |
+
models_used = db.query(MLModel).filter(MLModel.user_id == user.id).count()
|
| 194 |
+
|
| 195 |
+
usage_by_endpoint = {}
|
| 196 |
+
records = db.query(UsageRecord).filter(
|
| 197 |
+
UsageRecord.user_id == user.id,
|
| 198 |
+
UsageRecord.month == month_str
|
| 199 |
+
).all()
|
| 200 |
+
for r in records:
|
| 201 |
+
usage_by_endpoint[r.endpoint] = usage_by_endpoint.get(r.endpoint, 0) + r.count
|
| 202 |
+
|
| 203 |
+
pred_limit = tier_limits.get(user.tier, 100)
|
| 204 |
+
model_limit = model_limits.get(user.tier, 0)
|
| 205 |
+
|
| 206 |
+
return {
|
| 207 |
+
"tier": user.tier,
|
| 208 |
+
"predictions_used": predictions_used,
|
| 209 |
+
"predictions_limit": pred_limit,
|
| 210 |
+
"remaining_predictions": max(0, pred_limit - predictions_used),
|
| 211 |
+
"models_used": models_used,
|
| 212 |
+
"models_limit": model_limit,
|
| 213 |
+
"remaining_models": max(0, model_limit - models_used),
|
| 214 |
+
"usage_by_endpoint": usage_by_endpoint,
|
| 215 |
+
}
|
| 216 |
+
|
| 217 |
+
|
| 218 |
+
# Import MLModel at the bottom to avoid circular imports
|
| 219 |
+
from app.models.mlmodel import MLModel
|
app/main.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import FastAPI
|
| 2 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 3 |
+
from contextlib import asynccontextmanager
|
| 4 |
+
|
| 5 |
+
from app.config import get_settings
|
| 6 |
+
from app.database import init_db
|
| 7 |
+
from app.routers import auth, predict, training, audio, usage, benchmarks
|
| 8 |
+
|
| 9 |
+
settings = get_settings()
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
@asynccontextmanager
|
| 13 |
+
async def lifespan(app: FastAPI):
|
| 14 |
+
init_db()
|
| 15 |
+
yield
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
app = FastAPI(
|
| 19 |
+
title="RevAI API",
|
| 20 |
+
description="""
|
| 21 |
+
## Churn Prediction & Lead Scoring API
|
| 22 |
+
|
| 23 |
+
### Core Features
|
| 24 |
+
- **Predict churn**: Send customer data, get churn risk scores + explanations
|
| 25 |
+
- **Score leads**: Prioritize sales pipeline by conversion probability
|
| 26 |
+
- **Train custom models**: Upload labeled data, get a company-specific XGBoost model
|
| 27 |
+
- **Analyze calls**: Transcribe support/sales calls, detect churn intent signals
|
| 28 |
+
|
| 29 |
+
### Authentication
|
| 30 |
+
All endpoints (except `/v1/health`) require an API key header:
|
| 31 |
+
```
|
| 32 |
+
X-API-Key: revai_live_...
|
| 33 |
+
```
|
| 34 |
+
|
| 35 |
+
Get your key by registering at `/v1/auth/register`.
|
| 36 |
+
|
| 37 |
+
### Rate Limits
|
| 38 |
+
| Tier | Predictions/mo | Models | Req/min |
|
| 39 |
+
|---------|---------------|--------|---------|
|
| 40 |
+
| Free | 100 | 0 | 10 |
|
| 41 |
+
| Maker | 5,000 | 3 | 100 |
|
| 42 |
+
| Growth | 50,000 | 10 | 500 |
|
| 43 |
+
| Scale | 500,000 | Unlim | 2,000 |
|
| 44 |
+
|
| 45 |
+
### Pricing
|
| 46 |
+
Visit [RevAI on Payhip](https://payhip.com) to subscribe.
|
| 47 |
+
""",
|
| 48 |
+
version=settings.version,
|
| 49 |
+
lifespan=lifespan,
|
| 50 |
+
docs_url="/docs",
|
| 51 |
+
redoc_url="/redoc",
|
| 52 |
+
openapi_url="/openapi.json",
|
| 53 |
+
)
|
| 54 |
+
|
| 55 |
+
app.add_middleware(
|
| 56 |
+
CORSMiddleware,
|
| 57 |
+
allow_origins=["*"],
|
| 58 |
+
allow_credentials=True,
|
| 59 |
+
allow_methods=["*"],
|
| 60 |
+
allow_headers=["*"],
|
| 61 |
+
)
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
@app.get("/")
|
| 65 |
+
@app.get("/_health")
|
| 66 |
+
async def root_health():
|
| 67 |
+
return {"status": "ok", "api": "RevAI", "version": settings.version}
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
app.include_router(auth.router)
|
| 71 |
+
app.include_router(predict.router)
|
| 72 |
+
app.include_router(training.router)
|
| 73 |
+
app.include_router(audio.router)
|
| 74 |
+
app.include_router(benchmarks.router)
|
| 75 |
+
app.include_router(usage.router)
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
if __name__ == "__main__":
|
| 79 |
+
import uvicorn
|
| 80 |
+
uvicorn.run("app.main:app", host="0.0.0.0", port=8000, reload=settings.debug)
|
app/models/__init__.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from app.database import Base
|
| 2 |
+
from app.models.user import User
|
| 3 |
+
from app.models.apikey import APIKey
|
| 4 |
+
from app.models.usage import UsageRecord
|
| 5 |
+
from app.models.mlmodel import MLModel
|
| 6 |
+
from app.models.benchmark import Benchmark
|
| 7 |
+
|
| 8 |
+
__all__ = ["User", "APIKey", "UsageRecord", "MLModel", "Benchmark", "Base"]
|
app/models/__pycache__/__init__.cpython-312.pyc
ADDED
|
Binary file (491 Bytes). View file
|
|
|
app/models/__pycache__/apikey.cpython-312.pyc
ADDED
|
Binary file (1.38 kB). View file
|
|
|
app/models/__pycache__/benchmark.cpython-312.pyc
ADDED
|
Binary file (2.14 kB). View file
|
|
|
app/models/__pycache__/mlmodel.cpython-312.pyc
ADDED
|
Binary file (2.25 kB). View file
|
|
|
app/models/__pycache__/usage.cpython-312.pyc
ADDED
|
Binary file (1.28 kB). View file
|
|
|
app/models/__pycache__/user.cpython-312.pyc
ADDED
|
Binary file (1.56 kB). View file
|
|
|
app/models/apikey.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import uuid
|
| 2 |
+
import datetime
|
| 3 |
+
from sqlalchemy import Column, String, DateTime, Boolean, ForeignKey
|
| 4 |
+
from sqlalchemy.orm import relationship
|
| 5 |
+
from app.database import Base
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
class APIKey(Base):
|
| 9 |
+
__tablename__ = "api_keys"
|
| 10 |
+
|
| 11 |
+
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
|
| 12 |
+
user_id = Column(String, ForeignKey("users.id"), nullable=False)
|
| 13 |
+
key_hash = Column(String, unique=True, nullable=False)
|
| 14 |
+
prefix = Column(String, nullable=False) # revai_live_abc123 β store "revai_live_abc..."
|
| 15 |
+
name = Column(String, default="Default")
|
| 16 |
+
is_active = Column(Boolean, default=True)
|
| 17 |
+
created_at = Column(DateTime, default=datetime.datetime.utcnow)
|
| 18 |
+
last_used_at = Column(DateTime, nullable=True)
|
| 19 |
+
|
| 20 |
+
user = relationship("User", back_populates="api_keys")
|
app/models/benchmark.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import datetime
|
| 3 |
+
from sqlalchemy import Column, String, DateTime, Text, Integer, Float
|
| 4 |
+
from app.database import Base
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
class Benchmark(Base):
|
| 8 |
+
__tablename__ = "benchmarks"
|
| 9 |
+
|
| 10 |
+
id = Column(String, primary_key=True, default="global") # single row for global benchmarks
|
| 11 |
+
churn_data_json = Column(Text, default="{}") # aggregated churn score distribution
|
| 12 |
+
lead_data_json = Column(Text, default="{}") # aggregated lead score distribution
|
| 13 |
+
total_churn_scored = Column(Integer, default=0)
|
| 14 |
+
total_lead_scored = Column(Integer, default=0)
|
| 15 |
+
unique_churn_companies = Column(Integer, default=0)
|
| 16 |
+
unique_lead_companies = Column(Integer, default=0)
|
| 17 |
+
updated_at = Column(DateTime, default=datetime.datetime.utcnow)
|
| 18 |
+
|
| 19 |
+
def get_churn_data(self) -> dict:
|
| 20 |
+
return json.loads(self.churn_data_json) if self.churn_data_json else {}
|
| 21 |
+
|
| 22 |
+
def get_lead_data(self) -> dict:
|
| 23 |
+
return json.loads(self.lead_data_json) if self.lead_data_json else {}
|
| 24 |
+
|
| 25 |
+
def set_churn_data(self, data: dict):
|
| 26 |
+
self.churn_data_json = json.dumps(data)
|
| 27 |
+
|
| 28 |
+
def set_lead_data(self, data: dict):
|
| 29 |
+
self.lead_data_json = json.dumps(data)
|
app/models/mlmodel.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import uuid
|
| 2 |
+
import datetime
|
| 3 |
+
import json
|
| 4 |
+
from sqlalchemy import Column, String, DateTime, Text, Integer, Float, ForeignKey
|
| 5 |
+
from sqlalchemy.orm import relationship
|
| 6 |
+
from app.database import Base
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class MLModel(Base):
|
| 10 |
+
__tablename__ = "ml_models"
|
| 11 |
+
|
| 12 |
+
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
|
| 13 |
+
user_id = Column(String, ForeignKey("users.id"), nullable=False)
|
| 14 |
+
name = Column(String, nullable=False)
|
| 15 |
+
model_type = Column(String, nullable=False) # churn, lead
|
| 16 |
+
feature_names_json = Column(Text, nullable=False) # JSON list of feature names
|
| 17 |
+
encoders_json = Column(Text, nullable=False) # JSON dict of label encoders
|
| 18 |
+
model_binary = Column(Text, nullable=False) # base64 encoded XGBoost model
|
| 19 |
+
metrics_json = Column(Text, default="{}") # accuracy, roc_auc, etc.
|
| 20 |
+
summary_json = Column(Text, default="{}") # n_rows, n_features, etc.
|
| 21 |
+
n_rows = Column(Integer, default=0)
|
| 22 |
+
n_features = Column(Integer, default=0)
|
| 23 |
+
created_at = Column(DateTime, default=datetime.datetime.utcnow)
|
| 24 |
+
|
| 25 |
+
user = relationship("User", back_populates="ml_models")
|
| 26 |
+
|
| 27 |
+
def get_feature_names(self):
|
| 28 |
+
return json.loads(self.feature_names_json)
|
| 29 |
+
|
| 30 |
+
def get_metrics(self):
|
| 31 |
+
return json.loads(self.metrics_json)
|
| 32 |
+
|
| 33 |
+
def get_summary(self):
|
| 34 |
+
return json.loads(self.summary_json)
|
app/models/usage.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import uuid
|
| 2 |
+
import datetime
|
| 3 |
+
from sqlalchemy import Column, String, DateTime, Integer, ForeignKey
|
| 4 |
+
from sqlalchemy.orm import relationship
|
| 5 |
+
from app.database import Base
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
class UsageRecord(Base):
|
| 9 |
+
__tablename__ = "usage_records"
|
| 10 |
+
|
| 11 |
+
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
|
| 12 |
+
user_id = Column(String, ForeignKey("users.id"), nullable=False)
|
| 13 |
+
endpoint = Column(String, nullable=False) # predict/churn, predict/lead, train, analyze/call
|
| 14 |
+
count = Column(Integer, default=1) # number of predictions/requests
|
| 15 |
+
month = Column(String, nullable=False) # "2026-05"
|
| 16 |
+
created_at = Column(DateTime, default=datetime.datetime.utcnow)
|
| 17 |
+
|
| 18 |
+
user = relationship("User", back_populates="usage_records")
|
app/models/user.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import uuid
|
| 2 |
+
import datetime
|
| 3 |
+
from sqlalchemy import Column, String, DateTime, Boolean
|
| 4 |
+
from sqlalchemy.orm import relationship
|
| 5 |
+
from app.database import Base
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
class User(Base):
|
| 9 |
+
__tablename__ = "users"
|
| 10 |
+
|
| 11 |
+
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
|
| 12 |
+
email = Column(String, unique=True, index=True, nullable=False)
|
| 13 |
+
hashed_password = Column(String, nullable=False)
|
| 14 |
+
name = Column(String, default="")
|
| 15 |
+
tier = Column(String, default="free") # free, maker, growth, scale, enterprise
|
| 16 |
+
stripe_customer_id = Column(String, default="")
|
| 17 |
+
stripe_subscription_id = Column(String, default="")
|
| 18 |
+
is_active = Column(Boolean, default=True)
|
| 19 |
+
created_at = Column(DateTime, default=datetime.datetime.utcnow)
|
| 20 |
+
|
| 21 |
+
api_keys = relationship("APIKey", back_populates="user", cascade="all, delete-orphan")
|
| 22 |
+
usage_records = relationship("UsageRecord", back_populates="user", cascade="all, delete-orphan")
|
| 23 |
+
ml_models = relationship("MLModel", back_populates="user", cascade="all, delete-orphan")
|
app/routers/__init__.py
ADDED
|
File without changes
|
app/routers/__pycache__/__init__.cpython-312.pyc
ADDED
|
Binary file (141 Bytes). View file
|
|
|
app/routers/__pycache__/audio.cpython-312.pyc
ADDED
|
Binary file (5.42 kB). View file
|
|
|
app/routers/__pycache__/auth.cpython-312.pyc
ADDED
|
Binary file (6.61 kB). View file
|
|
|
app/routers/__pycache__/benchmarks.cpython-312.pyc
ADDED
|
Binary file (936 Bytes). View file
|
|
|
app/routers/__pycache__/predict.cpython-312.pyc
ADDED
|
Binary file (6.96 kB). View file
|
|
|
app/routers/__pycache__/training.cpython-312.pyc
ADDED
|
Binary file (6 kB). View file
|
|
|
app/routers/__pycache__/usage.cpython-312.pyc
ADDED
|
Binary file (1.45 kB). View file
|
|
|
app/routers/audio.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form
|
| 2 |
+
from sqlalchemy.orm import Session
|
| 3 |
+
|
| 4 |
+
from app.database import get_db
|
| 5 |
+
from app.dependencies import get_current_user, check_rate_limit, check_prediction_quota, track_usage, get_usage_summary
|
| 6 |
+
from app.models.user import User
|
| 7 |
+
from app.schemas import CallAnalysisResponse, CallAnalysisItem
|
| 8 |
+
from app.services.audio import analyze_call
|
| 9 |
+
|
| 10 |
+
router = APIRouter(prefix="/v1/analyze", tags=["audio"])
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
@router.post("/call", response_model=CallAnalysisResponse)
|
| 14 |
+
async def analyze_call_endpoint(
|
| 15 |
+
file: UploadFile = File(...),
|
| 16 |
+
openai_api_key: str = Form(...),
|
| 17 |
+
user: User = Depends(get_current_user),
|
| 18 |
+
db: Session = Depends(get_db),
|
| 19 |
+
):
|
| 20 |
+
if not file.filename:
|
| 21 |
+
raise HTTPException(status_code=400, detail="No file provided")
|
| 22 |
+
|
| 23 |
+
allowed_ext = (".mp3", ".wav", ".m4a", ".mp4", ".ogg", ".webm")
|
| 24 |
+
if not file.filename.lower().endswith(allowed_ext):
|
| 25 |
+
raise HTTPException(status_code=400,
|
| 26 |
+
detail=f"Unsupported format. Allowed: {', '.join(allowed_ext)}")
|
| 27 |
+
|
| 28 |
+
check_rate_limit(user, db, "analyze/call", 1)
|
| 29 |
+
used, limit = check_prediction_quota(user, db, 1)
|
| 30 |
+
|
| 31 |
+
try:
|
| 32 |
+
audio_bytes = await file.read()
|
| 33 |
+
result = analyze_call(audio_bytes, file.filename, openai_api_key)
|
| 34 |
+
except Exception as e:
|
| 35 |
+
raise HTTPException(status_code=500, detail=f"Analysis failed: {str(e)}")
|
| 36 |
+
|
| 37 |
+
track_usage(user, db, "analyze/call", 1)
|
| 38 |
+
|
| 39 |
+
item = CallAnalysisItem(
|
| 40 |
+
filename=result["filename"],
|
| 41 |
+
transcript_snippet=result["transcript_snippet"],
|
| 42 |
+
sentiment_score=result["sentiment_score"],
|
| 43 |
+
sentiment_label=result["sentiment_label"],
|
| 44 |
+
churn_intent_score=float(result["churn_intent_score"]),
|
| 45 |
+
flagged_keywords=result["flagged_keywords"],
|
| 46 |
+
)
|
| 47 |
+
|
| 48 |
+
summary = {
|
| 49 |
+
"total_calls": 1,
|
| 50 |
+
"average_sentiment": result["sentiment_score"],
|
| 51 |
+
"high_churn_intent": result["churn_intent_score"] >= 50,
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
return CallAnalysisResponse(
|
| 55 |
+
results=[item],
|
| 56 |
+
summary=summary,
|
| 57 |
+
usage=get_usage_summary(user, db),
|
| 58 |
+
)
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
@router.post("/calls/batch", response_model=CallAnalysisResponse)
|
| 62 |
+
async def analyze_calls_batch(
|
| 63 |
+
files: list[UploadFile] = File(...),
|
| 64 |
+
openai_api_key: str = Form(...),
|
| 65 |
+
user: User = Depends(get_current_user),
|
| 66 |
+
db: Session = Depends(get_db),
|
| 67 |
+
):
|
| 68 |
+
if not files:
|
| 69 |
+
raise HTTPException(status_code=400, detail="No files provided")
|
| 70 |
+
|
| 71 |
+
n_files = len(files)
|
| 72 |
+
check_rate_limit(user, db, "analyze/call", n_files)
|
| 73 |
+
used, limit = check_prediction_quota(user, db, n_files)
|
| 74 |
+
|
| 75 |
+
results = []
|
| 76 |
+
errors = []
|
| 77 |
+
|
| 78 |
+
for f in files:
|
| 79 |
+
if not f.filename.lower().endswith((".mp3", ".wav", ".m4a", ".mp4", ".ogg", ".webm")):
|
| 80 |
+
errors.append(f"Skipped {f.filename}: unsupported format")
|
| 81 |
+
continue
|
| 82 |
+
try:
|
| 83 |
+
audio_bytes = await f.read()
|
| 84 |
+
result = analyze_call(audio_bytes, f.filename, openai_api_key)
|
| 85 |
+
results.append(CallAnalysisItem(
|
| 86 |
+
filename=result["filename"],
|
| 87 |
+
transcript_snippet=result["transcript_snippet"],
|
| 88 |
+
sentiment_score=result["sentiment_score"],
|
| 89 |
+
sentiment_label=result["sentiment_label"],
|
| 90 |
+
churn_intent_score=float(result["churn_intent_score"]),
|
| 91 |
+
flagged_keywords=result["flagged_keywords"],
|
| 92 |
+
))
|
| 93 |
+
except Exception as e:
|
| 94 |
+
errors.append(f"Failed {f.filename}: {str(e)}")
|
| 95 |
+
|
| 96 |
+
processed = len(results)
|
| 97 |
+
track_usage(user, db, "analyze/call", processed)
|
| 98 |
+
|
| 99 |
+
avg_sentiment = sum(r.sentiment_score for r in results) / max(processed, 1)
|
| 100 |
+
high_intent = sum(1 for r in results if r.churn_intent_score >= 50)
|
| 101 |
+
|
| 102 |
+
summary = {
|
| 103 |
+
"total_calls": n_files,
|
| 104 |
+
"processed": processed,
|
| 105 |
+
"errors": errors,
|
| 106 |
+
"average_sentiment": round(avg_sentiment, 4),
|
| 107 |
+
"high_churn_intent_count": high_intent,
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
return CallAnalysisResponse(
|
| 111 |
+
results=results,
|
| 112 |
+
summary=summary,
|
| 113 |
+
usage=get_usage_summary(user, db),
|
| 114 |
+
)
|
app/routers/auth.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import uuid
|
| 2 |
+
import datetime
|
| 3 |
+
import hashlib
|
| 4 |
+
import os
|
| 5 |
+
from fastapi import APIRouter, Depends, HTTPException
|
| 6 |
+
from sqlalchemy.orm import Session
|
| 7 |
+
from jose import jwt
|
| 8 |
+
|
| 9 |
+
from app.database import get_db
|
| 10 |
+
from app.models.user import User
|
| 11 |
+
from app.models.apikey import APIKey
|
| 12 |
+
from app.dependencies import hash_api_key, get_current_user
|
| 13 |
+
from app.schemas import UserRegister, UserLogin, TokenResponse, APIKeyResponse
|
| 14 |
+
from app.config import get_settings
|
| 15 |
+
|
| 16 |
+
router = APIRouter(prefix="/v1/auth", tags=["auth"])
|
| 17 |
+
settings = get_settings()
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def hash_password(password: str) -> str:
|
| 21 |
+
salt = os.urandom(16).hex()
|
| 22 |
+
h = hashlib.sha256((password + salt).encode()).hexdigest()
|
| 23 |
+
return f"{salt}${h}"
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def verify_password(password: str, hashed: str) -> bool:
|
| 27 |
+
salt, h = hashed.split("$", 1)
|
| 28 |
+
return hashlib.sha256((password + salt).encode()).hexdigest() == h
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def generate_api_key() -> tuple:
|
| 32 |
+
raw = f"revai_live_{uuid.uuid4().hex}{uuid.uuid4().hex[:8]}"
|
| 33 |
+
return raw, hash_api_key(raw)
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def create_token(user: User) -> str:
|
| 37 |
+
payload = {
|
| 38 |
+
"sub": user.id,
|
| 39 |
+
"email": user.email,
|
| 40 |
+
"tier": user.tier,
|
| 41 |
+
"exp": datetime.datetime.utcnow() + datetime.timedelta(minutes=settings.access_token_expire_minutes),
|
| 42 |
+
}
|
| 43 |
+
return jwt.encode(payload, settings.secret_key, algorithm=settings.algorithm)
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
@router.post("/register", response_model=TokenResponse)
|
| 47 |
+
def register(body: UserRegister, db: Session = Depends(get_db)):
|
| 48 |
+
existing = db.query(User).filter(User.email == body.email).first()
|
| 49 |
+
if existing:
|
| 50 |
+
raise HTTPException(status_code=409, detail="Email already registered")
|
| 51 |
+
|
| 52 |
+
user = User(
|
| 53 |
+
email=body.email,
|
| 54 |
+
hashed_password=hash_password(body.password),
|
| 55 |
+
name=body.name,
|
| 56 |
+
)
|
| 57 |
+
db.add(user)
|
| 58 |
+
db.flush()
|
| 59 |
+
|
| 60 |
+
raw_key, key_hash = generate_api_key()
|
| 61 |
+
api_key = APIKey(
|
| 62 |
+
user_id=user.id,
|
| 63 |
+
key_hash=key_hash,
|
| 64 |
+
prefix=raw_key[:20] + "...",
|
| 65 |
+
)
|
| 66 |
+
db.add(api_key)
|
| 67 |
+
db.commit()
|
| 68 |
+
|
| 69 |
+
token = create_token(user)
|
| 70 |
+
return TokenResponse(
|
| 71 |
+
access_token=token,
|
| 72 |
+
user_id=user.id,
|
| 73 |
+
email=user.email,
|
| 74 |
+
tier=user.tier,
|
| 75 |
+
api_key=raw_key,
|
| 76 |
+
)
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
@router.post("/login", response_model=TokenResponse)
|
| 80 |
+
def login(body: UserLogin, db: Session = Depends(get_db)):
|
| 81 |
+
user = db.query(User).filter(User.email == body.email).first()
|
| 82 |
+
if not user or not verify_password(body.password, user.hashed_password):
|
| 83 |
+
raise HTTPException(status_code=401, detail="Invalid email or password")
|
| 84 |
+
|
| 85 |
+
# Return existing active key or create new one
|
| 86 |
+
api_key = db.query(APIKey).filter(
|
| 87 |
+
APIKey.user_id == user.id, APIKey.is_active == True
|
| 88 |
+
).first()
|
| 89 |
+
|
| 90 |
+
raw_key = None
|
| 91 |
+
if not api_key:
|
| 92 |
+
raw_key, key_hash = generate_api_key()
|
| 93 |
+
api_key = APIKey(user_id=user.id, key_hash=key_hash, prefix=raw_key[:20] + "...")
|
| 94 |
+
db.add(api_key)
|
| 95 |
+
db.commit()
|
| 96 |
+
raw_key = raw_key
|
| 97 |
+
|
| 98 |
+
token = create_token(user)
|
| 99 |
+
return TokenResponse(
|
| 100 |
+
access_token=token,
|
| 101 |
+
user_id=user.id,
|
| 102 |
+
email=user.email,
|
| 103 |
+
tier=user.tier,
|
| 104 |
+
api_key=raw_key,
|
| 105 |
+
)
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
@router.get("/keys", response_model=list[APIKeyResponse])
|
| 109 |
+
def list_api_keys(user: User = Depends(get_current_user),
|
| 110 |
+
db: Session = Depends(get_db)):
|
| 111 |
+
keys = db.query(APIKey).filter(APIKey.user_id == user.id).all()
|
| 112 |
+
return [APIKeyResponse(
|
| 113 |
+
id=k.id, prefix=k.prefix, name=k.name,
|
| 114 |
+
is_active=k.is_active, created_at=k.created_at,
|
| 115 |
+
last_used_at=k.last_used_at
|
| 116 |
+
) for k in keys]
|
app/routers/benchmarks.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends
|
| 2 |
+
from sqlalchemy.orm import Session
|
| 3 |
+
|
| 4 |
+
from app.database import get_db
|
| 5 |
+
from app.dependencies import get_current_user
|
| 6 |
+
from app.models.user import User
|
| 7 |
+
from app.services.benchmarking import get_benchmarks
|
| 8 |
+
|
| 9 |
+
router = APIRouter(prefix="/v1/benchmarks", tags=["benchmarks"])
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
@router.get("")
|
| 13 |
+
def get_global_benchmarks(
|
| 14 |
+
user: User = Depends(get_current_user),
|
| 15 |
+
db: Session = Depends(get_db),
|
| 16 |
+
):
|
| 17 |
+
"""Get global anonymized benchmarks. Minimum 3 companies required for privacy."""
|
| 18 |
+
return get_benchmarks(db)
|
app/routers/predict.py
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import base64
|
| 3 |
+
import pickle
|
| 4 |
+
from fastapi import APIRouter, Depends, HTTPException
|
| 5 |
+
from sqlalchemy.orm import Session
|
| 6 |
+
|
| 7 |
+
from app.database import get_db
|
| 8 |
+
from app.dependencies import (
|
| 9 |
+
get_current_user, check_rate_limit, check_prediction_quota,
|
| 10 |
+
track_usage, get_usage_summary,
|
| 11 |
+
)
|
| 12 |
+
from app.models.user import User
|
| 13 |
+
from app.models.mlmodel import MLModel
|
| 14 |
+
from app.schemas import PredictionInput, PredictionResponse, SinglePrediction
|
| 15 |
+
from app.services.scoring import _churn_factor, _lead_factor
|
| 16 |
+
from app.services.training import predict_with_model
|
| 17 |
+
from app.services.benchmarking import update_benchmarks, compare_to_benchmark
|
| 18 |
+
|
| 19 |
+
router = APIRouter(prefix="/v1/predict", tags=["prediction"])
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def _apply_heuristics(data: list, model_type: str) -> list:
|
| 23 |
+
"""Apply heuristic rules to a list of data dicts."""
|
| 24 |
+
results = []
|
| 25 |
+
rule_func = _churn_factor if model_type == "churn" else _lead_factor
|
| 26 |
+
|
| 27 |
+
for i, row in enumerate(data):
|
| 28 |
+
score, factors = rule_func(row)
|
| 29 |
+
|
| 30 |
+
if score >= 70:
|
| 31 |
+
risk = "High Risk" if model_type == "churn" else "Hot"
|
| 32 |
+
action = ("Immediate outreach + retention offer" if model_type == "churn"
|
| 33 |
+
else "Call immediately β high intent signals")
|
| 34 |
+
elif score >= 40:
|
| 35 |
+
risk = "Medium Risk" if model_type == "churn" else "Warm"
|
| 36 |
+
action = ("Monitor + engagement campaign" if model_type == "churn"
|
| 37 |
+
else "Send case study + schedule demo")
|
| 38 |
+
else:
|
| 39 |
+
risk = "Low Risk" if model_type == "churn" else "Cold"
|
| 40 |
+
action = ("No action needed" if model_type == "churn"
|
| 41 |
+
else "Add to email drip sequence")
|
| 42 |
+
|
| 43 |
+
cid = row.get("customer_id") or row.get("lead_id") or row.get("id") or None
|
| 44 |
+
|
| 45 |
+
results.append(SinglePrediction(
|
| 46 |
+
index=i,
|
| 47 |
+
score=float(score),
|
| 48 |
+
risk_level=risk,
|
| 49 |
+
recommended_action=action,
|
| 50 |
+
risk_factors=factors,
|
| 51 |
+
scoring_mode="heuristic",
|
| 52 |
+
customer_id=str(cid) if cid else None,
|
| 53 |
+
input_fields=row,
|
| 54 |
+
))
|
| 55 |
+
|
| 56 |
+
return results
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
@router.post("/churn", response_model=PredictionResponse)
|
| 60 |
+
def predict_churn(
|
| 61 |
+
body: PredictionInput,
|
| 62 |
+
user: User = Depends(get_current_user),
|
| 63 |
+
db: Session = Depends(get_db),
|
| 64 |
+
):
|
| 65 |
+
n_predictions = len(body.data)
|
| 66 |
+
check_rate_limit(user, db, "predict/churn", n_predictions)
|
| 67 |
+
used, limit = check_prediction_quota(user, db, n_predictions)
|
| 68 |
+
|
| 69 |
+
if body.model_id:
|
| 70 |
+
# Use custom trained model
|
| 71 |
+
ml_model = db.query(MLModel).filter(
|
| 72 |
+
MLModel.id == body.model_id, MLModel.user_id == user.id
|
| 73 |
+
).first()
|
| 74 |
+
if not ml_model:
|
| 75 |
+
raise HTTPException(status_code=404, detail="Model not found")
|
| 76 |
+
|
| 77 |
+
scores = predict_with_model(
|
| 78 |
+
ml_model.model_binary,
|
| 79 |
+
ml_model.get_feature_names(),
|
| 80 |
+
ml_model.encoders_json,
|
| 81 |
+
body.data,
|
| 82 |
+
)
|
| 83 |
+
|
| 84 |
+
results = []
|
| 85 |
+
for i, (row, score) in enumerate(zip(body.data, scores)):
|
| 86 |
+
cid = row.get("customer_id") or row.get("id")
|
| 87 |
+
if score >= 70:
|
| 88 |
+
risk = "High Risk"
|
| 89 |
+
action = "Immediate outreach + retention offer"
|
| 90 |
+
elif score >= 40:
|
| 91 |
+
risk = "Medium Risk"
|
| 92 |
+
action = "Monitor + engagement campaign"
|
| 93 |
+
else:
|
| 94 |
+
risk = "Low Risk"
|
| 95 |
+
action = "No action needed"
|
| 96 |
+
|
| 97 |
+
results.append(SinglePrediction(
|
| 98 |
+
index=i, score=score, risk_level=risk,
|
| 99 |
+
recommended_action=action, risk_factors=[],
|
| 100 |
+
scoring_mode="custom_ml",
|
| 101 |
+
customer_id=str(cid) if cid else None,
|
| 102 |
+
input_fields=row,
|
| 103 |
+
))
|
| 104 |
+
|
| 105 |
+
model_label = f"custom_ml_{body.model_id[:8]}"
|
| 106 |
+
else:
|
| 107 |
+
results = _apply_heuristics(body.data, "churn")
|
| 108 |
+
model_label = "heuristic"
|
| 109 |
+
|
| 110 |
+
track_usage(user, db, "predict/churn", n_predictions)
|
| 111 |
+
|
| 112 |
+
# ββ Anonymous benchmark update + comparison ββ
|
| 113 |
+
all_scores = [p.score for p in results]
|
| 114 |
+
update_benchmarks(db, all_scores, "churn")
|
| 115 |
+
benchmark = compare_to_benchmark(all_scores, "churn", db)
|
| 116 |
+
|
| 117 |
+
return PredictionResponse(
|
| 118 |
+
predictions=results,
|
| 119 |
+
model_used=model_label,
|
| 120 |
+
usage=get_usage_summary(user, db),
|
| 121 |
+
benchmark=benchmark,
|
| 122 |
+
)
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
@router.post("/lead", response_model=PredictionResponse)
|
| 126 |
+
def predict_lead(
|
| 127 |
+
body: PredictionInput,
|
| 128 |
+
user: User = Depends(get_current_user),
|
| 129 |
+
db: Session = Depends(get_db),
|
| 130 |
+
):
|
| 131 |
+
n_predictions = len(body.data)
|
| 132 |
+
check_rate_limit(user, db, "predict/lead", n_predictions)
|
| 133 |
+
used, limit = check_prediction_quota(user, db, n_predictions)
|
| 134 |
+
|
| 135 |
+
if body.model_id:
|
| 136 |
+
ml_model = db.query(MLModel).filter(
|
| 137 |
+
MLModel.id == body.model_id, MLModel.user_id == user.id
|
| 138 |
+
).first()
|
| 139 |
+
if not ml_model:
|
| 140 |
+
raise HTTPException(status_code=404, detail="Model not found")
|
| 141 |
+
|
| 142 |
+
scores = predict_with_model(
|
| 143 |
+
ml_model.model_binary,
|
| 144 |
+
ml_model.get_feature_names(),
|
| 145 |
+
ml_model.encoders_json,
|
| 146 |
+
body.data,
|
| 147 |
+
)
|
| 148 |
+
|
| 149 |
+
results = []
|
| 150 |
+
for i, (row, score) in enumerate(zip(body.data, scores)):
|
| 151 |
+
cid = row.get("lead_id") or row.get("id")
|
| 152 |
+
if score >= 70:
|
| 153 |
+
risk = "Hot"
|
| 154 |
+
action = "Call immediately β high intent signals"
|
| 155 |
+
elif score >= 40:
|
| 156 |
+
risk = "Warm"
|
| 157 |
+
action = "Send case study + schedule demo"
|
| 158 |
+
else:
|
| 159 |
+
risk = "Cold"
|
| 160 |
+
action = "Add to email drip sequence"
|
| 161 |
+
|
| 162 |
+
results.append(SinglePrediction(
|
| 163 |
+
index=i, score=score, risk_level=risk,
|
| 164 |
+
recommended_action=action, risk_factors=[],
|
| 165 |
+
scoring_mode="custom_ml",
|
| 166 |
+
customer_id=str(cid) if cid else None,
|
| 167 |
+
input_fields=row,
|
| 168 |
+
))
|
| 169 |
+
|
| 170 |
+
model_label = f"custom_ml_{body.model_id[:8]}"
|
| 171 |
+
else:
|
| 172 |
+
results = _apply_heuristics(body.data, "lead")
|
| 173 |
+
model_label = "heuristic"
|
| 174 |
+
|
| 175 |
+
track_usage(user, db, "predict/lead", n_predictions)
|
| 176 |
+
|
| 177 |
+
all_scores = [p.score for p in results]
|
| 178 |
+
update_benchmarks(db, all_scores, "lead")
|
| 179 |
+
benchmark = compare_to_benchmark(all_scores, "lead", db)
|
| 180 |
+
|
| 181 |
+
return PredictionResponse(
|
| 182 |
+
predictions=results,
|
| 183 |
+
model_used=model_label,
|
| 184 |
+
usage=get_usage_summary(user, db),
|
| 185 |
+
benchmark=benchmark,
|
| 186 |
+
)
|
app/routers/training.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import uuid
|
| 2 |
+
import json
|
| 3 |
+
from fastapi import APIRouter, Depends, HTTPException
|
| 4 |
+
from sqlalchemy.orm import Session
|
| 5 |
+
|
| 6 |
+
from app.database import get_db
|
| 7 |
+
from app.dependencies import get_current_user, check_rate_limit, check_model_quota, track_usage
|
| 8 |
+
from app.models.user import User
|
| 9 |
+
from app.models.mlmodel import MLModel
|
| 10 |
+
from app.schemas import TrainingInput, TrainingResponse, MLModelResponse, MLModelListResponse
|
| 11 |
+
from app.services.training import train_from_data
|
| 12 |
+
|
| 13 |
+
router = APIRouter(prefix="/v1", tags=["training"])
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
@router.post("/train", response_model=TrainingResponse)
|
| 17 |
+
def train_model(
|
| 18 |
+
body: TrainingInput,
|
| 19 |
+
user: User = Depends(get_current_user),
|
| 20 |
+
db: Session = Depends(get_db),
|
| 21 |
+
):
|
| 22 |
+
check_rate_limit(user, db, "train", 1)
|
| 23 |
+
check_model_quota(user, db)
|
| 24 |
+
|
| 25 |
+
if body.model_type not in ("churn", "lead"):
|
| 26 |
+
raise HTTPException(status_code=400, detail="model_type must be 'churn' or 'lead'")
|
| 27 |
+
|
| 28 |
+
if len(body.data) < 50:
|
| 29 |
+
raise HTTPException(status_code=400, detail="Need at least 50 rows to train")
|
| 30 |
+
|
| 31 |
+
try:
|
| 32 |
+
result = train_from_data(body.data, body.target_column, body.model_type)
|
| 33 |
+
except ValueError as e:
|
| 34 |
+
raise HTTPException(status_code=400, detail=str(e))
|
| 35 |
+
|
| 36 |
+
model_name = body.model_name or f"{body.model_type}-model-{uuid.uuid4().hex[:6]}"
|
| 37 |
+
|
| 38 |
+
ml_model = MLModel(
|
| 39 |
+
user_id=user.id,
|
| 40 |
+
name=model_name,
|
| 41 |
+
model_type=body.model_type,
|
| 42 |
+
feature_names_json=json.dumps(result["feature_names"]),
|
| 43 |
+
encoders_json=result["encoders_json"],
|
| 44 |
+
model_binary=result["model_binary"],
|
| 45 |
+
metrics_json=result["metrics_json"],
|
| 46 |
+
summary_json=result["summary_json"],
|
| 47 |
+
n_rows=result["n_rows"],
|
| 48 |
+
n_features=result["n_features"],
|
| 49 |
+
)
|
| 50 |
+
db.add(ml_model)
|
| 51 |
+
db.commit()
|
| 52 |
+
db.refresh(ml_model)
|
| 53 |
+
|
| 54 |
+
track_usage(user, db, "train", 1)
|
| 55 |
+
|
| 56 |
+
return TrainingResponse(
|
| 57 |
+
model_id=ml_model.id,
|
| 58 |
+
model_name=ml_model.name,
|
| 59 |
+
model_type=ml_model.model_type,
|
| 60 |
+
metrics=result["metrics"],
|
| 61 |
+
summary=result["summary"],
|
| 62 |
+
)
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
@router.get("/models", response_model=MLModelListResponse)
|
| 66 |
+
def list_models(
|
| 67 |
+
user: User = Depends(get_current_user),
|
| 68 |
+
db: Session = Depends(get_db),
|
| 69 |
+
):
|
| 70 |
+
models = db.query(MLModel).filter(MLModel.user_id == user.id).order_by(
|
| 71 |
+
MLModel.created_at.desc()
|
| 72 |
+
).all()
|
| 73 |
+
|
| 74 |
+
items = []
|
| 75 |
+
for m in models:
|
| 76 |
+
items.append(MLModelResponse(
|
| 77 |
+
id=m.id, name=m.name, model_type=m.model_type,
|
| 78 |
+
n_features=m.n_features, n_rows=m.n_rows,
|
| 79 |
+
metrics=m.get_metrics(), summary=m.get_summary(),
|
| 80 |
+
created_at=m.created_at,
|
| 81 |
+
))
|
| 82 |
+
|
| 83 |
+
return MLModelListResponse(models=items, total=len(items))
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
@router.get("/models/{model_id}", response_model=MLModelResponse)
|
| 87 |
+
def get_model(
|
| 88 |
+
model_id: str,
|
| 89 |
+
user: User = Depends(get_current_user),
|
| 90 |
+
db: Session = Depends(get_db),
|
| 91 |
+
):
|
| 92 |
+
m = db.query(MLModel).filter(
|
| 93 |
+
MLModel.id == model_id, MLModel.user_id == user.id
|
| 94 |
+
).first()
|
| 95 |
+
if not m:
|
| 96 |
+
raise HTTPException(status_code=404, detail="Model not found")
|
| 97 |
+
|
| 98 |
+
return MLModelResponse(
|
| 99 |
+
id=m.id, name=m.name, model_type=m.model_type,
|
| 100 |
+
n_features=m.n_features, n_rows=m.n_rows,
|
| 101 |
+
metrics=m.get_metrics(), summary=m.get_summary(),
|
| 102 |
+
created_at=m.created_at,
|
| 103 |
+
)
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
@router.delete("/models/{model_id}")
|
| 107 |
+
def delete_model(
|
| 108 |
+
model_id: str,
|
| 109 |
+
user: User = Depends(get_current_user),
|
| 110 |
+
db: Session = Depends(get_db),
|
| 111 |
+
):
|
| 112 |
+
m = db.query(MLModel).filter(
|
| 113 |
+
MLModel.id == model_id, MLModel.user_id == user.id
|
| 114 |
+
).first()
|
| 115 |
+
if not m:
|
| 116 |
+
raise HTTPException(status_code=404, detail="Model not found")
|
| 117 |
+
|
| 118 |
+
db.delete(m)
|
| 119 |
+
db.commit()
|
| 120 |
+
return {"detail": "Model deleted", "model_id": model_id}
|
app/routers/usage.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import time
|
| 2 |
+
from fastapi import APIRouter, Depends
|
| 3 |
+
from sqlalchemy.orm import Session
|
| 4 |
+
|
| 5 |
+
from app.database import get_db
|
| 6 |
+
from app.dependencies import get_current_user, get_usage_summary
|
| 7 |
+
from app.models.user import User
|
| 8 |
+
from app.schemas import UsageResponse, HealthResponse
|
| 9 |
+
from app.config import get_settings
|
| 10 |
+
|
| 11 |
+
router = APIRouter(tags=["usage"])
|
| 12 |
+
settings = get_settings()
|
| 13 |
+
_start_time = time.time()
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
@router.get("/v1/usage", response_model=UsageResponse)
|
| 17 |
+
def get_usage(
|
| 18 |
+
user: User = Depends(get_current_user),
|
| 19 |
+
db: Session = Depends(get_db),
|
| 20 |
+
):
|
| 21 |
+
return get_usage_summary(user, db)
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
@router.get("/v1/health", response_model=HealthResponse)
|
| 25 |
+
def health_check():
|
| 26 |
+
return HealthResponse(
|
| 27 |
+
version=settings.version,
|
| 28 |
+
uptime=round(time.time() - _start_time, 1),
|
| 29 |
+
)
|
app/schemas/__init__.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pydantic import BaseModel, Field, EmailStr, field_validator
|
| 2 |
+
from typing import Optional, List, Dict, Any
|
| 3 |
+
from datetime import datetime
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
# ββ Auth ββ
|
| 7 |
+
class UserRegister(BaseModel):
|
| 8 |
+
email: str
|
| 9 |
+
password: str = Field(min_length=8)
|
| 10 |
+
name: str = ""
|
| 11 |
+
|
| 12 |
+
class UserLogin(BaseModel):
|
| 13 |
+
email: str
|
| 14 |
+
password: str
|
| 15 |
+
|
| 16 |
+
class TokenResponse(BaseModel):
|
| 17 |
+
access_token: str
|
| 18 |
+
token_type: str = "bearer"
|
| 19 |
+
user_id: str
|
| 20 |
+
email: str
|
| 21 |
+
tier: str
|
| 22 |
+
api_key: Optional[str] = None
|
| 23 |
+
|
| 24 |
+
class APIKeyResponse(BaseModel):
|
| 25 |
+
id: str
|
| 26 |
+
prefix: str
|
| 27 |
+
name: str
|
| 28 |
+
is_active: bool
|
| 29 |
+
created_at: datetime
|
| 30 |
+
last_used_at: Optional[datetime] = None
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
# ββ Prediction ββ
|
| 34 |
+
class PredictionInput(BaseModel):
|
| 35 |
+
data: List[Dict[str, Any]] = Field(..., min_length=1, max_length=1000)
|
| 36 |
+
model_id: Optional[str] = None # use custom trained model if provided
|
| 37 |
+
|
| 38 |
+
class RiskFactor(BaseModel):
|
| 39 |
+
rule: str
|
| 40 |
+
points: int
|
| 41 |
+
detail: str
|
| 42 |
+
|
| 43 |
+
class SinglePrediction(BaseModel):
|
| 44 |
+
index: int
|
| 45 |
+
score: float
|
| 46 |
+
risk_level: str
|
| 47 |
+
recommended_action: Optional[str] = None
|
| 48 |
+
risk_factors: List[RiskFactor] = []
|
| 49 |
+
scoring_mode: str # "heuristic" or "custom_ml"
|
| 50 |
+
customer_id: Optional[str] = None
|
| 51 |
+
# Re-echo input row merged with output
|
| 52 |
+
input_fields: Dict[str, Any] = {}
|
| 53 |
+
|
| 54 |
+
class PredictionResponse(BaseModel):
|
| 55 |
+
predictions: List[SinglePrediction]
|
| 56 |
+
model_used: str # "heuristic" or "custom_ml_<id>"
|
| 57 |
+
usage: Dict[str, Any]
|
| 58 |
+
benchmark: Optional[Dict[str, Any]] = None # comparison vs industry
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
# ββ Training ββ
|
| 62 |
+
class TrainingInput(BaseModel):
|
| 63 |
+
data: List[Dict[str, Any]] = Field(..., min_length=50)
|
| 64 |
+
target_column: str
|
| 65 |
+
model_type: str = "churn" # churn or lead
|
| 66 |
+
model_name: Optional[str] = None
|
| 67 |
+
|
| 68 |
+
class TrainingResponse(BaseModel):
|
| 69 |
+
model_id: str
|
| 70 |
+
model_name: str
|
| 71 |
+
model_type: str
|
| 72 |
+
metrics: Dict[str, Any]
|
| 73 |
+
summary: Dict[str, Any]
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
# ββ Call Analysis ββ
|
| 77 |
+
class CallAnalysisItem(BaseModel):
|
| 78 |
+
filename: str
|
| 79 |
+
transcript_snippet: str
|
| 80 |
+
sentiment_score: float # -1 to 1
|
| 81 |
+
sentiment_label: str # positive, neutral, negative
|
| 82 |
+
churn_intent_score: float # 0-100
|
| 83 |
+
flagged_keywords: List[str]
|
| 84 |
+
duration_seconds: Optional[float] = None
|
| 85 |
+
|
| 86 |
+
class CallAnalysisResponse(BaseModel):
|
| 87 |
+
results: List[CallAnalysisItem]
|
| 88 |
+
summary: Dict[str, Any]
|
| 89 |
+
usage: Dict[str, Any]
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
# ββ Usage ββ
|
| 93 |
+
class UsageResponse(BaseModel):
|
| 94 |
+
tier: str
|
| 95 |
+
predictions_used: int
|
| 96 |
+
predictions_limit: int
|
| 97 |
+
models_used: int
|
| 98 |
+
models_limit: int
|
| 99 |
+
remaining_predictions: int
|
| 100 |
+
remaining_models: int
|
| 101 |
+
usage_by_endpoint: Dict[str, int]
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
# ββ Models ββ
|
| 105 |
+
class MLModelResponse(BaseModel):
|
| 106 |
+
id: str
|
| 107 |
+
name: str
|
| 108 |
+
model_type: str
|
| 109 |
+
n_features: int
|
| 110 |
+
n_rows: int
|
| 111 |
+
metrics: Dict[str, Any]
|
| 112 |
+
summary: Dict[str, Any]
|
| 113 |
+
created_at: datetime
|
| 114 |
+
|
| 115 |
+
class MLModelListResponse(BaseModel):
|
| 116 |
+
models: List[MLModelResponse]
|
| 117 |
+
total: int
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
# ββ Health ββ
|
| 121 |
+
class HealthResponse(BaseModel):
|
| 122 |
+
status: str = "ok"
|
| 123 |
+
version: str
|
| 124 |
+
uptime: float
|
app/schemas/__pycache__/__init__.cpython-312.pyc
ADDED
|
Binary file (6.04 kB). View file
|
|
|
app/services/__init__.py
ADDED
|
File without changes
|
app/services/__pycache__/__init__.cpython-312.pyc
ADDED
|
Binary file (142 Bytes). View file
|
|
|
app/services/__pycache__/audio.cpython-312.pyc
ADDED
|
Binary file (5.23 kB). View file
|
|
|
app/services/__pycache__/benchmarking.cpython-312.pyc
ADDED
|
Binary file (9.3 kB). View file
|
|
|
app/services/__pycache__/scoring.cpython-312.pyc
ADDED
|
Binary file (10 kB). View file
|
|
|
app/services/__pycache__/training.cpython-312.pyc
ADDED
|
Binary file (8.03 kB). View file
|
|
|
app/services/audio.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import io
|
| 2 |
+
import tempfile
|
| 3 |
+
import os
|
| 4 |
+
from typing import List, Dict, Any, Optional
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
|
| 7 |
+
try:
|
| 8 |
+
from vaderSentiment.vaderSentiment import SentimentIntensityAnalyzer
|
| 9 |
+
_vader = SentimentIntensityAnalyzer()
|
| 10 |
+
except ImportError:
|
| 11 |
+
_vader = None
|
| 12 |
+
|
| 13 |
+
from app.services.scoring import detect_churn_keywords, CHURN_KEYWORDS
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def analyze_sentiment(text: str) -> dict:
|
| 17 |
+
"""Run VADER sentiment analysis on transcript text."""
|
| 18 |
+
if not text.strip():
|
| 19 |
+
return {"compound": 0.0, "pos": 0.0, "neg": 0.0, "neu": 1.0, "label": "neutral"}
|
| 20 |
+
|
| 21 |
+
if _vader is None:
|
| 22 |
+
# fallback: simple heuristic
|
| 23 |
+
positive = sum(1 for w in ["good", "great", "excellent", "happy", "love", "thanks", "amazing", "helpful"] if w in text.lower())
|
| 24 |
+
negative = sum(1 for w in ["bad", "terrible", "awful", "hate", "frustrated", "angry", "broken", "useless"] if w in text.lower())
|
| 25 |
+
compound = (positive - negative) / max(positive + negative + 1, 1)
|
| 26 |
+
return {
|
| 27 |
+
"compound": round(compound, 4),
|
| 28 |
+
"pos": 0.0, "neg": 0.0, "neu": 0.0,
|
| 29 |
+
"label": "positive" if compound > 0.1 else ("negative" if compound < -0.1 else "neutral")
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
scores = _vader.polarity_scores(text)
|
| 33 |
+
compound = scores["compound"]
|
| 34 |
+
label = "positive" if compound >= 0.05 else ("negative" if compound <= -0.05 else "neutral")
|
| 35 |
+
|
| 36 |
+
return {
|
| 37 |
+
"compound": round(compound, 4),
|
| 38 |
+
"pos": round(scores["pos"], 4),
|
| 39 |
+
"neg": round(scores["neg"], 4),
|
| 40 |
+
"neu": round(scores["neu"], 4),
|
| 41 |
+
"label": label,
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def transcribe_with_whisper(audio_bytes: bytes, filename: str, api_key: str) -> str:
|
| 46 |
+
"""Transcribe audio using OpenAI Whisper API."""
|
| 47 |
+
import httpx
|
| 48 |
+
|
| 49 |
+
# Determine content type from extension
|
| 50 |
+
ext = Path(filename).suffix.lower()
|
| 51 |
+
content_type_map = {
|
| 52 |
+
".mp3": "audio/mpeg",
|
| 53 |
+
".wav": "audio/wav",
|
| 54 |
+
".m4a": "audio/mp4",
|
| 55 |
+
".mp4": "video/mp4",
|
| 56 |
+
".ogg": "audio/ogg",
|
| 57 |
+
".webm": "audio/webm",
|
| 58 |
+
}
|
| 59 |
+
content_type = content_type_map.get(ext, "audio/mpeg")
|
| 60 |
+
|
| 61 |
+
# Write bytes to temp file
|
| 62 |
+
with tempfile.NamedTemporaryFile(suffix=ext, delete=False) as tmp:
|
| 63 |
+
tmp.write(audio_bytes)
|
| 64 |
+
tmp_path = tmp.name
|
| 65 |
+
|
| 66 |
+
try:
|
| 67 |
+
with open(tmp_path, "rb") as f:
|
| 68 |
+
response = httpx.post(
|
| 69 |
+
"https://api.openai.com/v1/audio/transcriptions",
|
| 70 |
+
headers={"Authorization": f"Bearer {api_key}"},
|
| 71 |
+
data={"model": "whisper-1", "response_format": "text"},
|
| 72 |
+
files={"file": (filename, f, content_type)},
|
| 73 |
+
timeout=60.0,
|
| 74 |
+
)
|
| 75 |
+
if response.status_code != 200:
|
| 76 |
+
raise Exception(f"Whisper API error ({response.status_code}): {response.text[:200]}")
|
| 77 |
+
|
| 78 |
+
return response.text
|
| 79 |
+
finally:
|
| 80 |
+
os.unlink(tmp_path)
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def analyze_call(audio_bytes: bytes, filename: str, api_key: str) -> dict:
|
| 84 |
+
"""Full call analysis: transcript β sentiment β churn keywords."""
|
| 85 |
+
transcript = transcribe_with_whisper(audio_bytes, filename, api_key)
|
| 86 |
+
sentiment = analyze_sentiment(transcript)
|
| 87 |
+
keywords = detect_churn_keywords(transcript)
|
| 88 |
+
|
| 89 |
+
# Combined churn intent from transcript
|
| 90 |
+
churn_score = keywords["churn_intent_score"]
|
| 91 |
+
if sentiment["label"] == "negative":
|
| 92 |
+
churn_score = min(churn_score + 20, 100)
|
| 93 |
+
elif sentiment["label"] == "positive":
|
| 94 |
+
churn_score = max(churn_score - 15, 0)
|
| 95 |
+
|
| 96 |
+
return {
|
| 97 |
+
"filename": filename,
|
| 98 |
+
"transcript_snippet": transcript[:300] + ("..." if len(transcript) > 300 else ""),
|
| 99 |
+
"transcript_full": transcript,
|
| 100 |
+
"sentiment_score": sentiment["compound"],
|
| 101 |
+
"sentiment_label": sentiment["label"],
|
| 102 |
+
"churn_intent_score": churn_score,
|
| 103 |
+
"flagged_keywords": keywords["flagged_keywords"],
|
| 104 |
+
"flagged_keyword_count": keywords["flagged_keyword_count"],
|
| 105 |
+
}
|
app/services/benchmarking.py
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import uuid
|
| 3 |
+
import datetime
|
| 4 |
+
import numpy as np
|
| 5 |
+
from typing import List, Dict, Any, Optional
|
| 6 |
+
from sqlalchemy.orm import Session
|
| 7 |
+
|
| 8 |
+
from app.models.benchmark import Benchmark
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
BUCKET_SIZE = 10 # 0-10, 10-20, ..., 90-100
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def _scores_to_histogram(scores: List[float]) -> Dict[str, int]:
|
| 15 |
+
"""Convert a list of scores to a bucketed histogram."""
|
| 16 |
+
hist = {}
|
| 17 |
+
for s in scores:
|
| 18 |
+
bucket = min(90, int(s // BUCKET_SIZE) * BUCKET_SIZE)
|
| 19 |
+
key = f"{bucket}-{bucket + BUCKET_SIZE}"
|
| 20 |
+
hist[key] = hist.get(key, 0) + 1
|
| 21 |
+
return hist
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def _merge_histograms(existing: Dict[str, int], new: Dict[str, int]) -> Dict[str, int]:
|
| 25 |
+
"""Merge new histogram counts into existing."""
|
| 26 |
+
merged = existing.copy()
|
| 27 |
+
for k, v in new.items():
|
| 28 |
+
merged[k] = merged.get(k, 0) + v
|
| 29 |
+
return merged
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def _histogram_to_stats(histogram: Dict[str, int], total: int) -> dict:
|
| 33 |
+
"""Compute avg, median, percentiles from bucketed histogram."""
|
| 34 |
+
if total == 0:
|
| 35 |
+
return {"avg_score": 0, "median_score": 0, "p25_score": 0, "p75_score": 0,
|
| 36 |
+
"high_risk_pct": 0, "medium_risk_pct": 0, "low_risk_pct": 0}
|
| 37 |
+
|
| 38 |
+
# Expand to approximate flat list (midpoint of each bucket)
|
| 39 |
+
scores = []
|
| 40 |
+
for bucket, count in histogram.items():
|
| 41 |
+
lo, hi = bucket.split("-")
|
| 42 |
+
midpoint = (int(lo) + int(hi)) / 2
|
| 43 |
+
scores.extend([midpoint] * count)
|
| 44 |
+
|
| 45 |
+
arr = np.array(scores)
|
| 46 |
+
high = int((arr >= 70).sum())
|
| 47 |
+
med = int(((arr >= 40) & (arr < 70)).sum())
|
| 48 |
+
low = int((arr < 40).sum())
|
| 49 |
+
|
| 50 |
+
return {
|
| 51 |
+
"avg_score": round(float(np.mean(arr)), 1),
|
| 52 |
+
"median_score": round(float(np.median(arr)), 1),
|
| 53 |
+
"p25_score": round(float(np.percentile(arr, 25)), 1),
|
| 54 |
+
"p75_score": round(float(np.percentile(arr, 75)), 1),
|
| 55 |
+
"high_risk_pct": round(high / total * 100, 1),
|
| 56 |
+
"medium_risk_pct": round(med / total * 100, 1),
|
| 57 |
+
"low_risk_pct": round(low / total * 100, 1),
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def update_benchmarks(db: Session, scores: List[float], model_type: str):
|
| 62 |
+
"""Anonymously merge new scores into global benchmarks."""
|
| 63 |
+
if not scores:
|
| 64 |
+
return
|
| 65 |
+
|
| 66 |
+
benchmark = db.query(Benchmark).filter(Benchmark.id == "global").first()
|
| 67 |
+
if not benchmark:
|
| 68 |
+
benchmark = Benchmark(id="global")
|
| 69 |
+
db.add(benchmark)
|
| 70 |
+
db.flush()
|
| 71 |
+
|
| 72 |
+
new_hist = _scores_to_histogram(scores)
|
| 73 |
+
|
| 74 |
+
if model_type == "churn":
|
| 75 |
+
existing = benchmark.get_churn_data().get("histogram", {})
|
| 76 |
+
merged = _merge_histograms(existing, new_hist)
|
| 77 |
+
total = benchmark.total_churn_scored + len(scores)
|
| 78 |
+
|
| 79 |
+
data = {
|
| 80 |
+
"histogram": merged,
|
| 81 |
+
**_histogram_to_stats(merged, total),
|
| 82 |
+
"total_records_scored": total,
|
| 83 |
+
}
|
| 84 |
+
benchmark.set_churn_data(data)
|
| 85 |
+
benchmark.total_churn_scored = total
|
| 86 |
+
benchmark.unique_churn_companies += 1 # each predict call = 1 company's data
|
| 87 |
+
elif model_type == "lead":
|
| 88 |
+
existing = benchmark.get_lead_data().get("histogram", {})
|
| 89 |
+
merged = _merge_histograms(existing, new_hist)
|
| 90 |
+
total = benchmark.total_lead_scored + len(scores)
|
| 91 |
+
|
| 92 |
+
data = {
|
| 93 |
+
"histogram": merged,
|
| 94 |
+
**_histogram_to_stats(merged, total),
|
| 95 |
+
"total_records_scored": total,
|
| 96 |
+
}
|
| 97 |
+
benchmark.set_lead_data(data)
|
| 98 |
+
benchmark.total_lead_scored = total
|
| 99 |
+
benchmark.unique_lead_companies += 1
|
| 100 |
+
|
| 101 |
+
benchmark.updated_at = datetime.datetime.utcnow()
|
| 102 |
+
db.commit()
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def get_benchmarks(db: Session) -> dict:
|
| 106 |
+
"""Get current global benchmarks with privacy floor."""
|
| 107 |
+
benchmark = db.query(Benchmark).filter(Benchmark.id == "global").first()
|
| 108 |
+
if not benchmark:
|
| 109 |
+
return {"churn": None, "lead": None, "privacy_notice": "Not enough data yet. Benchmarks available when β₯3 companies contribute."}
|
| 110 |
+
|
| 111 |
+
result = {}
|
| 112 |
+
for mt in ("churn", "lead"):
|
| 113 |
+
raw = benchmark.get_churn_data() if mt == "churn" else benchmark.get_lead_data()
|
| 114 |
+
companies = benchmark.unique_churn_companies if mt == "churn" else benchmark.unique_lead_companies
|
| 115 |
+
|
| 116 |
+
if companies < 3:
|
| 117 |
+
result[mt] = None
|
| 118 |
+
else:
|
| 119 |
+
result[mt] = {
|
| 120 |
+
**raw,
|
| 121 |
+
"unique_companies": companies,
|
| 122 |
+
"privacy_safe": True,
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
return result
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
def compare_to_benchmark(user_scores: List[float], model_type: str, db: Session) -> dict:
|
| 129 |
+
"""Compare a user's score distribution against global benchmarks."""
|
| 130 |
+
benchmark = db.query(Benchmark).filter(Benchmark.id == "global").first()
|
| 131 |
+
if not benchmark:
|
| 132 |
+
return {"available": False, "reason": "No benchmarks yet"}
|
| 133 |
+
|
| 134 |
+
data = benchmark.get_churn_data() if model_type == "churn" else benchmark.get_lead_data()
|
| 135 |
+
companies = benchmark.unique_churn_companies if model_type == "churn" else benchmark.unique_lead_companies
|
| 136 |
+
|
| 137 |
+
if companies < 3 or not data:
|
| 138 |
+
return {"available": False, "reason": f"Need β₯3 companies ({companies} so far)"}
|
| 139 |
+
|
| 140 |
+
arr = np.array(user_scores)
|
| 141 |
+
user_avg = float(np.mean(arr))
|
| 142 |
+
user_median = float(np.median(arr))
|
| 143 |
+
user_high = round(float((arr >= 70).sum() / len(arr) * 100), 1)
|
| 144 |
+
user_med = round(float(((arr >= 40) & (arr < 70)).sum() / len(arr) * 100), 1)
|
| 145 |
+
user_low = round(float((arr < 40).sum() / len(arr) * 100), 1)
|
| 146 |
+
|
| 147 |
+
bench_avg = data.get("avg_score", 0)
|
| 148 |
+
bench_median = data.get("median_score", 0)
|
| 149 |
+
bench_high = data.get("high_risk_pct", 0)
|
| 150 |
+
bench_med = data.get("medium_risk_pct", 0)
|
| 151 |
+
bench_low = data.get("low_risk_pct", 0)
|
| 152 |
+
|
| 153 |
+
# Percentile: where user_avg sits in benchmark distribution (approximate)
|
| 154 |
+
percentile = round(min(99, max(1, (user_avg / max(bench_avg, 1)) * 50)), 0)
|
| 155 |
+
|
| 156 |
+
# Status
|
| 157 |
+
if user_avg < bench_avg * 0.8:
|
| 158 |
+
status = "excellent" # significantly below industry avg
|
| 159 |
+
elif user_avg < bench_avg * 1.1:
|
| 160 |
+
status = "average"
|
| 161 |
+
else:
|
| 162 |
+
status = "needs_attention"
|
| 163 |
+
|
| 164 |
+
return {
|
| 165 |
+
"available": True,
|
| 166 |
+
"companies_compared": companies,
|
| 167 |
+
"user": {
|
| 168 |
+
"avg_score": round(user_avg, 1),
|
| 169 |
+
"median_score": round(user_median, 1),
|
| 170 |
+
"high_risk_pct": user_high,
|
| 171 |
+
"medium_risk_pct": user_med,
|
| 172 |
+
"low_risk_pct": user_low,
|
| 173 |
+
"n_scored": len(user_scores),
|
| 174 |
+
},
|
| 175 |
+
"industry_benchmark": {
|
| 176 |
+
"avg_score": bench_avg,
|
| 177 |
+
"median_score": bench_median,
|
| 178 |
+
"high_risk_pct": bench_high,
|
| 179 |
+
"medium_risk_pct": bench_med,
|
| 180 |
+
"low_risk_pct": bench_low,
|
| 181 |
+
"total_records": data.get("total_records_scored", 0),
|
| 182 |
+
"companies": companies,
|
| 183 |
+
},
|
| 184 |
+
"comparison": {
|
| 185 |
+
"percentile": percentile,
|
| 186 |
+
"vs_industry_avg": round(user_avg - bench_avg, 1),
|
| 187 |
+
"status": status,
|
| 188 |
+
"status_label": {"excellent": "Better than industry β your churn is below average",
|
| 189 |
+
"average": "In line with industry average",
|
| 190 |
+
"needs_attention": "Above industry average β review risk factors"}.get(status, ""),
|
| 191 |
+
},
|
| 192 |
+
}
|
app/services/scoring.py
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pandas as pd
|
| 2 |
+
import numpy as np
|
| 3 |
+
from typing import List, Dict, Any, Tuple, Optional
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def _safe_float(val: Any, default: float = 0.0) -> float:
|
| 7 |
+
try:
|
| 8 |
+
return float(val) if val is not None else default
|
| 9 |
+
except (ValueError, TypeError):
|
| 10 |
+
return default
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def _safe_int(val: Any, default: int = 0) -> int:
|
| 14 |
+
try:
|
| 15 |
+
return int(float(val)) if val is not None else default
|
| 16 |
+
except (ValueError, TypeError):
|
| 17 |
+
return default
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 21 |
+
# CHURN HEURISTIC RULES
|
| 22 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 23 |
+
|
| 24 |
+
def _churn_factor(row: Dict[str, Any]) -> tuple:
|
| 25 |
+
rules = []
|
| 26 |
+
|
| 27 |
+
days_off = _safe_float(row.get("days_since_last_login"), 0)
|
| 28 |
+
if days_off >= 14:
|
| 29 |
+
rules.append(("Login Recency", 25, f"No login in {int(days_off)} days"))
|
| 30 |
+
elif days_off >= 7:
|
| 31 |
+
rules.append(("Login Recency", 15, f"No login in {int(days_off)} days"))
|
| 32 |
+
|
| 33 |
+
logins = _safe_float(row.get("login_frequency_7d"), 10)
|
| 34 |
+
if logins <= 1:
|
| 35 |
+
rules.append(("Low Engagement", 20, f"Only {int(logins)} logins this week"))
|
| 36 |
+
elif logins <= 3:
|
| 37 |
+
rules.append(("Low Engagement", 10, f"Only {int(logins)} logins this week"))
|
| 38 |
+
|
| 39 |
+
tickets = _safe_float(row.get("support_tickets_last_30d"), 0)
|
| 40 |
+
if tickets >= 5:
|
| 41 |
+
rules.append(("Support Friction", 15, f"{int(tickets)} tickets in 30 days"))
|
| 42 |
+
elif tickets >= 3:
|
| 43 |
+
rules.append(("Support Friction", 8, f"{int(tickets)} tickets in 30 days"))
|
| 44 |
+
|
| 45 |
+
delays = _safe_float(row.get("payment_delays_90d"), 0)
|
| 46 |
+
if delays >= 3:
|
| 47 |
+
rules.append(("Payment Failure", 25, f"{int(delays)} payment delays"))
|
| 48 |
+
elif delays >= 1:
|
| 49 |
+
rules.append(("Payment Failure", 12, f"{int(delays)} payment delays in 90 days"))
|
| 50 |
+
|
| 51 |
+
adoption = _safe_float(row.get("feature_adoption_score"), 100)
|
| 52 |
+
if adoption <= 30:
|
| 53 |
+
rules.append(("Low Adoption", 10, f"Only {adoption:.0f}% feature adoption"))
|
| 54 |
+
|
| 55 |
+
nps = _safe_float(row.get("nps_score"), 10)
|
| 56 |
+
if nps <= 4:
|
| 57 |
+
rules.append(("Low NPS", 10, f"NPS score of {int(nps)}/10"))
|
| 58 |
+
|
| 59 |
+
tenure = _safe_float(row.get("tenure_days"), 365)
|
| 60 |
+
if tenure <= 60:
|
| 61 |
+
rules.append(("Short Tenure", 10, f"Only {int(tenure)} days as customer"))
|
| 62 |
+
elif tenure <= 90:
|
| 63 |
+
rules.append(("Short Tenure", 5, f"Only {int(tenure)} days as customer"))
|
| 64 |
+
|
| 65 |
+
ct = str(row.get("contract_type", "")).strip().lower()
|
| 66 |
+
if ct in ("month-to-month", "month to month", "monthly"):
|
| 67 |
+
rules.append(("Contract Risk", 10, "Month-to-month contract"))
|
| 68 |
+
|
| 69 |
+
session = _safe_float(row.get("avg_session_minutes"), 60)
|
| 70 |
+
if session <= 5:
|
| 71 |
+
rules.append(("Low Sessions", 5, f"Avg session {session:.1f} min"))
|
| 72 |
+
|
| 73 |
+
call_score = _safe_float(row.get("call_sentiment_churn_risk"), None)
|
| 74 |
+
if call_score is not None and call_score >= 70:
|
| 75 |
+
rules.append(("Cancellation Intent (Audio)", 30, f"Call churn score: {int(call_score)}%"))
|
| 76 |
+
elif call_score is not None and call_score >= 40:
|
| 77 |
+
rules.append(("Call Concern (Audio)", 15, f"Call churn score: {int(call_score)}%"))
|
| 78 |
+
|
| 79 |
+
call_sentiment = _safe_float(row.get("call_sentiment"), None)
|
| 80 |
+
if call_sentiment is not None and call_sentiment < -0.5:
|
| 81 |
+
rules.append(("Negative Sentiment (Audio)", 15, f"Sentiment: {call_sentiment:.2f}"))
|
| 82 |
+
|
| 83 |
+
keyword_count = _safe_int(row.get("flagged_keyword_count"), None)
|
| 84 |
+
if keyword_count is not None and keyword_count >= 2:
|
| 85 |
+
rules.append(("Churn Keywords (Audio)", 10, f"{int(keyword_count)} flagged keywords"))
|
| 86 |
+
|
| 87 |
+
score = min(sum(r[1] for r in rules), 100)
|
| 88 |
+
factors = [{"rule": r[0], "points": r[1], "detail": r[2]} for r in rules]
|
| 89 |
+
return score, factors
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 93 |
+
# LEAD HEURISTIC RULES
|
| 94 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 95 |
+
|
| 96 |
+
def _lead_factor(row: Dict[str, Any]) -> tuple:
|
| 97 |
+
rules = []
|
| 98 |
+
|
| 99 |
+
demo = _safe_int(row.get("demo_requested"), 0)
|
| 100 |
+
if demo == 1:
|
| 101 |
+
rules.append(("Demo Requested", 25, "Demo has been requested"))
|
| 102 |
+
|
| 103 |
+
budget = _safe_int(row.get("budget_confirmed"), 0)
|
| 104 |
+
if budget == 1:
|
| 105 |
+
rules.append(("Budget Confirmed", 20, "Budget is confirmed"))
|
| 106 |
+
|
| 107 |
+
dm = _safe_int(row.get("decision_maker_contacted"), 0)
|
| 108 |
+
if dm == 1:
|
| 109 |
+
rules.append(("DM Access", 20, "Decision maker contacted"))
|
| 110 |
+
|
| 111 |
+
eng = _safe_float(row.get("engagement_score"), 0)
|
| 112 |
+
if eng >= 60:
|
| 113 |
+
rules.append(("High Engagement", 15, f"Engagement score {eng:.0f}/100"))
|
| 114 |
+
|
| 115 |
+
src = str(row.get("source", "")).strip().lower()
|
| 116 |
+
if src in ("referral", "organic", "paid ads"):
|
| 117 |
+
rules.append(("Quality Source", 10, f"Source: {src.title()}"))
|
| 118 |
+
|
| 119 |
+
dip = _safe_float(row.get("days_in_pipeline"), 60)
|
| 120 |
+
if dip <= 14:
|
| 121 |
+
rules.append(("Fresh Lead", 10, f"Only {int(dip)} days in pipeline"))
|
| 122 |
+
|
| 123 |
+
convs = _safe_float(row.get("previous_conversations"), 0)
|
| 124 |
+
if convs >= 3:
|
| 125 |
+
rules.append(("Active Relationship", 10, f"{int(convs)} conversations"))
|
| 126 |
+
|
| 127 |
+
downloads = _safe_float(row.get("content_downloads"), 0)
|
| 128 |
+
if downloads >= 3:
|
| 129 |
+
rules.append(("Content Interest", 5, f"{int(downloads)} downloads"))
|
| 130 |
+
|
| 131 |
+
opens = _safe_float(row.get("email_opens"), 0)
|
| 132 |
+
if opens >= 5:
|
| 133 |
+
rules.append(("Email Engagement", 5, f"{int(opens)} email opens"))
|
| 134 |
+
|
| 135 |
+
visitors = _safe_float(row.get("website_visits"), 0)
|
| 136 |
+
if visitors >= 5:
|
| 137 |
+
rules.append(("Website Activity", 5, f"{int(visitors)} visits"))
|
| 138 |
+
|
| 139 |
+
score = min(sum(r[1] for r in rules), 100)
|
| 140 |
+
factors = [{"rule": r[0], "points": r[1], "detail": r[2]} for r in rules]
|
| 141 |
+
return score, factors
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 145 |
+
# CHURN KEYWORDS FOR AUDIO ANALYSIS
|
| 146 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 147 |
+
|
| 148 |
+
CHURN_KEYWORDS = [
|
| 149 |
+
("cancel", 30),
|
| 150 |
+
("cancel my account", 30),
|
| 151 |
+
("not renewing", 30),
|
| 152 |
+
("refund", 25),
|
| 153 |
+
("chargeback", 25),
|
| 154 |
+
("too expensive", 20),
|
| 155 |
+
("cheaper", 20),
|
| 156 |
+
("overpriced", 20),
|
| 157 |
+
("can't afford", 20),
|
| 158 |
+
("competitor", 15),
|
| 159 |
+
("switching to", 15),
|
| 160 |
+
("better option", 15),
|
| 161 |
+
("not working", 20),
|
| 162 |
+
("broken", 20),
|
| 163 |
+
("bug", 15),
|
| 164 |
+
("glitch", 15),
|
| 165 |
+
("unusable", 20),
|
| 166 |
+
("frustrated", 15),
|
| 167 |
+
("fed up", 20),
|
| 168 |
+
("done with this", 25),
|
| 169 |
+
("never works", 20),
|
| 170 |
+
("waste of money", 25),
|
| 171 |
+
("leaving", 15),
|
| 172 |
+
("close account", 25),
|
| 173 |
+
("unsubscribe", 15),
|
| 174 |
+
("stop service", 20),
|
| 175 |
+
("downgrade", 10),
|
| 176 |
+
("not happy", 15),
|
| 177 |
+
("disappointed", 15),
|
| 178 |
+
]
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
def detect_churn_keywords(transcript: str) -> dict:
|
| 182 |
+
transcript_lower = transcript.lower()
|
| 183 |
+
flagged = []
|
| 184 |
+
score = 0
|
| 185 |
+
|
| 186 |
+
for keyword, points in CHURN_KEYWORDS:
|
| 187 |
+
if keyword in transcript_lower:
|
| 188 |
+
flagged.append(keyword)
|
| 189 |
+
score = max(score, points)
|
| 190 |
+
|
| 191 |
+
# Count total flagged keywords for aggregate signal
|
| 192 |
+
return {
|
| 193 |
+
"flagged_keywords": flagged,
|
| 194 |
+
"churn_intent_score": min(score, 100),
|
| 195 |
+
"flagged_keyword_count": len(flagged),
|
| 196 |
+
}
|