mirror of
https://github.com/x1xhlol/system-prompts-and-models-of-ai-tools.git
synced 2026-06-17 23:09:35 +00:00
feat(dealix): follow-ups + outreach stats + 14 automation tests (40/40 pass)
https://claude.ai/code/session_01W1rJthWDkasijTdXCfxVHs
This commit is contained in:
parent
066ce32aa7
commit
a369e503b3
@ -240,3 +240,16 @@ async def dlq_purge(queue_name: str) -> dict:
|
||||
async def circuit_breaker_states() -> dict:
|
||||
from app.utils.circuit_breaker import registry
|
||||
return {"breakers": registry.all_states()}
|
||||
|
||||
|
||||
# ── Outreach Stats ───────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("/outreach/stats")
|
||||
async def outreach_stats() -> dict:
|
||||
try:
|
||||
from app.api.v1.drafts import draft_stats, _get_db
|
||||
async for db in _get_db():
|
||||
return await draft_stats(db)
|
||||
except Exception:
|
||||
return {"total": 0, "draft": 0, "approved": 0, "sent": 0, "replied": 0, "opted_out": 0, "bounced": 0, "skipped": 0}
|
||||
|
||||
162
salesflow-saas/backend/app/api/v1/followups.py
Normal file
162
salesflow-saas/backend/app/api/v1/followups.py
Normal file
@ -0,0 +1,162 @@
|
||||
"""Follow-up Scheduler — generates follow-up drafts for unreplied outreach.
|
||||
|
||||
Checks sent drafts that haven't received replies after 2/5/10 days
|
||||
and creates new follow-up drafts linked to the original.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Dict, List
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select, and_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
logger = logging.getLogger("dealix.followups")
|
||||
|
||||
router = APIRouter(prefix="/followups", tags=["Follow-ups"])
|
||||
|
||||
|
||||
async def _get_db():
|
||||
from app.database import get_db
|
||||
async for session in get_db():
|
||||
yield session
|
||||
|
||||
|
||||
@router.get("/due")
|
||||
async def list_due_followups(
|
||||
days_since_sent: int = Query(2, ge=1, le=30),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
db: AsyncSession = Depends(_get_db),
|
||||
) -> Dict[str, Any]:
|
||||
from app.models.outreach_draft import OutreachDraft
|
||||
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(days=days_since_sent)
|
||||
stmt = (
|
||||
select(OutreachDraft)
|
||||
.where(
|
||||
and_(
|
||||
OutreachDraft.status == "sent",
|
||||
OutreachDraft.sent_at <= cutoff,
|
||||
OutreachDraft.reply_text.is_(None),
|
||||
)
|
||||
)
|
||||
.order_by(OutreachDraft.sent_at.asc())
|
||||
.limit(limit)
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
rows = list(result.scalars().all())
|
||||
|
||||
due = []
|
||||
for row in rows:
|
||||
days_elapsed = (datetime.now(timezone.utc) - row.sent_at).days if row.sent_at else 0
|
||||
followup_text = ""
|
||||
followup_type = ""
|
||||
if days_elapsed >= 10:
|
||||
followup_text = row.followup_5d or "آخر متابعة — لو مو الوقت المناسب أفهم تماماً. شكراً."
|
||||
followup_type = "day_10_breakup"
|
||||
elif days_elapsed >= 5:
|
||||
followup_text = row.followup_5d or row.followup_2d or ""
|
||||
followup_type = "day_5_value"
|
||||
elif days_elapsed >= 2:
|
||||
followup_text = row.followup_2d or ""
|
||||
followup_type = "day_2_reminder"
|
||||
|
||||
if followup_text:
|
||||
due.append({
|
||||
"original_draft_id": str(row.id),
|
||||
"company": row.company,
|
||||
"channel": row.channel,
|
||||
"contact_email": row.contact_email,
|
||||
"contact_phone": row.contact_phone,
|
||||
"days_since_sent": days_elapsed,
|
||||
"followup_type": followup_type,
|
||||
"followup_text": followup_text,
|
||||
"sector": row.sector,
|
||||
})
|
||||
|
||||
return {
|
||||
"due_count": len(due),
|
||||
"cutoff_days": days_since_sent,
|
||||
"followups": due,
|
||||
}
|
||||
|
||||
|
||||
class GenerateFollowupsRequest(BaseModel):
|
||||
days_since_sent: int = 2
|
||||
max_followups: int = 20
|
||||
|
||||
|
||||
@router.post("/generate")
|
||||
async def generate_followup_drafts(
|
||||
req: GenerateFollowupsRequest,
|
||||
db: AsyncSession = Depends(_get_db),
|
||||
) -> Dict[str, Any]:
|
||||
from app.models.outreach_draft import OutreachDraft
|
||||
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(days=req.days_since_sent)
|
||||
stmt = (
|
||||
select(OutreachDraft)
|
||||
.where(
|
||||
and_(
|
||||
OutreachDraft.status == "sent",
|
||||
OutreachDraft.sent_at <= cutoff,
|
||||
OutreachDraft.reply_text.is_(None),
|
||||
)
|
||||
)
|
||||
.order_by(OutreachDraft.sent_at.asc())
|
||||
.limit(req.max_followups)
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
originals = list(result.scalars().all())
|
||||
|
||||
created = 0
|
||||
batch_id = f"followup_{datetime.now(timezone.utc).strftime('%Y%m%d')}_{str(uuid4())[:6]}"
|
||||
|
||||
for orig in originals:
|
||||
days_elapsed = (datetime.now(timezone.utc) - orig.sent_at).days if orig.sent_at else 0
|
||||
if days_elapsed >= 10:
|
||||
body = orig.followup_5d or "آخر متابعة — لو مناسب نتكلم، أنا موجود. لو لا، شكراً على وقتكم."
|
||||
subject = f"متابعة أخيرة: {orig.company}"
|
||||
elif days_elapsed >= 5:
|
||||
body = orig.followup_5d or orig.followup_2d or ""
|
||||
subject = f"متابعة: {orig.company}"
|
||||
else:
|
||||
body = orig.followup_2d or ""
|
||||
subject = f"متابعة سريعة: {orig.company}"
|
||||
|
||||
if not body:
|
||||
continue
|
||||
|
||||
followup = OutreachDraft(
|
||||
batch_id=batch_id,
|
||||
company=orig.company,
|
||||
contact_name=orig.contact_name,
|
||||
contact_email=orig.contact_email,
|
||||
contact_phone=orig.contact_phone,
|
||||
channel=orig.channel,
|
||||
subject=subject,
|
||||
body=body,
|
||||
sector=orig.sector,
|
||||
city=orig.city,
|
||||
fit_score=orig.fit_score,
|
||||
risk_score=orig.risk_score,
|
||||
status="draft",
|
||||
approval_required=True,
|
||||
source=f"followup_day_{days_elapsed}_of_{str(orig.id)[:8]}",
|
||||
)
|
||||
db.add(followup)
|
||||
created += 1
|
||||
|
||||
if created:
|
||||
await db.commit()
|
||||
|
||||
return {
|
||||
"batch_id": batch_id,
|
||||
"followups_created": created,
|
||||
"originals_checked": len(originals),
|
||||
}
|
||||
@ -144,3 +144,7 @@ api_router.include_router(automation_router.router)
|
||||
# ── Draft Queue — review, approve, send outreach drafts ────────
|
||||
from app.api.v1 import drafts as drafts_router
|
||||
api_router.include_router(drafts_router.router)
|
||||
|
||||
# ── Follow-ups — auto-generate follow-up drafts for unreplied ──
|
||||
from app.api.v1 import followups as followups_router
|
||||
api_router.include_router(followups_router.router)
|
||||
|
||||
175
salesflow-saas/backend/tests/test_automation_system.py
Normal file
175
salesflow-saas/backend/tests/test_automation_system.py
Normal file
@ -0,0 +1,175 @@
|
||||
"""Tests for the full automation outreach system — drafts, pipeline, followups."""
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from fastapi import FastAPI
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app():
|
||||
app = FastAPI()
|
||||
from app.api.v1.automation import router as auto_router
|
||||
app.include_router(auto_router)
|
||||
return app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(app):
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def test_email_generate(client):
|
||||
resp = client.post("/automation/email/generate", json={
|
||||
"company": "Foodics",
|
||||
"sector": "saas",
|
||||
"city": "الرياض",
|
||||
"contact_name": "أحمد",
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "subject_ar" in data
|
||||
assert "body_ar" in data
|
||||
assert "followup_day_2" in data
|
||||
assert "followup_day_5" in data
|
||||
assert "call_script_ar" in data
|
||||
assert "linkedin_manual_message" in data
|
||||
assert data["opt_out_included"] is True
|
||||
assert data["word_count"] > 0
|
||||
assert "Foodics" in data["subject_ar"]
|
||||
|
||||
|
||||
def test_email_generate_with_signals(client):
|
||||
resp = client.post("/automation/email/generate", json={
|
||||
"company": "TestCo",
|
||||
"sector": "real_estate",
|
||||
"signals": ["hubspot"],
|
||||
})
|
||||
data = resp.json()
|
||||
assert "HubSpot" in data["body_ar"]
|
||||
|
||||
|
||||
def test_compliance_check_allowed(client):
|
||||
resp = client.post("/automation/compliance/check", json={
|
||||
"email": "ahmed@company.sa",
|
||||
"company": "TestCo",
|
||||
"source": "linkedin",
|
||||
})
|
||||
data = resp.json()
|
||||
assert data["allowed"] is True
|
||||
assert data["reason"] == "compliant"
|
||||
|
||||
|
||||
def test_compliance_check_opt_out(client):
|
||||
resp = client.post("/automation/compliance/check", json={
|
||||
"email": "ahmed@company.sa",
|
||||
"opt_out": True,
|
||||
})
|
||||
data = resp.json()
|
||||
assert data["allowed"] is False
|
||||
assert data["reason"] == "opt_out"
|
||||
|
||||
|
||||
def test_compliance_check_bounced(client):
|
||||
resp = client.post("/automation/compliance/check", json={
|
||||
"email": "bad@company.sa",
|
||||
"bounced_before": True,
|
||||
})
|
||||
data = resp.json()
|
||||
assert data["allowed"] is False
|
||||
assert data["reason"] == "bounced_before"
|
||||
|
||||
|
||||
def test_compliance_check_high_risk(client):
|
||||
resp = client.post("/automation/compliance/check", json={
|
||||
"email": "ceo@big.sa",
|
||||
"risk_score": 80,
|
||||
})
|
||||
data = resp.json()
|
||||
assert data["allowed"] is False
|
||||
assert data["reason"] == "high_risk"
|
||||
|
||||
|
||||
def test_compliance_check_personal_email(client):
|
||||
resp = client.post("/automation/compliance/check", json={
|
||||
"email": "ahmed@gmail.com",
|
||||
"source": "linkedin",
|
||||
})
|
||||
data = resp.json()
|
||||
assert data["allowed"] is True
|
||||
assert "personal_email" in data["reason"]
|
||||
|
||||
|
||||
def test_compliance_check_no_source(client):
|
||||
resp = client.post("/automation/compliance/check", json={
|
||||
"email": "ahmed@company.sa",
|
||||
"source": "",
|
||||
})
|
||||
data = resp.json()
|
||||
assert data["allowed"] is False
|
||||
assert data["reason"] == "no_source"
|
||||
|
||||
|
||||
def test_reply_classify_interested(client):
|
||||
resp = client.post("/automation/reply/classify", json={
|
||||
"reply_text": "مهتم جداً، أبي أجرب",
|
||||
"company": "TestCo",
|
||||
})
|
||||
data = resp.json()
|
||||
assert data["category"] == "interested"
|
||||
assert data["auto_reply_allowed"] is True
|
||||
assert "calendly" in data["suggested_response"].lower() or "demo" in data["suggested_response"].lower() or "20 دقيقة" in data["suggested_response"]
|
||||
|
||||
|
||||
def test_reply_classify_price(client):
|
||||
resp = client.post("/automation/reply/classify", json={
|
||||
"reply_text": "كم السعر؟",
|
||||
})
|
||||
data = resp.json()
|
||||
assert data["category"] == "ask_price"
|
||||
assert "499" in data["suggested_response"]
|
||||
|
||||
|
||||
def test_reply_classify_unsubscribe(client):
|
||||
resp = client.post("/automation/reply/classify", json={
|
||||
"reply_text": "إيقاف لا تتواصل معي",
|
||||
})
|
||||
data = resp.json()
|
||||
assert data["category"] == "unsubscribe"
|
||||
assert data["auto_reply_allowed"] is False
|
||||
|
||||
|
||||
def test_reply_classify_crm(client):
|
||||
resp = client.post("/automation/reply/classify", json={
|
||||
"reply_text": "عندنا CRM وما نحتاج نظام ثاني",
|
||||
})
|
||||
data = resp.json()
|
||||
assert data["category"] == "already_has_crm"
|
||||
assert "طبقة" in data["suggested_response"] or "CRM" in data["suggested_response"]
|
||||
|
||||
|
||||
def test_daily_targeting_generate(client):
|
||||
resp = client.post("/automation/daily-targeting/generate", json={
|
||||
"sectors": ["real_estate", "construction"],
|
||||
"cities": ["الرياض"],
|
||||
"daily_target_count": 5,
|
||||
})
|
||||
data = resp.json()
|
||||
assert data["total_generated"] > 0
|
||||
assert len(data["targets"]) <= 5
|
||||
assert data["targets"][0]["sector"] in ("real_estate", "construction")
|
||||
assert data["approval_required"] is True
|
||||
|
||||
|
||||
def test_sector_pain_map_coverage(client):
|
||||
"""Verify all 9 sectors produce valid emails."""
|
||||
sectors = ["real_estate", "construction", "hospitality", "food_beverage",
|
||||
"logistics", "agency", "saas", "healthcare", "education"]
|
||||
for sector in sectors:
|
||||
resp = client.post("/automation/email/generate", json={
|
||||
"company": f"Test_{sector}",
|
||||
"sector": sector,
|
||||
})
|
||||
assert resp.status_code == 200, f"Failed for sector: {sector}"
|
||||
data = resp.json()
|
||||
assert len(data["body_ar"]) > 50, f"Empty body for {sector}"
|
||||
assert "إيقاف" in data["body_ar"], f"Missing opt-out for {sector}"
|
||||
Loading…
Reference in New Issue
Block a user