Transcripts

Access and use real-time and post-call transcripts for analytics, training, and compliance.

Transcripts

Transcripts provide a text record of conversations, enabling analytics, compliance, quality assurance, and AI training.

Transcript Types

Type When Available Use Case
Real-time During call Live monitoring, agent assist
Post-call After call ends QA, analytics, compliance
Summary After call ends Quick review, CRM notes

Configuration

Enable Transcripts

{
  "agent": {
    "transcripts": {
      "enabled": true,
      "includeTimestamps": true,
      "includeConfidence": true,
      "generateSummary": true,
      "storage": "s3",
      "retentionDays": 90
    }
  }
}

Real-time Transcripts

WebSocket Events

{
  "event": "transcript.updated",
  "data": {
    "call_id": "call_abc123",
    "turn_id": "turn_5",
    "role": "user",
    "text": "What's the status of my order?",
    "is_final": true,
    "confidence": 0.95,
    "timestamp_ms": 12500,
    "words": [
      {"word": "What's", "start": 12500, "end": 12700, "confidence": 0.98},
      {"word": "the", "start": 12700, "end": 12850, "confidence": 0.99},
      {"word": "status", "start": 12850, "end": 13200, "confidence": 0.96}
    ]
  }
}

Implementation

type TranscriptHandler struct {
    transcripts map[string]*Transcript
    storage     Storage
}

type Transcript struct {
    CallID    string
    Turns     []TranscriptTurn
    StartTime time.Time
}

type TranscriptTurn struct {
    ID         string
    Role       string  // "user" or "assistant"
    Text       string
    Confidence float32
    Timestamp  time.Duration
    Words      []WordTiming
}

func (h *TranscriptHandler) OnTranscript(event TranscriptEvent) {
    transcript := h.getOrCreate(event.CallID)

    turn := TranscriptTurn{
        ID:         event.TurnID,
        Role:       event.Role,
        Text:       event.Text,
        Confidence: event.Confidence,
        Timestamp:  time.Duration(event.TimestampMs) * time.Millisecond,
        Words:      event.Words,
    }

    transcript.AddTurn(turn)

    // Emit webhook
    h.webhooks.Send("transcript.updated", event)
}

Post-Call Transcripts

Transcript Structure

{
  "id": "trans_xyz789",
  "call_id": "call_abc123",
  "agent_id": "agent_456",
  "duration_ms": 180000,
  "language": "en-US",
  "turns": [
    {
      "id": "turn_1",
      "role": "assistant",
      "text": "Hello! Thank you for calling Acme Support. How can I help you today?",
      "timestamp_ms": 0,
      "duration_ms": 3500
    },
    {
      "id": "turn_2",
      "role": "user",
      "text": "Hi, I want to check the status of my order.",
      "timestamp_ms": 4000,
      "duration_ms": 2500,
      "confidence": 0.94
    },
    {
      "id": "turn_3",
      "role": "assistant",
      "text": "I'd be happy to help with that. What's your order number?",
      "timestamp_ms": 6800,
      "duration_ms": 2800
    }
  ],
  "metadata": {
    "topics": ["order_status"],
    "sentiment": "neutral",
    "intent": "check_order",
    "entities": [
      {"type": "order_id", "value": "ORD-12345", "turn_id": "turn_4"}
    ]
  },
  "summary": "Customer called to check order status. Agent looked up order ORD-12345 which has shipped. Customer was satisfied with the update.",
  "created_at": "2024-12-28T16:35:00Z"
}

Generating Summaries

func (h *TranscriptHandler) generateSummary(transcript *Transcript) string {
    // Build conversation text
    var conversation strings.Builder
    for _, turn := range transcript.Turns {
        conversation.WriteString(fmt.Sprintf("%s: %s\n", turn.Role, turn.Text))
    }

    // Use LLM to generate summary
    prompt := fmt.Sprintf(`Summarize this customer service call in 2-3 sentences:

%s

Summary:`, conversation.String())

    summary, _ := h.llm.Generate(context.Background(), []Message{
        {Role: "user", Content: prompt},
    })

    return summary
}

Accessing Transcripts

Via API

# Get transcript for a call
curl https://api.edesy.in/v1/transcripts/trans_xyz789 \
  -H "Authorization: Bearer $API_KEY"

# List transcripts
curl "https://api.edesy.in/v1/transcripts?call_id=call_abc123" \
  -H "Authorization: Bearer $API_KEY"

# Get transcript as text
curl https://api.edesy.in/v1/transcripts/trans_xyz789/text \
  -H "Authorization: Bearer $API_KEY"

Text Format

[00:00] Assistant: Hello! Thank you for calling Acme Support. How can I help you today?
[00:04] User: Hi, I want to check the status of my order.
[00:07] Assistant: I'd be happy to help with that. What's your order number?
[00:10] User: It's ORD-12345.
[00:13] Assistant: Let me look that up for you.
[00:18] Assistant: Your order has been shipped and is expected to arrive by December 30th.
[00:23] User: Great, thank you!
[00:25] Assistant: You're welcome! Is there anything else I can help with?
[00:28] User: No, that's all. Bye!
[00:30] Assistant: Thank you for calling. Have a great day!

Via Webhook

{
  "event": "call.ended",
  "data": {
    "call_id": "call_abc123",
    "transcript": {
      "id": "trans_xyz789",
      "url": "https://api.edesy.in/v1/transcripts/trans_xyz789",
      "summary": "Customer checked order status. Order has shipped.",
      "turn_count": 10
    }
  }
}

Analytics

Sentiment Analysis

type SentimentAnalyzer struct {
    llm LLMProvider
}

