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

338 lines
13 KiB
Python

"""
Outreach preparation router.
POST /api/v1/outreach/prepare-from-data
Take enriched accounts + apply suppression + per-channel policy →
produce ready/needs_review/blocked counts. Optionally persist to
outreach_queue with approval_required=True.
GET /api/v1/outreach/queue
List queue rows.
POST /api/v1/outreach/queue/{id}/approve
Mark a queued message as approved (does NOT auto-send).
POST /api/v1/outreach/queue/{id}/skip
Mark as skipped with reason.
PDPL & policy guards:
- Suppression hit → blocked
- opt_out=true on contact → blocked
- high risk → needs_review
- missing source → needs_review
- approval_required=True for cold outbound regardless
"""
from __future__ import annotations
import logging
import uuid
from datetime import datetime, timedelta, timezone
from typing import Any
from fastapi import APIRouter, Body, HTTPException
from sqlalchemy import select
from db.models import (
AccountRecord,
ContactRecord,
LeadScoreRecord,
OutreachQueueRecord,
SuppressionRecord,
)
from db.session import async_session_factory
router = APIRouter(prefix="/api/v1/outreach", tags=["outreach"])
log = logging.getLogger(__name__)
def _new_id(prefix: str = "") -> str:
suffix = uuid.uuid4().hex[:24]
return f"{prefix}{suffix}" if prefix else suffix
def _utcnow() -> datetime:
return datetime.now(timezone.utc).replace(tzinfo=None)
# Channel policy
CHANNEL_DEFAULT_APPROVAL = {
"email_warm": True, # always require approval first 30 days
"phone_task": True, # human dials anyway
"website_form_or_phone_task": True,
"in_person_or_phone": True,
"linkedin_manual": True, # never auto, always human
"whatsapp_inbound_only": True, # never cold WhatsApp
"needs_enrichment": True,
}
def _build_message_template(account: dict[str, Any], score: dict[str, Any]) -> str:
"""
Generate a Khaliji opening message based on account + score.
Deterministic — no LLM. Replace later with LLM-generated personalization.
"""
name = account.get("company_name") or "فريقكم"
sector = account.get("sector") or "نشاطكم"
city = account.get("city") or "السعودية"
priority = score.get("priority") or "P2"
channel = score.get("recommended_channel") or "email"
if priority == "P0":
opening = (
f"السلام عليكم، نتابع نشاط {name} في {city} ولاحظنا عدة مؤشرات تخص "
f"تسريع التعامل مع leads العربية في {sector}. "
"Dealix يخدم نفس القطاع ويرد خلال 45 ثانية بالعربي الخليجي مع التزام PDPL. "
"تناسبكم 20 دقيقة هذا الأسبوع نوضح كيف يطبق على وضعكم؟"
)
elif priority == "P1":
opening = (
f"مرحباً، Dealix منصة AI sales rep بالعربي الخليجي تخدم شركات {sector} في {city}. "
"نرد على leads خلال 45 ثانية ونحجز demos تلقائياً. "
"هل عندكم تحدي حالي مع وقت الرد على leads؟"
)
else:
opening = (
f"السلام عليكم {name}، نقدم AI sales rep بالعربي للسوق السعودي. "
"رغبت أعرف هل تواجهون تحدي مع وقت الرد على العملاء الجدد بعد التواصل الأولي؟"
)
opening += f"\n\n— Sami | Dealix\nhttps://dealix.me\nالقناة المقترحة: {channel} | الأولوية: {priority}"
return opening
@router.post("/prepare-from-data")
async def prepare_from_data(body: dict[str, Any] = Body(default={})) -> dict[str, Any]:
"""
Walk enriched accounts and produce an outreach plan.
Body:
priority: filter by P0/P1/P2/P3 (default: all P0+P1)
max_accounts: int (default 50)
persist: bool (default False) — actually create OutreachQueueRecord rows
channels: list[str] (default: all)
"""
priorities = body.get("priority") or ["P0", "P1"]
if isinstance(priorities, str):
priorities = [priorities]
max_accounts = int(body.get("max_accounts") or 50)
persist = bool(body.get("persist", False))
allowed_channels = body.get("channels")
if max_accounts < 1 or max_accounts > 500:
raise HTTPException(400, "max_accounts_out_of_range")
async with async_session_factory() as session:
try:
# Get enriched accounts with their latest scores
accounts = (await session.execute(
select(AccountRecord).where(AccountRecord.status == "enriched")
.limit(max_accounts * 3) # over-fetch then filter
)).scalars().all()
scores = (await session.execute(
select(LeadScoreRecord)
.where(LeadScoreRecord.account_id.in_([a.id for a in accounts]))
)).scalars().all()
score_map: dict[str, LeadScoreRecord] = {}
for s in scores:
if s.account_id not in score_map or s.created_at > score_map[s.account_id].created_at:
score_map[s.account_id] = s
contacts = (await session.execute(
select(ContactRecord).where(
ContactRecord.account_id.in_([a.id for a in accounts])
)
)).scalars().all()
contacts_by_acc: dict[str, list[ContactRecord]] = {}
for c in contacts:
contacts_by_acc.setdefault(c.account_id, []).append(c)
suppressed = (await session.execute(select(SuppressionRecord))).scalars().all()
except Exception as exc: # noqa: BLE001
return {"status": "skipped_db_unreachable", "error": str(exc)}
sup_emails = {s.email for s in suppressed if s.email}
sup_phones = {s.phone for s in suppressed if s.phone}
sup_domains = {s.domain for s in suppressed if s.domain}
ready: list[dict[str, Any]] = []
needs_review: list[dict[str, Any]] = []
blocked: list[dict[str, Any]] = []
queue_rows: list[OutreachQueueRecord] = []
for acc in accounts:
score = score_map.get(acc.id)
if not score:
continue
if score.priority not in priorities:
continue
channel = score.recommended_channel
if allowed_channels and channel not in allowed_channels:
continue
account_payload = {
"id": acc.id, "company_name": acc.company_name,
"domain": acc.domain, "website": acc.website,
"city": acc.city, "sector": acc.sector,
}
score_payload = {
"fit": score.fit_score, "intent": score.intent_score,
"total": score.total_score, "priority": score.priority,
"recommended_channel": channel, "reason": score.reason,
}
ac_contacts = contacts_by_acc.get(acc.id, [])
block_reasons: list[str] = []
review_reasons: list[str] = []
# Suppression check
if acc.domain and acc.domain in sup_domains:
block_reasons.append("domain_suppressed")
for c in ac_contacts:
if c.opt_out:
block_reasons.append("contact_opted_out")
if c.email and c.email in sup_emails:
block_reasons.append("email_suppressed")
if c.phone and c.phone in sup_phones:
block_reasons.append("phone_suppressed")
# Risk gates
if (acc.risk_level or "").lower() == "high":
review_reasons.append("high_risk_level")
if not (acc.extra or {}).get("allowed_use"):
review_reasons.append("missing_allowed_use")
if not channel or channel == "needs_enrichment":
review_reasons.append("needs_enrichment")
if block_reasons:
blocked.append({
"account_id": acc.id, "company": acc.company_name,
"priority": score.priority, "reasons": block_reasons,
})
continue
# Build the message
message = _build_message_template(account_payload, score_payload)
entry = {
"account_id": acc.id, "company": acc.company_name,
"channel": channel, "priority": score.priority,
"score": score.total_score, "message": message,
"approval_required": CHANNEL_DEFAULT_APPROVAL.get(channel or "", True),
"due_at": (_utcnow() + timedelta(hours=2)).isoformat(),
}
if review_reasons:
entry["review_reasons"] = review_reasons
needs_review.append(entry)
else:
ready.append(entry)
if persist:
queue_rows.append(OutreachQueueRecord(
id=_new_id("oq_"),
lead_id=acc.id,
channel=channel or "manual",
message=message,
approval_required=True, # always require for first 30 days
status="queued",
due_at=_utcnow() + timedelta(hours=2),
risk_reason=None,
))
if len(ready) + len(needs_review) >= max_accounts:
break
if persist and queue_rows:
for q in queue_rows:
session.add(q)
try:
await session.commit()
except Exception as exc: # noqa: BLE001
await session.rollback()
return {"status": "commit_failed", "error": str(exc)}
return {
"status": "ok",
"filters": {"priorities": priorities, "channels": allowed_channels},
"ready_count": len(ready),
"needs_review_count": len(needs_review),
"blocked_count": len(blocked),
"persisted": persist and bool(queue_rows),
"ready": ready,
"needs_review": needs_review,
"blocked": blocked,
}
@router.get("/queue")
async def list_queue(status: str | None = None, limit: int = 100) -> dict[str, Any]:
async with async_session_factory() as session:
try:
q = select(OutreachQueueRecord).order_by(OutreachQueueRecord.due_at).limit(min(500, limit))
if status:
q = q.where(OutreachQueueRecord.status == status)
rows = (await session.execute(q)).scalars().all()
except Exception as exc: # noqa: BLE001
return {"status": "skipped_db_unreachable", "error": str(exc), "items": []}
return {
"count": len(rows),
"items": [
{
"id": r.id, "lead_id": r.lead_id, "channel": r.channel,
"message": r.message, "approval_required": r.approval_required,
"status": r.status, "due_at": r.due_at.isoformat(),
"sent_at": r.sent_at.isoformat() if r.sent_at else None,
"risk_reason": r.risk_reason,
}
for r in rows
],
}
@router.post("/queue/{queue_id}/approve")
async def approve_queue(queue_id: str) -> dict[str, Any]:
async with async_session_factory() as session:
try:
q = (await session.execute(
select(OutreachQueueRecord).where(OutreachQueueRecord.id == queue_id)
)).scalar_one_or_none()
if not q:
raise HTTPException(404, "queue_not_found")
q.status = "approved"
except HTTPException:
raise
except Exception as exc: # noqa: BLE001
return {"status": "skipped_db_unreachable", "error": str(exc)}
try:
await session.commit()
except Exception as exc: # noqa: BLE001
await session.rollback()
return {"status": "commit_failed", "error": str(exc)}
return {"id": queue_id, "status": "approved"}
@router.post("/queue/{queue_id}/skip")
async def skip_queue(queue_id: str, body: dict[str, Any] = Body(default={})) -> dict[str, Any]:
reason = str(body.get("reason") or "manual_skip")[:255]
async with async_session_factory() as session:
try:
q = (await session.execute(
select(OutreachQueueRecord).where(OutreachQueueRecord.id == queue_id)
)).scalar_one_or_none()
if not q:
raise HTTPException(404, "queue_not_found")
q.status = "skipped"
q.risk_reason = reason
except HTTPException:
raise
except Exception as exc: # noqa: BLE001
return {"status": "skipped_db_unreachable", "error": str(exc)}
try:
await session.commit()
except Exception as exc: # noqa: BLE001
await session.rollback()
return {"status": "commit_failed", "error": str(exc)}
return {"id": queue_id, "status": "skipped", "reason": reason}