system-prompts-and-models-o.../dealix/api/routers/customer_success.py
2026-05-01 14:03:52 +03:00

376 lines
16 KiB
Python

"""
Customer Success router — health scores, QBRs, and Saudi B2B Pulse.
Endpoints:
POST /api/v1/customer-success/health/{customer_id} — compute health score
GET /api/v1/customer-success/at-risk — list at-risk customers
POST /api/v1/customer-success/qbr/{customer_id} — generate QBR (md + json)
GET /api/v1/customer-success/benchmarks/{sector} — sector percentiles
POST /api/v1/customer-success/compare/{customer_id} — customer vs sector
GET /api/v1/customer-success/saudi-b2b-pulse — public monthly report
Privacy: benchmarks use min cohort = 5 (re-identification guard).
"""
from __future__ import annotations
import logging
from collections import defaultdict
from datetime import datetime, timedelta, timezone
from typing import Any
from fastapi import APIRouter, Body, HTTPException
from sqlalchemy import func, select
from auto_client_acquisition.customer_success.benchmarks import (
MIN_COHORT_SIZE, compare_customer, compute_sector_benchmark, saudi_b2b_pulse,
)
from auto_client_acquisition.customer_success.health_score import compute_health
from auto_client_acquisition.customer_success.qbr_generator import generate_qbr
from db.models import (
AccountRecord, CustomerRecord, EmailSendLog, GmailDraftRecord,
LeadScoreRecord, LinkedInDraftRecord,
)
from db.session import async_session_factory
router = APIRouter(prefix="/api/v1/customer-success", tags=["customer-success"])
log = logging.getLogger(__name__)
def _utcnow() -> datetime:
return datetime.now(timezone.utc).replace(tzinfo=None)
# ── Health score for one customer ─────────────────────────────────
@router.post("/health/{customer_id}")
async def compute_customer_health(customer_id: str) -> dict[str, Any]:
"""Compute live health score for a customer using last-30d signals."""
cutoff_30d = _utcnow() - timedelta(days=30)
async with async_session_factory() as session:
try:
cust = (await session.execute(
select(CustomerRecord).where(CustomerRecord.id == customer_id)
)).scalar_one_or_none()
if not cust:
raise HTTPException(404, "customer_not_found")
drafts_created = int((await session.execute(
select(func.count()).select_from(GmailDraftRecord).where(
GmailDraftRecord.created_at >= cutoff_30d,
)
)).scalar() or 0)
drafts_sent = int((await session.execute(
select(func.count()).select_from(GmailDraftRecord).where(
GmailDraftRecord.created_at >= cutoff_30d,
GmailDraftRecord.status == "sent",
)
)).scalar() or 0)
replies = int((await session.execute(
select(func.count()).select_from(EmailSendLog).where(
EmailSendLog.reply_received_at >= cutoff_30d,
)
)).scalar() or 0)
total_drafts = int((await session.execute(
select(func.count()).select_from(GmailDraftRecord)
)).scalar() or 0)
except HTTPException:
raise
except Exception as exc: # noqa: BLE001
return {"status": "skipped_db_unreachable", "error": str(exc)}
days_since_login = (
(_utcnow() - cust.updated_at).days
if cust.updated_at else 0
)
score = compute_health(
customer_id=customer_id,
logins_last_30d=max(0, 22 - days_since_login),
drafts_approved_last_30d=drafts_sent,
replies_acted_on_last_30d=replies,
demos_booked_last_30d=int(cust.daily_report_sent or 0) // 5,
deals_stage_progressed_last_30d=drafts_sent // 5,
paid_customers_last_30d=1 if cust.onboarding_status != "kickoff_pending" else 0,
pipeline_value_sar=drafts_sent * 5000, # rough estimate
channels_enabled=2, # default 2 (Gmail + LinkedIn)
integrations_connected=1,
sectors_targeted=1,
total_drafts_lifetime=total_drafts,
nps=cust.nps_score,
support_tickets_open=0,
days_since_last_login=days_since_login,
billing_failures=0,
)
return score.to_dict()
@router.get("/at-risk")
async def list_at_risk_customers() -> dict[str, Any]:
"""Return all customers in at_risk or critical buckets."""
async with async_session_factory() as session:
try:
customers = (await session.execute(select(CustomerRecord))).scalars().all()
except Exception as exc: # noqa: BLE001
return {"status": "skipped_db_unreachable", "error": str(exc), "items": []}
at_risk: list[dict[str, Any]] = []
for c in customers:
# Simplified: pull the full health score per customer
days_idle = (_utcnow() - c.updated_at).days if c.updated_at else 0
score = compute_health(
customer_id=c.id,
logins_last_30d=max(0, 22 - days_idle),
nps=c.nps_score,
days_since_last_login=days_idle,
drafts_approved_last_30d=0, # TODO query per customer
)
if score.bucket in {"at_risk", "critical"}:
at_risk.append(score.to_dict())
return {
"count": len(at_risk),
"customers": sorted(at_risk, key=lambda x: x["overall"]),
"next_action": "Reach out to critical bucket within 24 hours.",
}
# ── QBR generator ─────────────────────────────────────────────────
@router.post("/qbr/{customer_id}")
async def generate_customer_qbr(customer_id: str, body: dict[str, Any] = Body(default={})) -> dict[str, Any]:
"""Generate a Quarterly Business Review for a customer (default: last 30 days)."""
period_days = int(body.get("period_days") or 30)
cutoff = _utcnow() - timedelta(days=period_days)
async with async_session_factory() as session:
try:
cust = (await session.execute(
select(CustomerRecord).where(CustomerRecord.id == customer_id)
)).scalar_one_or_none()
if not cust:
raise HTTPException(404, "customer_not_found")
emails_sent = int((await session.execute(
select(func.count()).select_from(EmailSendLog).where(
EmailSendLog.sent_at >= cutoff,
EmailSendLog.status == "sent",
)
)).scalar() or 0)
emails_replied = int((await session.execute(
select(func.count()).select_from(EmailSendLog).where(
EmailSendLog.reply_received_at >= cutoff,
)
)).scalar() or 0)
emails_bounced = int((await session.execute(
select(func.count()).select_from(EmailSendLog).where(
EmailSendLog.status == "bounced",
EmailSendLog.updated_at >= cutoff,
)
)).scalar() or 0)
drafts_created = int((await session.execute(
select(func.count()).select_from(GmailDraftRecord).where(
GmailDraftRecord.created_at >= cutoff,
)
)).scalar() or 0)
drafts_sent = int((await session.execute(
select(func.count()).select_from(GmailDraftRecord).where(
GmailDraftRecord.created_at >= cutoff,
GmailDraftRecord.status == "sent",
)
)).scalar() or 0)
linkedin_drafts = int((await session.execute(
select(func.count()).select_from(LinkedInDraftRecord).where(
LinkedInDraftRecord.created_at >= cutoff,
)
)).scalar() or 0)
linkedin_sent = int((await session.execute(
select(func.count()).select_from(LinkedInDraftRecord).where(
LinkedInDraftRecord.created_at >= cutoff,
LinkedInDraftRecord.status == "sent",
)
)).scalar() or 0)
except HTTPException:
raise
except Exception as exc: # noqa: BLE001
return {"status": "skipped_db_unreachable", "error": str(exc)}
# Health score
days_idle = (_utcnow() - cust.updated_at).days if cust.updated_at else 0
health = compute_health(
customer_id=customer_id,
logins_last_30d=max(0, 22 - days_idle),
drafts_approved_last_30d=drafts_sent,
replies_acted_on_last_30d=emails_replied,
nps=cust.nps_score, days_since_last_login=days_idle,
total_drafts_lifetime=drafts_created,
)
qbr = generate_qbr(
customer_id=customer_id,
customer_name=cust.company_id or customer_id,
period_days=period_days,
emails_sent=emails_sent, emails_replied=emails_replied,
emails_bounced=emails_bounced,
drafts_created=drafts_created, drafts_sent=drafts_sent,
linkedin_drafts=linkedin_drafts, linkedin_sent=linkedin_sent,
health_overall=health.overall, health_bucket=health.bucket,
current_plan=cust.plan,
)
return {
"qbr": qbr.to_dict(),
"markdown": qbr.to_markdown(),
"health": health.to_dict(),
}
# ── Sector benchmarks (private to subscribers) ────────────────────
@router.get("/benchmarks/{sector}")
async def get_sector_benchmarks(sector: str, metric: str = "reply_rate") -> dict[str, Any]:
"""Sector percentiles. Requires >=5 customers in sector for privacy."""
cutoff_30d = _utcnow() - timedelta(days=30)
async with async_session_factory() as session:
try:
accounts = (await session.execute(
select(AccountRecord).where(AccountRecord.sector == sector)
)).scalars().all()
account_ids = [a.id for a in accounts]
if not account_ids:
return {"status": "no_data", "sector": sector}
sends = (await session.execute(
select(EmailSendLog).where(
EmailSendLog.account_id.in_(account_ids),
EmailSendLog.sent_at >= cutoff_30d,
)
)).scalars().all()
except Exception as exc: # noqa: BLE001
return {"status": "skipped_db_unreachable", "error": str(exc)}
# Group by account_id, compute reply rates
by_account: dict[str, dict[str, int]] = defaultdict(lambda: {"sent": 0, "replied": 0})
for s in sends:
by_account[s.account_id]["sent"] += 1
if s.reply_received_at:
by_account[s.account_id]["replied"] += 1
if metric == "reply_rate":
values = [
(v["replied"] / max(1, v["sent"])) * 100
for v in by_account.values() if v["sent"] >= 5
]
elif metric == "send_volume":
values = [v["sent"] for v in by_account.values()]
else:
return {"status": "unknown_metric", "sector": sector, "metric": metric,
"valid_metrics": ["reply_rate", "send_volume"]}
bench = compute_sector_benchmark(sector, metric, values)
if bench is None:
return {
"status": "cohort_too_small",
"sector": sector, "metric": metric,
"min_required": MIN_COHORT_SIZE, "current": len(values),
"note": "Privacy guard: need ≥5 active customers in this sector.",
}
return {"status": "ok", "benchmark": bench.to_dict()}
@router.post("/compare/{customer_id}")
async def compare_to_sector(customer_id: str, body: dict[str, Any] = Body(default={})) -> dict[str, Any]:
"""Where does this customer rank in their sector cohort?"""
metric = str(body.get("metric") or "reply_rate")
cutoff_30d = _utcnow() - timedelta(days=30)
async with async_session_factory() as session:
try:
cust = (await session.execute(
select(CustomerRecord).where(CustomerRecord.id == customer_id)
)).scalar_one_or_none()
if not cust or not cust.company_id:
return {"status": "customer_or_company_not_found"}
company = (await session.execute(
select(AccountRecord).where(AccountRecord.id == cust.company_id)
)).scalar_one_or_none()
if not company:
return {"status": "company_not_found"}
sector = company.sector or "unknown"
peers = (await session.execute(
select(AccountRecord).where(AccountRecord.sector == sector)
)).scalars().all()
peer_ids = [a.id for a in peers]
sends = (await session.execute(
select(EmailSendLog).where(
EmailSendLog.account_id.in_(peer_ids),
EmailSendLog.sent_at >= cutoff_30d,
)
)).scalars().all()
except Exception as exc: # noqa: BLE001
return {"status": "skipped_db_unreachable", "error": str(exc)}
by_acc: dict[str, dict[str, int]] = defaultdict(lambda: {"sent": 0, "replied": 0})
for s in sends:
by_acc[s.account_id]["sent"] += 1
if s.reply_received_at:
by_acc[s.account_id]["replied"] += 1
sector_values = [
(v["replied"] / max(1, v["sent"])) * 100
for v in by_acc.values() if v["sent"] >= 5
]
customer_stats = by_acc.get(cust.company_id, {"sent": 0, "replied": 0})
customer_value = (
(customer_stats["replied"] / max(1, customer_stats["sent"])) * 100
)
cmp = compare_customer(
customer_id=customer_id, sector=sector, metric=metric,
customer_value=customer_value, sector_values=sector_values,
)
if cmp is None:
return {"status": "cohort_too_small", "min_required": MIN_COHORT_SIZE}
return cmp.to_dict()
# ── Saudi B2B Pulse (public, monthly) ─────────────────────────────
@router.get("/saudi-b2b-pulse")
async def get_saudi_b2b_pulse() -> dict[str, Any]:
"""Public anonymized monthly report — works as a free lead magnet."""
cutoff_30d = _utcnow() - timedelta(days=30)
async with async_session_factory() as session:
try:
accounts = (await session.execute(select(AccountRecord))).scalars().all()
sector_to_ids: dict[str, list[str]] = defaultdict(list)
for a in accounts:
sector_to_ids[a.sector or "unknown"].append(a.id)
all_sends = (await session.execute(
select(EmailSendLog).where(EmailSendLog.sent_at >= cutoff_30d)
)).scalars().all()
by_acc: dict[str, dict[str, int]] = defaultdict(lambda: {"sent": 0, "replied": 0})
for s in all_sends:
by_acc[s.account_id]["sent"] += 1
if s.reply_received_at:
by_acc[s.account_id]["replied"] += 1
except Exception as exc: # noqa: BLE001
return {"status": "skipped_db_unreachable", "error": str(exc)}
sector_data: dict[str, dict[str, list[float]]] = {}
for sector, ids in sector_to_ids.items():
if len(ids) < MIN_COHORT_SIZE:
continue
reply_rates = [
(by_acc[i]["replied"] / max(1, by_acc[i]["sent"])) * 100
for i in ids if by_acc[i]["sent"] >= 5
]
send_volumes = [by_acc[i]["sent"] for i in ids]
if reply_rates or send_volumes:
sector_data[sector] = {}
if reply_rates:
sector_data[sector]["reply_rate"] = reply_rates
if send_volumes:
sector_data[sector]["send_volume"] = [float(s) for s in send_volumes]
return saudi_b2b_pulse(sector_data=sector_data)