Shaankar39 commited on
Commit
ed6c66c
·
verified ·
1 Parent(s): 687722a

Add Universal Connector: POST /v1/predict/smart — auto-map any columns + derive dates, with mapping report

Browse files
app/routers/__pycache__/predict.cpython-312.pyc CHANGED
Binary files a/app/routers/__pycache__/predict.cpython-312.pyc and b/app/routers/__pycache__/predict.cpython-312.pyc differ
 
app/routers/predict.py CHANGED
@@ -14,7 +14,8 @@ from app.dependencies import (
14
  )
15
  from app.models.user import User
16
  from app.models.mlmodel import MLModel
17
- from app.schemas import PredictionInput, PredictionResponse, SinglePrediction
 
18
  from app.services.scoring import _churn_factor, _lead_factor
19
  from app.services.training import predict_with_model
20
  from app.services.benchmarking import update_benchmarks, compare_to_benchmark
@@ -92,6 +93,51 @@ def _score_with_custom_model(ml_model, data: list, model_type: str) -> list:
92
  return results
93
 
94
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
95
  @router.post("/csv", response_model=PredictionResponse)
96
  async def predict_csv(
97
  file: UploadFile = File(..., description="CSV export of your customers/leads"),
 
14
  )
15
  from app.models.user import User
16
  from app.models.mlmodel import MLModel
17
+ from app.schemas import PredictionInput, PredictionResponse, SinglePrediction, SmartPredictInput
18
+ from app.services.field_mapping import normalize_rows
19
  from app.services.scoring import _churn_factor, _lead_factor
20
  from app.services.training import predict_with_model
21
  from app.services.benchmarking import update_benchmarks, compare_to_benchmark
 
93
  return results
94
 
95
 
96
+ @router.post("/smart", response_model=PredictionResponse)
97
+ def predict_smart(
98
+ body: SmartPredictInput,
99
+ user: User = Depends(get_current_user),
100
+ db: Session = Depends(get_db),
101
+ ):
102
+ """Universal connector — send rows with ANY column names. We auto-map them
103
+ to RevAI's signals (and derive durations from dates), then score. The
104
+ response includes a `field_mapping` report of what was matched/missed."""
105
+ if body.model_type not in ("churn", "lead"):
106
+ raise HTTPException(status_code=400, detail="model_type must be 'churn' or 'lead'")
107
+
108
+ rows, report = normalize_rows(body.data, body.mapping, body.model_type)
109
+
110
+ n_predictions = len(rows)
111
+ check_rate_limit(user, db, f"predict/{body.model_type}", n_predictions)
112
+ check_prediction_quota(user, db, n_predictions)
113
+
114
+ if body.model_id:
115
+ ml_model = db.query(MLModel).filter(
116
+ MLModel.id == body.model_id, MLModel.user_id == user.id
117
+ ).first()
118
+ if not ml_model:
119
+ raise HTTPException(status_code=404, detail="Model not found")
120
+ results = _score_with_custom_model(ml_model, rows, body.model_type)
121
+ model_label = f"custom_ml_{body.model_id[:8]}"
122
+ else:
123
+ results = _apply_heuristics(rows, body.model_type)
124
+ model_label = "heuristic"
125
+
126
+ track_usage(user, db, f"predict/{body.model_type}", n_predictions)
127
+
128
+ all_scores = [p.score for p in results]
129
+ update_benchmarks(db, all_scores, body.model_type)
130
+ benchmark = compare_to_benchmark(all_scores, body.model_type, db)
131
+
132
+ return PredictionResponse(
133
+ predictions=results,
134
+ model_used=model_label,
135
+ usage=get_usage_summary(user, db),
136
+ benchmark=benchmark,
137
+ field_mapping=report,
138
+ )
139
+
140
+
141
  @router.post("/csv", response_model=PredictionResponse)
