ProductionAgents

Customer Service Agent Copilot

7-Endpoint Conversation API · GPT-4o-mini

7specialised copilot endpoints guiding live customer-service agents

FastAPI backend that acts as a real-time copilot for customer service agents. Analyzes live conversations to identify journey stages, map resolution paths, generate scorecards, and forecast projected resolutions — all via structured JSON responses from GPT-4o-mini.

Engagement

Status
Production
Primary KPI
Lower average handle time through real-time guidance
Starting point
Manual coaching and inconsistent live guidance
Scope
Contact-center support interactions
My role
AI engineer (service design + reliability controls)

01 Overview

From manual coaching and inconsistent live guidance to lower average handle time through real-time guidance.

Before

Manual coaching and inconsistent live guidance

After

Lower average handle time through real-time guidance

How it works

The Agent Copilot is a FastAPI service that integrates into a contact centre's real-time conversation stream. It exposes seven specialised endpoints, each targeting a distinct moment in the customer service journey — from opening stage identification through to outcome forecasting. Prompts are loaded from Markdown files at startup so domain teams can update guidance without touching application code. An async LLM client with exponential backoff handles GPT-4o-mini 429 and 5xx errors transparently, ensuring reliability under burst traffic.

What I built

  • 7 specialised endpoints: journey stage, resolution steps, scorecard, and more
  • Async LLM client with exponential backoff for 429/5xx error recovery
  • Markdown-rendered prompts loaded from the filesystem at startup
  • Projected resolution forecasting from partial conversation transcripts

02 The system

Architecture at a glance.

FIG. 05 — SYSTEM ARCHITECTUREbackoffTranscriptlive · partialFastAPI7 endpointsAsync clientGPT-4o-miniJourney stageResolution pathScorecardForecastAgentlive guidance
A live, partial transcript fans out to specialised endpoints through one resilient async client, returning structured guidance while the call is still in progress.
  1. 01

    Journey Stage Classifier

    Identifies which phase of the domain-specific journey taxonomy the conversation is currently in (opening → problem discovery → resolution → closure).

  2. 02

    Resolution Path Mapper

    Returns ordered resolution steps based on the detected journey stage and issue type.

  3. 03

    Scorecard Generator

    Evaluates agent communication quality across defined rubric dimensions at any point in the conversation.

  4. 04

    Outcome Forecaster

    Predicts projected resolution category from a partial conversation transcript.

  5. 05

    Async LLM Client

    aiohttp-based client with configurable retry policy and exponential backoff for 429/5xx error recovery.

  6. 06

    Markdown Prompt Loader

    Prompt templates loaded from the filesystem at startup — domain teams update prompts without code deploys.

Stack

Models
GPT-4o-mini
Backend
FastAPIaiohttpPython asyncio

03 Engineering decisions

The 2 decisions that made it production-grade.

Each decision opens with the business problem, contrasts the common approach with what I shipped, then walks through the real code — the highlighted lines follow the step you are reading.

  1. 01Reliable Real-Time Guidance
  2. 02Actionable Coaching Scorecards

Decision 01 · Production APIs · Reliability

Reliable Real-Time Guidance

In plain termsThis tracks where each customer conversation is in real time — so the AI copilot always surfaces the right guidance at the right moment, even when messages arrive out of order or connections are briefly interrupted.

The problem

OpenAI 429s and 5xx errors are common under load — a single raise_for_status() drops the call.

A real-time copilot that silently fails on transient errors looks broken to the agent mid-call. Exponential backoff with a configured retry ceiling makes API errors invisible to the caller while respecting the provider's rate signals.

Before · Common approach

Single aiohttp call with raise_for_status(). Fails immediately on 429 or 5xx.

✗ Transient errors surface as 500s to the frontend. No recovery, no signal in logs until users report failures.

After · This implementation

Recursive _call_with_retry with attempt counter. Exponential wait (2^attempt). Configurable max_retries ceiling.

✓ 429/5xx handled transparently. Failures only propagate after max_retries consecutive errors.

Code walkthrough — 3 steps · scroll or click

