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:
|
async def circuit_breaker_states() -> dict:
|
||||||
from app.utils.circuit_breaker import registry
|
from app.utils.circuit_breaker import registry
|
||||||
return {"breakers": registry.all_states()}
|
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 ────────
|
# ── Draft Queue — review, approve, send outreach drafts ────────
|
||||||
from app.api.v1 import drafts as drafts_router
|
from app.api.v1 import drafts as drafts_router
|
||||||
api_router.include_router(drafts_router.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