func (a *SentimentAnalyzer) Analyze(transcript *Transcript) SentimentResult {
    // Per-turn sentiment
    for _, turn := range transcript.Turns {
        if turn.Role == "user" {
            turn.Sentiment = a.analyzeTurn(turn.Text)
        }
    }

    // Overall sentiment
    return SentimentResult{
        Overall:   a.calculateOverall(transcript.Turns),
        Trend:     a.calculateTrend(transcript.Turns),
        KeyMoments: a.findKeyMoments(transcript.Turns),
    }
}

func (a *SentimentAnalyzer) analyzeTurn(text string) string {
    prompt := fmt.Sprintf(`Classify the sentiment of this customer statement as positive, negative, or neutral:

"%s"

Sentiment:`, text)

    result, _ := a.llm.Generate(context.Background(), []Message{
        {Role: "user", Content: prompt},
    })

    return strings.TrimSpace(strings.ToLower(result))
}

Topic Extraction

func extractTopics(transcript *Transcript) []string {
    // Combine all text
    var text strings.Builder
    for _, turn := range transcript.Turns {
        text.WriteString(turn.Text + " ")
    }

    prompt := fmt.Sprintf(`Extract the main topics discussed in this conversation as a JSON array:

%s

Topics:`, text.String())

    result, _ := llm.Generate(context.Background(), []Message{
        {Role: "user", Content: prompt},
    })

    var topics []string
    json.Unmarshal([]byte(result), &topics)
    return topics
}

Entity Extraction

type Entity struct {
    Type   string // order_id, phone, email, name, etc.
    Value  string
    TurnID string
}

func extractEntities(transcript *Transcript) []Entity {
    var entities []Entity

    for _, turn := range transcript.Turns {
        // Order IDs
        orderPattern := regexp.MustCompile(`(?i)(order|ord)[#\-\s]?(\d+)`)
        matches := orderPattern.FindAllStringSubmatch(turn.Text, -1)
        for _, match := range matches {
            entities = append(entities, Entity{
                Type:   "order_id",
                Value:  match[2],
                TurnID: turn.ID,
            })
        }

        // Phone numbers
        phonePattern := regexp.MustCompile(`\b\d{3}[-.]?\d{3}[-.]?\d{4}\b`)
        phones := phonePattern.FindAllString(turn.Text, -1)
        for _, phone := range phones {
            entities = append(entities, Entity{
                Type:   "phone",
                Value:  phone,
                TurnID: turn.ID,
            })
        }

        // Emails
        emailPattern := regexp.MustCompile(`\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b`)
        emails := emailPattern.FindAllString(turn.Text, -1)
        for _, email := range emails {
            entities = append(entities, Entity{
                Type:   "email",
                Value:  email,
                TurnID: turn.ID,
            })
        }
    }

    return entities
}

Search and Query

type TranscriptSearch struct {
    elastic *elasticsearch.Client
}

func (s *TranscriptSearch) Index(transcript *Transcript) error {
    doc := map[string]any{
        "call_id":    transcript.CallID,
        "agent_id":   transcript.AgentID,
        "full_text":  transcript.FullText(),
        "summary":    transcript.Summary,
        "topics":     transcript.Topics,
        "sentiment":  transcript.Sentiment,
        "created_at": transcript.CreatedAt,
    }

    _, err := s.elastic.Index(
        "transcripts",
        esutil.NewJSONReader(doc),
        s.elastic.Index.WithDocumentID(transcript.ID),
    )

    return err
}

func (s *TranscriptSearch) Search(query string) ([]Transcript, error) {
    res, _ := s.elastic.Search(
        s.elastic.Search.WithIndex("transcripts"),
        s.elastic.Search.WithBody(strings.NewReader(fmt.Sprintf(`{
            "query": {
                "multi_match": {
                    "query": "%s",
                    "fields": ["full_text", "summary", "topics"]
                }
            }
        }`, query))),
    )

    // Parse and return results
    return parseSearchResults(res)
}

Privacy and Compliance

PII Redaction

func redactPII(transcript *Transcript) *Transcript {
    redacted := *transcript

    for i, turn := range redacted.Turns {
        // Redact credit card numbers
        turn.Text = redactPattern(turn.Text, `\b\d{4}[\s-]?\d{4}[\s-]?\d{4}[\s-]?\d{4}\b`, "[CARD]")

        // Redact SSN
        turn.Text = redactPattern(turn.Text, `\b\d{3}-\d{2}-\d{4}\b`, "[SSN]")

        // Redact phone numbers
        turn.Text = redactPattern(turn.Text, `\b\d{3}[-.]?\d{3}[-.]?\d{4}\b`, "[PHONE]")

        redacted.Turns[i] = turn
    }

    return &redacted
}

func redactPattern(text, pattern, replacement string) string {
    re := regexp.MustCompile(pattern)
    return re.ReplaceAllString(text, replacement)
}

Data Retention

func (h *TranscriptHandler) ApplyRetention() {
    cutoff := time.Now().AddDate(0, 0, -h.retentionDays)

    expired, _ := h.db.Find(&Transcript{
        CreatedAt: lt(cutoff),
    })

    for _, transcript := range expired {
        h.storage.Delete(transcript.StorageURL)
        h.db.Delete(&transcript)
    }
}

Best Practices

1. Include Word-Level Timestamps

Enables precise audio-text alignment:

{
  "words": [
    {"word": "order", "start": 12850, "end": 13200, "confidence": 0.96}
  ]
}

2. Store Raw and Processed

type TranscriptStorage struct {
    Raw       string // Exactly as transcribed
    Processed string // Cleaned, formatted
    Redacted  string // PII removed
}
{
  "transcript_id": "trans_xyz",
  "recording_id": "rec_abc",
  "alignment": {
    "turn_1": {"start_ms": 0, "end_ms": 3500}
  }
}

Next Steps