agent-copilot/journey_tracker.py
"""
Journey stage tracker — real-time conversation state analysis.

WHAT IT DEMONSTRATES:
  The core async LLM client used by the customer service agent copilot
  to identify where a caller is in their service journey (opening,
  problem discovery, resolution, closure) and what steps come next.

WHY IT'S INTERESTING:
  The exponential-backoff retry pattern is essential for production LLM
  APIs: OpenAI's 429 (rate limit) and 5xx (transient) errors are common
  under load. The conditional decorator pattern handles environments where
  the `backoff` library may not be installed — gracefully degrading to a
  no-op wrapper rather than crashing at import time.

NOVELTY:
  The design challenge for a real-time copilot is latency: every endpoint
  must complete within the time an agent pauses between sentences. The
  GPT-4o-mini model choice over GPT-4o is a deliberate latency–cost
  optimisation validated by evaluating structured-output accuracy across
  both models on a held-out transcript set. The journey stage taxonomy
  (opening → problem discovery → resolution → closure) was engineered
  collaboratively with contact centre operations staff, not derived from
  generic conversation frameworks — the domain-specific labels produce
  more actionable copilot suggestions than coarser "intent" classifications.
  Conditional backoff import avoids a hard dependency while preserving
  reliability in production environments where the library is always present.
"""

import asyncio
import json
import logging
from typing import Any, Optional

import aiohttp

logger = logging.getLogger(__name__)


class JourneyTracker:
    """
    Identifies journey stages and projects resolution paths for support calls.

    Endpoints driven by this tracker:
      POST /api/identify_journey_stage        — current stage label + confidence
      POST /api/identify_projected_resolution — likely resolution before it happens
      POST /api/recalibrate_resolution_path   — update projection mid-call
    """

    JOURNEY_STAGES = [
        "opening",
        "problem_identification",
        "investigation",
        "resolution_in_progress",
        "confirmation",
        "closure",
    ]

    def __init__(
        self,
        api_key: str,
        model: str = "gpt-4o-mini",
        temperature: float = 0.1,
        max_tokens: int = 1024,
        max_retries: int = 3,
        timeout: int = 30,
    ):
        self.api_key = api_key
        self.model = model
        self.temperature = temperature
        self.max_tokens = max_tokens
        self.max_retries = max_retries
        self.timeout = timeout
        self.base_url = "https://api.openai.com/v1"

    async def identify_stage(
        self, conversation: str, prompt_template: str
    ) -> dict[str, Any]:
        """
        Classify the current journey stage from a live transcript.

        Returns:
            {"stage": str, "confidence": float, "reasoning": str,
             "suggested_next_steps": list[str]}
        """
        prompt = prompt_template.replace("{{conversation}}", conversation)
        response = await self._call_with_retry(
            prompt=prompt,
            system="You are a customer service journey analysis expert. "
                   "Return valid JSON only.",
        )
        return json.loads(response)

    async def project_resolution(
        self, conversation: str, journey_stage: str, prompt_template: str
    ) -> dict[str, Any]:
        """
        Forecast the most likely resolution before it occurs.

        Useful for pre-emptive escalation decisions.
        """
        prompt = (
            prompt_template
            .replace("{{conversation}}", conversation)
            .replace("{{journey_stage}}", journey_stage)
        )
        response = await self._call_with_retry(prompt=prompt)
        return json.loads(response)

    async def _call_with_retry(
        self,
        prompt: str,
        system: Optional[str] = None,
        attempt: int = 0,
    ) -> str:
        """Async LLM call with exponential backoff on 429 / 5xx errors."""
        headers = {
            "Authorization": f"Bearer {self.api_key}",
            "Content-Type": "application/json",
        }
        messages = []
        if system:
            messages.append({"role": "system", "content": system})
        messages.append({"role": "user", "content": prompt})

        payload = {
            "model": self.model,
            "messages": messages,
            "temperature": self.temperature,
            "max_tokens": self.max_tokens,
        }

        async with aiohttp.ClientSession() as session:
            async with session.post(
                f"{self.base_url}/chat/completions",
                headers=headers,
                json=payload,
                timeout=aiohttp.ClientTimeout(total=self.timeout),
            ) as resp:
                if resp.status in (429, 500, 502, 503, 504):
                    if attempt >= self.max_retries:
                        resp.raise_for_status()
                    wait = 2 ** attempt          # 1s, 2s, 4s …
                    logger.warning("HTTP %d — retrying in %ds", resp.status, wait)
                    await asyncio.sleep(wait)
                    return await self._call_with_retry(prompt, system, attempt + 1)

                resp.raise_for_status()
                data = await resp.json()
                return data["choices"][0]["message"]["content"]