142
  async def predict_csv(
143
  file: UploadFile = File(..., description="CSV export of your customers/leads"),
app/schemas/__init__.py CHANGED
@@ -85,6 +85,16 @@ class PredictionResponse(BaseModel):
85
  model_used: str # "heuristic" or "custom_ml_<id>"
86
  usage: Dict[str, Any]
87
  benchmark: Optional[Dict[str, Any]] = None # comparison vs industry
 
 
 
 
 
 
 
 
 
 
88
 
89
 
90
  # ── Training ──
 
85
  model_used: str # "heuristic" or "custom_ml_<id>"
86
  usage: Dict[str, Any]
87
  benchmark: Optional[Dict[str, Any]] = None # comparison vs industry
88
+ field_mapping: Optional[Dict[str, Any]] = None # set by the universal connector
89
+
90
+
91
+ class SmartPredictInput(BaseModel):
92
+ data: List[Dict[str, Any]] = Field(..., min_length=1, max_length=1000,
93
+ description="Your rows with WHATEVER column names you already have")
94
+ mapping: Optional[Dict[str, str]] = Field(None,
95
+ description="Optional {canonical_signal: your_column} overrides; auto-detected otherwise")
96
+ model_type: str = Field("churn", description="'churn' or 'lead'")
97
+ model_id: Optional[str] = Field(None, description="Optional trained model ID")
98
 
99
 
100
  # ── Training ──
app/schemas/__pycache__/__init__.cpython-312.pyc CHANGED
Binary files a/app/schemas/__pycache__/__init__.cpython-312.pyc and b/app/schemas/__pycache__/__init__.cpython-312.pyc differ
 
app/services/__pycache__/field_mapping.cpython-312.pyc ADDED
Binary file (7.11 kB). View file
 
app/services/field_mapping.py ADDED
@@ -0,0 +1,143 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Universal Connector — map ANY customer data onto RevAI's signals.
2
+
3
+ Real-world exports never use our exact column names (`last_seen` not
4
+ `days_since_last_login`, `signup_date` not `tenure_days`). This layer:
5
+ 1. honors an explicit {canonical: source_column} mapping if given,
6
+ 2. otherwise auto-detects columns by a big alias table,
7
+ 3. derives durations from dates (signup_date -> tenure_days),
8
+ 4. returns a transparency report of what it matched / missed.
9
+ So a customer can send whatever they already have and still get scored.
10
+ """
11
+ import datetime
12
+ from typing import Any, Dict, List, Optional, Tuple
13
+
14
+ from dateutil import parser as _dateparser
15
+
16
+ # canonical signal -> source-column aliases (priority order; canonical name first)
17
+ CHURN_ALIASES: Dict[str, List[str]] = {
18
+ "customer_id": ["customer_id", "id", "user_id", "account_id", "email", "customer"],
19
+ "tenure_days": ["tenure_days", "tenure", "account_age_days", "account_age",
20
+ "customer_since", "signup_date", "signup", "created_at", "created",
21
+ "join_date", "date_joined", "start_date"],
22
+ "days_since_last_login": ["days_since_last_login", "days_inactive", "last_login",
23
+ "last_login_date", "last_seen", "last_seen_date",
24
+ "last_active", "last_activity"],
25
+ "login_frequency_7d": ["login_frequency_7d", "logins_7d", "weekly_logins",
26
+ "login_count_7d", "logins_per_week"],
27
+ "payment_delays_90d": ["payment_delays_90d", "payment_delays", "late_payments",
28
+ "failed_payments", "missed_payments", "overdue_count"],
29
+ "support_tickets_last_30d": ["support_tickets_last_30d", "support_tickets",
30
+ "tickets_30d", "open_tickets", "tickets"],
31
+ "contract_type": ["contract_type", "plan_interval", "billing_cycle",
32
+ "billing_interval", "subscription_type", "plan"],
33
+ "nps_score": ["nps_score", "nps", "satisfaction", "csat"],
34
+ "feature_adoption_score": ["feature_adoption_score", "feature_adoption", "adoption",
35
+ "adoption_rate", "feature_usage"],
36
+ "avg_session_minutes": ["avg_session_minutes", "session_length", "avg_session_time",
37
+ "avg_session", "session_minutes"],
38
+ "subscription_status": ["subscription_status", "sub_status", "billing_status", "status"],
39
+ }
40
+
41
+ LEAD_ALIASES: Dict[str, List[str]] = {
42
+ "lead_id": ["lead_id", "id", "contact_id", "email", "lead"],
43
+ "demo_requested": ["demo_requested", "requested_demo", "demo"],
44
+ "budget_confirmed": ["budget_confirmed", "has_budget", "budget"],
45
+ "decision_maker_contacted": ["decision_maker_contacted", "dm_contacted",
46
+ "reached_dm", "decision_maker"],
47
+ "engagement_score": ["engagement_score", "engagement"],
48
+ "source": ["source", "lead_source", "channel"],
49
+ "days_in_pipeline": ["days_in_pipeline", "pipeline_days", "age_in_pipeline",
50
+ "entered_pipeline", "pipeline_entry"],
51
+ "previous_conversations": ["previous_conversations", "conversations",
52
+ "num_conversations", "touchpoints"],
53
+ "content_downloads": ["content_downloads", "downloads", "content_downloaded"],
54
+ "email_opens": ["email_opens", "opens", "email_opened"],
55
+ "website_visits": ["website_visits", "visits", "page_views", "sessions"],
56
+ }
57
+
58
+ # canonical fields that should become "days since <date>" when given a date value
59
+ DATE_DERIVED = {"tenure_days", "days_since_last_login", "days_in_pipeline"}
60
+
61
+
62
+ def _norm(k: Any) -> str:
63
+ return str(k).strip().lower().replace(" ", "_").replace("-", "_")
64
+
65
+
66
+ def _is_number(v: Any) -> bool:
67
+ if isinstance(v, (int, float)):
68
+ return True
69
+ s = str(v).strip()
70
+ if not s:
71
+ return False
72
+ return s.replace(".", "", 1).replace("-", "", 1).isdigit()
73
+
74
+
75
+ def _looks_like_date(v: Any) -> bool:
76
+ if _is_number(v) or v is None:
77
+ return False
78
+ try:
79
+ _dateparser.parse(str(v))
80
+ return True
81
+ except (ValueError, OverflowError, TypeError):
82
+ return False
83
+
84
+
85
+ def _days_since(v: Any) -> Optional[int]:
86
+ try:
87
+ dt = _dateparser.parse(str(v))
88
+ except (ValueError, OverflowError, TypeError):
89
+ return None
90
+ now = datetime.datetime.now(dt.tzinfo) if dt.tzinfo else datetime.datetime.now()
91
+ return max(0, (now - dt).days)
92
+
93
+
94
+ def normalize_rows(
95
+ data: List[Dict[str, Any]],
96
+ mapping: Optional[Dict[str, str]] = None,
97
+ model_type: str = "churn",
98
+ ) -> Tuple[List[Dict[str, Any]], Dict[str, Any]]:
99
+ """Return (canonical_rows, report). report shows matched/derived/missing signals."""
100
+ aliases = CHURN_ALIASES if model_type == "churn" else LEAD_ALIASES
101
+ explicit = {c: _norm(src) for c, src in (mapping or {}).items()}
102
+
103
+ report: Dict[str, Any] = {"matched": {}, "missing": [], "ignored_columns": []}
104
+ used_source_keys = set()
105
+ out_rows: List[Dict[str, Any]] = []
106
+
107
+ for row in data:
108
+ norm_row = {_norm(k): v for k, v in row.items()}
109
+ canon_row: Dict[str, Any] = {}
110
+
111
+ for canon, alias_list in aliases.items():
112
+ src_key = None
113
+ if canon in explicit and explicit[canon] in norm_row:
114
+ src_key = explicit[canon]
115
+ else:
116
+ for a in alias_list:
117
+ if _norm(a) in norm_row:
118
+ src_key = _norm(a)
119
+ break
120
+ if src_key is None:
121
+ continue
122
+
123
+ val = norm_row[src_key]
124
+ how = "direct"
125
+ if canon in DATE_DERIVED and _looks_like_date(val):
126
+ d = _days_since(val)
127
+ if d is not None:
128
+ val, how = d, f"derived from date in '{src_key}'"
129
+
130
+ canon_row[canon] = val
131
+ used_source_keys.add(src_key)
132
+ if canon not in report["matched"]:
133
+ report["matched"][canon] = {"source_column": src_key, "how": how}
134
+
135
+ out_rows.append(canon_row)
136
+
137
+ # transparency: which signals never matched, and which columns we ignored
138
+ report["missing"] = [c for c in aliases if c not in report["matched"]]
139
+ if data:
140
+ all_cols = {_norm(k) for k in data[0].keys()}
141
+ report["ignored_columns"] = sorted(all_cols - used_source_keys)
142
+
143
+ return out_rows, report