Sentiment Analysis Pipeline
After a call ends, run the recording through a pipeline that produces structured analysis: sentiment, intent, urgency, conversion probability, extracted entities. This converts raw audio into queryable signal — the bridge between telephony and CRM/marketing systems.
The end-to-end pipeline
Call recorded (SignalWire) → Webhook fires recording.complete event
↓
Recording URL pulled, audio fetched
↓
Speech-to-text (AssemblyAI / Deepgram / SW CI)
↓
Transcript + speaker diarization + timestamps
↓
LLM analysis (Claude / GPT) with structured prompt
↓
Structured JSON output (sentiment, intent, outcome)
↓
Write to leads table → trigger CRM workflows → push to GA4 conversion
Total elapsed time: 30-90 seconds for a 5-minute call. Cost: ~$0.01-0.05 per call depending on length and tier.
Step 1: Transcription
Three primary options:
| Provider | Cost | Accuracy | Diarization | Notes | |---|---|---|---|---| | AssemblyAI | $0.00025/sec ($0.015/min) | Best for accent + jargon | Yes (built-in) | Audio Intelligence add-ons (sentiment, summary, topics) | | Deepgram | $0.0043/min (Nova-2) | Comparable | Yes | Fastest, real-time capable | | SignalWire Call Intelligence | Included in select plans | Good | Yes | No external API call, runs in-platform |
AssemblyAI example
import requests
def transcribe_with_assemblyai(audio_url):
headers = {"authorization": ASSEMBLY_KEY}
response = requests.post(
"https://api.assemblyai.com/v2/transcript",
headers=headers,
json={
"audio_url": audio_url,
"speaker_labels": True,
"sentiment_analysis": True,
"entity_detection": True,
"auto_highlights": True,
"iab_categories": True,
}
)
transcript_id = response.json()["id"]
# Poll for completion
while True:
result = requests.get(
f"https://api.assemblyai.com/v2/transcript/{transcript_id}",
headers=headers
).json()
if result["status"] == "completed":
return result
elif result["status"] == "error":
raise Exception(result["error"])
time.sleep(2)
AssemblyAI returns:
- Full transcript with timestamps
- Per-utterance speaker labels (Speaker A, B, C...)
- Sentiment per sentence (positive, negative, neutral)
- Entity detection (people, places, products, organizations)
- IAB content categories (Auto, Health, Real Estate, etc.)
- Auto-highlights (key phrases)
Deepgram example
from deepgram import DeepgramClient, PrerecordedOptions
dg = DeepgramClient(DEEPGRAM_KEY)
response = dg.listen.prerecorded.v("1").transcribe_url(
{"url": audio_url},
PrerecordedOptions(
model="nova-2",
smart_format=True,
diarize=True,
sentiment=True,
intents=True,
topics=True,
)
)
Deepgram returns similar structured output with built-in sentiment and intent classification.
Step 2: LLM analysis with structured output
Even with AssemblyAI/Deepgram sentiment, the call-specific business logic (service requested, urgency, conversion intent) needs LLM analysis. Use Claude or GPT with a strict JSON-schema prompt.
import anthropic
def analyze_call(transcript):
client = anthropic.Anthropic(api_key=ANTHROPIC_KEY)
response = client.messages.create(
model="claude-opus-4-5",
max_tokens=2000,
system="""You analyze inbound phone calls for a service business and output structured JSON.
You MUST output ONLY valid JSON matching this schema:
{
"service_needed": "string or null",
"urgency_level": "low|medium|high|emergency",
"call_outcome": "lead|customer|complaint|wrong_number|spam|inquiry_only",
"conversion_probability": "0.0-1.0",
"sentiment_overall": "positive|neutral|negative",
"caller_intent_summary": "1-2 sentence summary",
"key_topics": ["array", "of", "topics"],
"entities_mentioned": {
"addresses": [],
"dates": [],
"products": [],
"competitor_mentions": []
},
"agent_quality_notes": "1-2 sentences",
"recommended_followup": "string or null",
"spam_likelihood": "0.0-1.0"
}""",
messages=[{
"role": "user",
"content": f"Analyze this call transcript:\n\n{transcript}"
}]
)
return json.loads(response.content[0].text)
The strict JSON schema in the system prompt makes Claude's output reliably parsable. For Claude specifically, use the tools mechanism for even higher reliability:
response = client.messages.create(
model="claude-opus-4-5",
max_tokens=2000,
tools=[{
"name": "save_call_analysis",
"description": "Save the structured call analysis",
"input_schema": {
"type": "object",
"properties": {
"service_needed": {"type": "string"},
"urgency_level": {"type": "string", "enum": ["low", "medium", "high", "emergency"]},
"call_outcome": {"type": "string", "enum": ["lead", "customer", "complaint", "wrong_number", "spam", "inquiry_only"]},
"conversion_probability": {"type": "number", "minimum": 0, "maximum": 1},
# ... rest of schema
},
"required": ["service_needed", "urgency_level", "call_outcome", "conversion_probability"]
}
}],
tool_choice={"type": "tool", "name": "save_call_analysis"},
messages=[{"role": "user", "content": f"Analyze: {transcript}"}]
)
analysis = response.content[0].input
The tool-use pattern is the most reliable way to get structured JSON from Claude — schema validation is built in.
Step 3: Persistence
CREATE TABLE call_analysis (
id UUID PRIMARY KEY,
call_sid TEXT REFERENCES calls(call_sid),
transcript TEXT,
analysis JSONB,
sentiment_overall TEXT,
call_outcome TEXT,
conversion_probability NUMERIC,
service_needed TEXT,
urgency_level TEXT,
transcript_cost_cents INT,
llm_cost_cents INT,
analyzed_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX idx_outcome ON call_analysis(call_outcome);
CREATE INDEX idx_service ON call_analysis(service_needed);
CREATE INDEX idx_analyzed_at ON call_analysis(analyzed_at DESC);
Index on the most commonly queried fields. The full JSON sits in analysis for ad-hoc queries.
Step 4: Trigger downstream actions
Based on the analysis, fire workflows:
def trigger_actions(call_id, analysis):
if analysis["call_outcome"] == "lead" and analysis["conversion_probability"] > 0.5:
push_to_crm(call_id, analysis, priority="high")
if analysis["urgency_level"] == "emergency":
sms_alert_owner(f"Emergency call: {analysis['caller_intent_summary']}")
if analysis["call_outcome"] == "complaint":
create_helpdesk_ticket(call_id, analysis)
if analysis["spam_likelihood"] > 0.8:
add_to_blocklist(call_caller)
push_to_ga4_conversion_if_appropriate(call_id, analysis)
SignalWire Call Intelligence integration
SignalWire's Call Intelligence is the platform-native version of this pipeline. The SWML record verb with post_prompt runs transcription + an LLM analysis in-platform:
- ai:
prompt:
text: "You are a customer service agent. Help the caller."
post_prompt:
text: |
Analyze this conversation and output JSON:
{
"service_needed": string,
"urgency_level": "low|medium|high|emergency",
"call_outcome": "lead|customer|complaint",
"conversion_probability": 0.0-1.0,
"summary": string
}
post_prompt_url: https://your.api/call-analysis-webhook
When the call ends, SignalWire transcribes, runs the post_prompt as a final LLM turn, and POSTs the JSON to your webhook. Single pipeline call, no external transcription provider.
When to use Call Intelligence vs DIY pipeline:
| Factor | Call Intelligence | DIY pipeline | |---|---|---| | Setup complexity | Low | High | | Cost per call | Bundled in SW pricing | $0.01-0.05 per call | | Customization | Limited to prompt | Full pipeline control | | Diarization quality | Good | AssemblyAI/Deepgram are tops | | Latency to result | At call end | At call end + transcription time | | Best for | Standard cases, faster setup | High-volume, custom analysis |
Cost projections
For 1000 calls/day, average 4 minutes each:
| Component | Cost per call | Daily cost | |---|---|---| | AssemblyAI transcription | $0.06 | $60 | | Claude analysis (Opus) | $0.04 | $40 | | Total | $0.10 | $100 |
For lower cost:
- AssemblyAI Nano model: $0.0125/min ($0.05/call) → ~$50/day
- Claude Sonnet instead of Opus: $0.01/call → ~$10/day
- Combined: $60/day for 1000 calls
For volume above 5K calls/day, consider Deepgram Nova-2 (cheaper) + Claude Haiku for cost optimization.
Real-time vs post-call analysis
This topic focuses on post-call analysis. For real-time (during the call):
- AssemblyAI Streaming for live transcription
- Deepgram real-time for sub-200ms transcription
- Live LLM analysis on partial transcripts (for agent assist — see agent assist)
Post-call analysis is cheaper and more accurate. Use real-time only when there's a clear action requirement during the live conversation.
Error handling
| Failure | Cause | Recovery |
|---|---|---|
| Recording URL 404 | Recording deleted (retention exceeded) | Fall back to "no transcript available" |
| Transcription returns empty | Silent call, very short | Skip analysis, mark as inquiry_only |
| LLM returns invalid JSON | Edge case in prompt | Retry once with strict schema, fall back to manual review |
| Webhook delivery fails | Network issue | Retry with exponential backoff (15s, 1m, 5m, 30m, give up) |
| Caller speaks unknown language | Transcript is garbled | Detect via language ID, route to human review |
Common pitfalls
- Trusting LLM categorization without spot-checking — sample 1% manually for the first 1000 calls to verify the prompt is producing useful output.
- Cost runaway from Opus on long calls — set a max-token budget. Calls > 30 minutes should chunk-then-summarize.
- No retention policy on transcripts — sensitive content (PII, payment info) accumulating indefinitely. Set 90-day retention by default.
- PHI in healthcare calls — see call recording compliance. Use BAA-eligible vendors only (AssemblyAI BAA available enterprise tier).
- Webhook timeout — analysis pipeline can take 60-90 seconds. Webhook receiver must respond fast and process async.
Related patterns
- AssemblyAI transcription — full AssemblyAI reference
- SignalWire Call Intelligence — platform-native pipeline
- Call recording compliance — what you can store and for how long
- Call attribution GA4 GHL — push analysis to revenue systems
- Agent assist — real-time variant
References
- AssemblyAI API documentation — transcript and audio intelligence
- Deepgram API documentation — Nova-2 model
- Anthropic Claude API — tool use for structured output
- SignalWire SWML post_prompt — platform-native analysis