import aiohttp

async def call_llm(api_key, prompt):
    async with aiohttp.ClientSession() as session:
        resp = await session.post(
            "https://api.openai.com/v1/chat/completions",
            headers={"Authorization": f"Bearer {api_key}"},
            json={"model": "gpt-4o-mini",
                  "messages": [{"role": "user",
                               "content": prompt}]},
        )
        resp.raise_for_status()  # fails on 429/5xx, no retry
        return (await resp.json())["choices"][0]["message"]["content"]

The Impact

3max retriesConfigurable — 429/5xx handled before propagating to caller
2×backoff per attempt1s, 2s, 4s — respects provider rate-limit signals
30srequest timeoutaiohttp.ClientTimeout prevents hung calls

Recursive _call_with_retry with exponential backoff absorbs transient 429 and 5xx errors before they surface to the copilot frontend — making API hiccups invisible in real-time contact centre conversations.

Decision 02 · Contact Centre AI · Quality Assurance

Actionable Coaching Scorecards

In plain termsThis generates structured coaching reports for each customer interaction — translating raw conversation data into specific, measurable feedback that supervisors can act on without listening to full call recordings.

The problem

A single 1–10 score is useless for coaching — agents need to know which skill to improve.

Generic 'rate this conversation' prompts give a number but no breakdown. A supervisor can't tell from a 6.5 whether empathy failed, compliance lapsed, or efficiency was low. Decomposing quality into weighted dimensions produces actionable feedback per criterion.

Before · Common approach

Single-question LLM prompt returning one score. Fast, but provides no coaching signal.

✗ Score of 6.5 tells you nothing about which skill needs improvement. No confidence signal, no flagging of uncertain cases.

After · This implementation

DEFAULT_RUBRIC with 5 weighted dimensions. Per-dimension score, confidence, rationale, and coaching_notes in one JSON call.

✓ Compliance at 0.4 with accuracy at 0.9 instantly surfaces a specific coaching need without the supervisor listening to the call.

Code walkthrough — 3 steps · scroll or click

agent-copilot/scorecard_generator.py
"""
Agent quality scorecard — rubric-based conversation evaluation.

WHAT IT DEMONSTRATES:
  A structured, weighted rubric evaluator that scores a customer service
  agent's conversation against five domain-specific quality dimensions:
  empathy, accuracy, first-contact resolution, compliance adherence, and
  efficiency. Returns per-dimension scores alongside an overall weighted
  Quality Score for coaching feedback.

WHY IT'S INTERESTING:
  Generic "rate this conversation 1–10" prompts are useless for coaching —
  they give no signal on which skill to improve. Decomposing quality into
  independently weighted dimensions produces actionable feedback: a score
  of 0.9 overall with a 0.4 on compliance flags a specific coaching need
  without requiring a supervisor to listen to the call.

NOVELTY:
  The rubric dimensions and their weights are intentionally kept in a
  dataclass configuration rather than hardcoded in the prompt. This lets
  different teams (billing vs. technical support) apply different rubrics
  and weightings without touching the evaluation code. The evaluator calls
  the LLM once per conversation rather than once per dimension — structured
  JSON output with per-dimension fields keeps latency flat regardless of
  rubric size. The confidence field per dimension flags low-certainty scores
  (e.g., when the conversation is too short to assess first-contact resolution),
  preventing coaching decisions based on insufficient evidence.
"""

from __future__ import annotations

import json
import logging
from dataclasses import dataclass, field
from typing import Any, Optional

import aiohttp

logger = logging.getLogger(__name__)


@dataclass
class RubricDimension:
    name: str
    description: str
    weight: float = 1.0


