Shaankar39 commited on
Commit
3d1332e
Β·
0 Parent(s):

Deploy RevAI API

Browse files
This view is limited to 50 files because it contains too many changes. Β  See raw diff
Files changed (50) hide show
  1. .dockerignore +12 -0
  2. Dockerfile +17 -0
  3. README.md +34 -0
  4. app/__init__.py +0 -0
  5. app/__pycache__/__init__.cpython-312.pyc +0 -0
  6. app/__pycache__/config.cpython-312.pyc +0 -0
  7. app/__pycache__/database.cpython-312.pyc +0 -0
  8. app/__pycache__/dependencies.cpython-312.pyc +0 -0
  9. app/__pycache__/main.cpython-312.pyc +0 -0
  10. app/config.py +43 -0
  11. app/database.py +24 -0
  12. app/dependencies.py +219 -0
  13. app/main.py +80 -0
  14. app/models/__init__.py +8 -0
  15. app/models/__pycache__/__init__.cpython-312.pyc +0 -0
  16. app/models/__pycache__/apikey.cpython-312.pyc +0 -0
  17. app/models/__pycache__/benchmark.cpython-312.pyc +0 -0
  18. app/models/__pycache__/mlmodel.cpython-312.pyc +0 -0
  19. app/models/__pycache__/usage.cpython-312.pyc +0 -0
  20. app/models/__pycache__/user.cpython-312.pyc +0 -0
  21. app/models/apikey.py +20 -0
  22. app/models/benchmark.py +29 -0
  23. app/models/mlmodel.py +34 -0
  24. app/models/usage.py +18 -0
  25. app/models/user.py +23 -0
  26. app/routers/__init__.py +0 -0
  27. app/routers/__pycache__/__init__.cpython-312.pyc +0 -0
  28. app/routers/__pycache__/audio.cpython-312.pyc +0 -0
  29. app/routers/__pycache__/auth.cpython-312.pyc +0 -0
  30. app/routers/__pycache__/benchmarks.cpython-312.pyc +0 -0
  31. app/routers/__pycache__/predict.cpython-312.pyc +0 -0
  32. app/routers/__pycache__/training.cpython-312.pyc +0 -0
  33. app/routers/__pycache__/usage.cpython-312.pyc +0 -0
  34. app/routers/audio.py +114 -0
  35. app/routers/auth.py +116 -0
  36. app/routers/benchmarks.py +18 -0
  37. app/routers/predict.py +186 -0
  38. app/routers/training.py +120 -0
  39. app/routers/usage.py +29 -0
  40. app/schemas/__init__.py +124 -0
  41. app/schemas/__pycache__/__init__.cpython-312.pyc +0 -0
  42. app/services/__init__.py +0 -0
  43. app/services/__pycache__/__init__.cpython-312.pyc +0 -0
  44. app/services/__pycache__/audio.cpython-312.pyc +0 -0
  45. app/services/__pycache__/benchmarking.cpython-312.pyc +0 -0
  46. app/services/__pycache__/scoring.cpython-312.pyc +0 -0
  47. app/services/__pycache__/training.cpython-312.pyc +0 -0
  48. app/services/audio.py +105 -0
  49. app/services/benchmarking.py +192 -0
  50. 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
+ }