DEV Community

Cover image for Building an AI-Powered Contact Center Quality Monitoring System on AWS
Robindeva
Robindeva

Posted on • Edited on

Building an AI-Powered Contact Center Quality Monitoring System on AWS

Introduction

Contact centers generate thousands of hours of customer calls every day. Reviewing them manually is not only time-consuming but also inconsistent. Supervisors often struggle to answer questions like:

  • How satisfied are customers during calls?
  • Are agents following compliance scripts?
  • Can we quickly summarize what happened in each call?

In this blog, I’ll walk you through how I built a serverless, AI-powered pipeline on AWS that automates all of this using:

Amazon S3 → for storing audio recordings
Amazon Transcribe → to convert speech to text
Amazon Bedrock (Titan) → to analyze sentiment, compliance, and summaries
Amazon DynamoDB → to store structured insights
Amazon QuickSight → to visualize the results in dashboards

Solution Architecture

Architecture

Here’s the high-level flow of the system:

  1. Upload Call Recording → stored in Amazon S3.
  2. Lambda Function 1 (TranscribeLambda) → triggers Amazon Transcribe to convert speech → text.
  3. Lambda Function 2 (BedrockAnalysisLambda) → processes the transcript using Amazon Bedrock (Titan).
  4. Detects Sentiment (Positive/Negative/Neutral).
  5. Runs a Compliance Check (Pass/Fail).
  6. Generates a short summary of the call.
  7. Results are saved in Amazon DynamoDB.
  8. Supervisors view real-time analytics in Amazon QuickSight dashboards.

This modular design ensures reliability and scalability — transcription and AI analysis scale independently.

Setting Up the Pipeline
1. Create S3 Bucket

aws s3 mb s3://contact-center-demo-bucket
Enter fullscreen mode Exit fullscreen mode

This bucket will store:

  • Input audio recordings
  • Transcribe-generated transcripts

2. DynamoDB Table
We use DynamoDB to store insights per call:

aws dynamodb create-table \
  --table-name CallAnalysis \
  --attribute-definitions AttributeName=CallID,AttributeType=S \
  --key-schema AttributeName=CallID,KeyType=HASH \
  --billing-mode PAY_PER_REQUEST
Enter fullscreen mode Exit fullscreen mode

3. Lambda #1 — TranscribeLambda
This function runs when an audio file is uploaded.

import boto3, os, time, re

transcribe = boto3.client('transcribe')
s3_bucket = os.environ['S3_BUCKET']

def lambda_handler(event, context):
    file_name = event['Records'][0]['s3']['object']['key']
    base_name = file_name.split("/")[-1]
    safe_name = re.sub(r'[^0-9a-zA-Z._-]', '_', base_name)
    job_name = safe_name + "-" + str(int(time.time()))

    transcribe.start_transcription_job(
        TranscriptionJobName=job_name,
        Media={'MediaFileUri': f"s3://{s3_bucket}/{file_name}"},
        MediaFormat='mp3',
        LanguageCode='en-US',
        OutputBucketName=s3_bucket
    )

    return {"message": f"Started Transcription Job: {job_name}"}
Enter fullscreen mode Exit fullscreen mode

4. Lambda #2 — BedrockAnalysisLambda (Titan)

This function analyzes transcripts via Amazon Titan and stores results in DynamoDB.

import boto3, json, os, urllib.parse

s3 = boto3.client('s3')
dynamodb = boto3.resource('dynamodb')
table = dynamodb.Table(os.environ['DDB_TABLE'])
bedrock = boto3.client("bedrock-runtime", region_name="us-east-1")

def lambda_handler(event, context):
    bucket = event['Records'][0]['s3']['bucket']['name']
    key = urllib.parse.unquote_plus(event['Records'][0]['s3']['object']['key'])

    if not key.endswith(".json"):
        return {"message": "Not a transcript JSON file"}

    response = s3.get_object(Bucket=bucket, Key=key)
    transcript_json = json.loads(response['Body'].read())
    transcript_text = transcript_json['results']['transcripts'][0]['transcript']

    prompt = f"""
    Analyze the following customer service call transcript:
    Transcript: {transcript_text}

    Provide the following JSON output:
    {{
      "Sentiment": "Positive | Negative | Neutral | Mixed",
      "ComplianceCheck": "Pass | Fail",
      "Summary": "<short summary of the call in 2 sentences>"
    }}
    """

    response = bedrock.invoke_model(
        modelId="amazon.titan-text-express-v1",
        contentType="application/json",
        accept="application/json",
        body=json.dumps({
            "inputText": prompt,
            "textGenerationConfig": {
                "maxTokenCount": 512,
                "temperature": 0.7,
                "topP": 0.9
            }
        })
    )

    result_str = response['body'].read().decode("utf-8")
    parsed = json.loads(result_str)
    output_text = parsed["results"][0]["outputText"]

    try:
        analysis = json.loads(output_text)
    except:
        analysis = {"RawOutput": output_text}

    table.put_item(Item={
        "CallID": key,
        "Transcript": transcript_text,
        "Sentiment": analysis.get("Sentiment", "Unknown"),
        "ComplianceCheck": analysis.get("ComplianceCheck", "Unknown"),
        "Summary": analysis.get("Summary", output_text)
    })

    return {"message": f"Processed call {key}", "Analysis": analysis}