@dataclass
class DimensionScore:
    name: str
    score: float              # 0.0 – 1.0
    confidence: float         # 0.0 – 1.0; low = insufficient evidence
    rationale: str
    coaching_notes: list[str] = field(default_factory=list)


@dataclass
class ScorecardResult:
    overall_score: float      # confidence-weighted average across dimensions
    dimensions: list[DimensionScore]
    summary: str
    flagged_for_review: bool  # True when any dimension confidence < 0.5


# Default rubric — overridable per team/use case
DEFAULT_RUBRIC: list[RubricDimension] = [
    RubricDimension("empathy",     "Agent acknowledges the customer's situation and emotional state.", weight=1.5),
    RubricDimension("accuracy",    "Information provided is correct and complete.",                   weight=2.0),
    RubricDimension("fcr",         "Issue resolved on first contact without need for escalation.",    weight=2.0),
    RubricDimension("compliance",  "Agent follows required scripts, disclosures, and procedures.",    weight=2.5),
    RubricDimension("efficiency",  "Resolution achieved without unnecessary holds or repetition.",    weight=1.0),
]

SCORECARD_PROMPT = """
You are an expert contact-centre quality assurance analyst.

Evaluate the following conversation against each rubric dimension below.
For each dimension, return:
  - score: float 0.0–1.0 (0 = completely fails, 1 = exemplary)
  - confidence: float 0.0–1.0 (0 = insufficient evidence, 1 = very clear)
  - rationale: one sentence explaining the score
  - coaching_notes: list of specific, actionable improvement suggestions (empty if score >= 0.85)

Rubric:
{rubric_json}

Conversation:
{conversation}

Return ONLY valid JSON matching this schema:
{{
  "dimensions": [
    {{"name": "...", "score": 0.0, "confidence": 0.0, "rationale": "...", "coaching_notes": []}}
  ],
  "summary": "one-sentence overall assessment"
}}
"""


class ScorecardGenerator:
    """
    Evaluates agent conversations against a configurable quality rubric.

    Endpoints driven by this generator:
      POST /api/generate_scorecard    — full scorecard for a completed call
      POST /api/partial_scorecard     — mid-call snapshot (lower confidence expected)
    """

    def __init__(
        self,
        api_key: str,
        model: str = "gpt-4o-mini",
        rubric: Optional[list[RubricDimension]] = None,
        max_retries: int = 3,
        timeout: int = 30,
    ):
        self.api_key = api_key
        self.model = model
        self.rubric = rubric or DEFAULT_RUBRIC
        self.max_retries = max_retries
        self.timeout = timeout

    async def generate(self, conversation: str) -> ScorecardResult:
        """Evaluate a conversation and return a weighted scorecard."""
        rubric_json = json.dumps(
            [{"name": d.name, "description": d.description} for d in self.rubric],
            indent=2,
        )
        prompt = SCORECARD_PROMPT.format(
            rubric_json=rubric_json,
            conversation=conversation,
        )
        raw = await self._call_with_retry(prompt)
        return self._parse(raw)

    # ── Parsing ────────────────────────────────────────────────────────────

    def _parse(self, raw: dict[str, Any]) -> ScorecardResult:
        dim_scores = [
            DimensionScore(
                name=d["name"],
                score=float(d["score"]),
                confidence=float(d["confidence"]),
                rationale=d["rationale"],
                coaching_notes=d.get("coaching_notes", []),
            )
            for d in raw.get("dimensions", [])
        ]

        # Weighted average — weight from rubric, scaled by LLM confidence
        weight_map = {d.name: d.weight for d in self.rubric}
        total_weight = 0.0
        weighted_sum = 0.0
        for ds in dim_scores:
            w = weight_map.get(ds.name, 1.0) * ds.confidence
            weighted_sum += ds.score * w
            total_weight += w

        overall = weighted_sum / total_weight if total_weight > 0 else 0.0
        flagged = any(ds.confidence < 0.5 for ds in dim_scores)

        return ScorecardResult(
            overall_score=round(overall, 3),
            dimensions=dim_scores,
            summary=raw.get("summary", ""),
            flagged_for_review=flagged,
        )

    # ── Async LLM call with exponential backoff ────────────────────────────

    async def _call_with_retry(self, prompt: str, attempt: int = 0) -> dict[str, Any]:
        headers = {
            "Authorization": f"Bearer {self.api_key}",
            "Content-Type": "application/json",
        }
        payload = {
            "model": self.model,
            "messages": [{"role": "user", "content": prompt}],
            "response_format": {"type": "json_object"},
            "temperature": 0.0,
        }
        async with aiohttp.ClientSession() as session:
            async with session.post(
                "https://api.openai.com/v1/chat/completions",
                headers=headers,
                json=payload,
                timeout=aiohttp.ClientTimeout(total=self.timeout),
            ) as resp:
                if resp.status in (429, 500, 502, 503, 504):
                    if attempt >= self.max_retries:
                        resp.raise_for_status()
                    wait = 2 ** attempt
                    logger.warning("HTTP %d — retrying in %ds", resp.status, wait)
                    import asyncio
                    await asyncio.sleep(wait)
                    return await self._call_with_retry(prompt, attempt + 1)
                resp.raise_for_status()
                data = await resp.json()
                return json.loads(data["choices"][0]["message"]["content"])
