mirror of
https://github.com/x1xhlol/system-prompts-and-models-of-ai-tools.git
synced 2026-06-18 07:19:35 +00:00
338 lines
13 KiB
Python
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}
|