Enter fullscreen mode Exit fullscreen mode

QuickSight Dashboard

Finally, you can connect QuickSight to DynamoDB to see the data.

  • Sentiment Pie Chart → Positive/Negative/Neutral split.
  • Compliance KPI → Percentage of compliant calls.
  • Summary Table → Quick overview of each call. This gives supervisors real-time visibility into call quality.

Step-by-Step: Add S3 Triggers for Lambda

  1. Open the S3 Bucket
  2. Go to AWS Console → S3.
  3. Find and click your bucket (contact-center-demo-bucket).
  4. Go to the Properties tab.

Add Event Notification for Audio → Transcribe

  1. Scroll down to Event notifications → click Create event notification.
  2. Give it a name: AudioToTranscribe.
  3. Event types: PUT / All object create events.
  4. Prefix filter (optional but recommended): audio/
  5. This keeps recordings in s3://bucket/audio/....
  6. Suffix filter: .mp3 (or .wav if needed).
  7. Destination: Lambda function.
  8. Choose TranscribeLambda.
  9. Save.

Add Event Notification for Transcript → Bedrock

  1. Still in the same bucket → Create event notification again.
  2. Name it: TranscriptToBedrock.
  3. Event types: PUT / All object create events.
  4. Prefix filter (optional): transcripts/
  5. Suffix filter: .json.
  6. Destination: Lambda function.
  7. Choose BedrockAnalysisLambda.
  8. Save.

Sample audio: https://github.com/aws-samples/amazon-transcribe-output-word-document/blob/main/sample-data/example-call.wav

Workflow

Here’s how the demo works in action:

  1. Upload sample_call.mp3 to S3.
  2. Amazon Transcribe job runs → transcript JSON created.
  3. Transcript triggers Bedrock Lambda → Titan generates sentiment, compliance, and summary.
  4. DynamoDB stores structured results.
  5. QuickSight dashboard updates → insights appear instantly.

Business Impact

  • Faster QA: No need for manual call listening.
  • Consistency: AI applies rules the same way every time.
  • Scalability: Works for 100 or 100,000 calls.
  • Actionable Insights: Supervisors can track compliance and customer satisfaction in real-time.

Challenges & Lessons Learned

I honestly thought this would be a quick build. It wasn’t. A few things went wrong, a few things surprised me, and I spent more time debugging than I expected. But that’s usually how these projects go.

The first headache was with S3 triggers. I connected two triggers to the same bucket — one for audio files and one for transcripts. On paper it looked fine. In reality, events started stepping on each other and throwing errors. After some digging, I simplified it. One trigger, file-based filtering, and the problem disappeared.

Then I hit a strange issue with Transcribe job names. Some jobs were failing for no obvious reason. Turns out Transcribe doesn’t like special characters or slashes in names. My file naming was the real culprit. I cleaned the names with a small regex and added timestamps so every job stayed unique. After that, no more failures.

Bedrock caused some confusion, too. Even with the right IAM permissions, I kept getting Access Denied errors. Everything looked correct, which made it frustrating. Later, I realised that model access needs to be enabled separately in the Bedrock console. Once I enabled it and moved to the Titan Text G1 Express model, things finally worked.

Another lesson came from how Titan returns data. The response is deeply nested JSON. I first dumped everything straight into DynamoDB. Bad idea. Reading and querying that data became painful very quickly. I changed the logic to pull only what I actually needed and stored clean fields like sentiment and summary. That made life much easier.

I also tripped over some basic Lambda mistakes. Using inline code in CloudFormation, I messed up handler names and indentation more than once. Small errors, big waste of time. Now I always double-check the handler and formatting before deploying.

QuickSight had its own personality. It doesn’t like nested DynamoDB structures much. Charts became harder than they needed to be. Flattening the data before storing it solved most of that.

At the end of the day, the build worked, and the system does exactly what I wanted. More importantly, I walked away with a better understanding of where these services can surprise you and where you need to be careful. That hands-on learning was the real win from this project.

Top comments (1)

Collapse
 
vigneshviky7 profile image
vigneshviky7

Insightful post, Looking more integrations along with this 😺