async def generate(self, conversation):
    response = await openai.chat.completions.create(
        model="gpt-4o-mini",
        messages=[{
            "role": "user",
            "content": f"Rate this call 1-10:\n{conversation}"
        }],
    )
    score = float(response.choices[0].message.content)
    return {"score": score}
    # no rubric, no per-dimension breakdown
    # no coaching_notes, no confidence flags

The Impact

5scored dimensionsEmpathy, accuracy, FCR, compliance, efficiency — each independently weighted
0hard-coded weightsTeams update rubric weights in config without touching code
1LLM callAll dimensions evaluated in one structured JSON call, latency stays flat

Decomposing quality into independently weighted rubric dimensions produces per-dimension coaching notes that supervisors can act on immediately — without listening to the call or waiting for a weekly review cycle.

04 Production hardening

What breaks, why, and what keeps it fixed.

The failure modes this system had to survive — each with its root cause, the fix that shipped, and the guardrail that stops it coming back.

  1. 01
    Failure modeFailure mode · Reliable Real-Time Guidance

    Transient errors surface as 500s to the frontend. No recovery, no signal in logs until users report failures.

    Root cause

    OpenAI 429s and 5xx errors are common under load — a single raise_for_status() drops the call.

    Fix shipped

    Recursive _call_with_retry with attempt counter. Exponential wait (2^attempt). Configurable max_retries ceiling.

    Guardrail

    429/5xx handled transparently. Failures only propagate after max_retries consecutive errors.

  2. 02
    Failure modeFailure mode · Actionable Coaching Scorecards

    Score of 6.5 tells you nothing about which skill needs improvement. No confidence signal, no flagging of uncertain cases.

    Root cause

    A single 1–10 score is useless for coaching — agents need to know which skill to improve.

    Fix shipped

    DEFAULT_RUBRIC with 5 weighted dimensions. Per-dimension score, confidence, rationale, and coaching_notes in one JSON call.

    Guardrail

    Compliance at 0.4 with accuracy at 0.9 instantly surfaces a specific coaching need without the supervisor listening to the call.

05 Outcomes

What changed for the business.

  1. 01

    Lower average handle time through real-time guidance

  2. 02

    Improved coaching consistency across agents

  3. 03

    Resilient API behavior under burst traffic.

Key benefits

  • Real-time agent guidance reduces reliance on supervisor escalations for complex cases
  • AHT reduction from surfacing resolution paths mid-conversation rather than post-call
  • Quality scorecard enables consistent coaching feedback across all agent interactions
  • Compliance-ready audit output captures journey stages and scores per conversation
  • Prompt-file architecture lets domain experts iterate on guidance without engineering involvement

Metrics are from project-specific observation windows. Codebases are private; excerpts are curated for illustration.

Want the full story behind Customer Service Agent Copilot?

I’m happy to walk through the architecture, trade-offs and numbers in an interview.