跳到正文
DEV Community· Nabeel Hassan·· 3 小時前AI 評分61

Retell 通話後分析:如何設計欄位以生成客戶 KPI

Retell Post-Call Analysis: Designing Fields That Turn Calls Into Client KPIs

AI 導讀

要把 Retell 每通電話的分析結果轉成可按客戶彙總的 KPI,應在 call_analyzed 事件到達後,將自訂欄位按明確 schema 設計並存入可彙總的資料表。

正文

Disclosure: I'm Nabeel, founder of VoiceDash, a client portal for Retell AI agencies. This post is about the Retell API itself and works with any stack.

TL;DR: Retell's post-call analysis turns each transcript into structured data, but only if you design the fields like a schema, not a wish list. Listen for call_analyzed (not call_ended), give every custom field a narrow type and a precise description, and store the result in a table you can aggregate per client. Then the monthly numbers a client sees are just queries.

What Retell actually gives you after a call

When a call finishes, Retell sends webhook events in order. Two matter here:

  • call_ended: every field of the call object except call_analysis.
  • call_analyzed: the full call data including call_analysis.

If your pipeline writes KPIs on call_ended, it will see an empty analysis and count nothing. Write the raw call on call_ended if you want it early, then upsert the analysis on call_analyzed.

The call_analysis object (per the Get Call API reference) contains:

Field Type Notes
call_summary string A short summary of the call
in_voicemail boolean Whether the call reached voicemail
user_sentiment enum Positive, Neutral, Negative, Unknown
call_successful boolean Whether the agent appears to have finished its task
custom_analysis_data object Your own fields, extracted from the transcript. Can be empty.

Alongside it, the call object carries the fields you need for reporting: agent_id, start_timestamp and end_timestamp (ms since epoch), duration_ms, disconnection_reason, recording_url, metadata, retell_llm_dynamic_variables and call_cost.

Treat the built-in fields with care

The built-ins are useful, but they answer generic questions.

  • call_successful means "the agent seems to have completed its task". It does not know that your client counts success as "appointment booked". Do not show it to a client as a success rate.
  • user_sentiment is a model's read of tone. Fine as a filter for which calls a human should review, weak as a headline number.
  • in_voicemail is the one to use for outbound campaigns: exclude voicemail calls from any conversion rate, or the rate will look much worse than the conversations really were.
  • call_summary belongs in a call list, not in a KPI.

Client-facing KPIs should come from custom fields you define.

Designing custom fields as a schema

In the agent's post-call analysis settings, each custom field has a name, a description and a format example, and one of four types: Text, Selector, Boolean, Number. The description is effectively a prompt for extraction, so write it as a definition with an explicit rule for edge cases.

A set that covers most receptionist agents:

appointment_booked (Boolean)
  True only if the caller confirmed a specific date and time and the
  booking tool returned success during this call. False if the caller
  only asked about availability.

call_intent (Selector: new_booking, reschedule, cancel, question, sales_lead, spam, other)
  The single main reason for the call. If several apply, pick the one
  the caller raised first.

lead_quality (Selector: hot, warm, cold, not_a_lead)
  hot = asked for a booking or a quote for a service the business offers.

escalated_to_human (Boolean)
  True if the call was transferred or a callback by staff was promised.

callback_number (Text)
  The number the caller asked to be called back on, in E.164 format.
  Empty if none was given.

estimated_job_value (Number)
  Only if the caller stated a budget or a price was quoted from the
  approved price list. Otherwise leave empty. Never guess.

Rules that keep extraction reliable:

  1. Prefer Selector and Boolean over Text. You can group by an enum; you cannot group by free text.
  2. Define the negative case. "False if the caller only asked about availability" prevents the most common false positive.
  3. One question per field. A field like outcome_and_next_step produces prose you cannot aggregate.
  4. Allow empty. For numbers especially, an explicit "leave empty" beats a hallucinated value in a revenue chart.
  5. Version the schema. When you change a description, record the date. KPI trends across a definition change are not comparable.

Receiving call_analyzed safely

Retell signs each webhook with the x-retell-signature header. Verify it against the raw request body with the SDK; a re-serialized JSON object will not match. Retell waits 10 seconds for a 2xx and retries up to 3 times, so acknowledge fast and make the write idempotent.

import express from "express";
import Retell from "retell-sdk";

const app = express();

app.post(
  "/webhooks/retell",
  express.raw({ type: "application/json" }),
  async (req, res) => {
    const raw = req.body.toString("utf8");
    const signature = req.header("x-retell-signature") ?? "";

    if (!Retell.verify(raw, process.env.RETELL_API_KEY!, signature)) {
      return res.status(401).end();
    }

    const { event, call } = JSON.parse(raw);
    res.status(204).end(); // acknowledge inside the 10s window

    if (event === "call_analyzed") {
      await upsertAnalysis(call); // idempotent on call.call_id
    }
  }
);

If you run several clients on one Retell account, decide how a call maps to a client before it happens. Two common options: a lookup table from agent_id to client, or a client_id you put in metadata when you create outbound calls. Never trust a client identifier taken from the transcript.

A table you can aggregate

Flatten the analysis into columns. JSON blobs are fine for the raw payload, but reporting queries should hit typed columns.

create table call_analysis (
  call_id            text primary key,
  client_id          text not null,
  agent_id           text not null,
  started_at         timestamptz not null,
  duration_ms        integer,
  disconnection_reason text,
  in_voicemail       boolean,
  user_sentiment     text,
  call_successful    boolean,
  appointment_booked boolean,
  call_intent        text,
  lead_quality       text,
  escalated_to_human boolean,
  estimated_job_value numeric,
  schema_version     integer not null,
  raw                jsonb not null
);

create index on call_analysis (client_id, started_at);
async function upsertAnalysis(call: any) {
  const a = call.call_analysis ?? {};
  const c = a.custom_analysis_data ?? {};
  await db.query(
    `insert into call_analysis (call_id, client_id, agent_id, started_at,
       duration_ms, disconnection_reason, in_voicemail, user_sentiment,
       call_successful, appointment_booked, call_intent, lead_quality,
       escalated_to_human, estimated_job_value, schema_version, raw)
     values ($1,$2,$3,to_timestamp($4/1000.0),$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16)
     on conflict (call_id) do update set
       appointment_booked = excluded.appointment_booked,
       call_intent = excluded.call_intent,
       lead_quality = excluded.lead_quality,
       raw = excluded.raw`,
    [call.call_id, clientFor(call), call.agent_id, call.start_timestamp,
     call.duration_ms, call.disconnection_reason, a.in_voicemail ?? null,
     a.user_sentiment ?? null, a.call_successful ?? null,
     c.appointment_booked ?? null, c.call_intent ?? null,
     c.lead_quality ?? null, c.escalated_to_human ?? null,
     c.estimated_job_value ?? null, SCHEMA_VERSION, call]
  );
}

The on conflict clause matters: a retried webhook must not create a second row.

From rows to client KPIs

Once the columns exist, a month of reporting is a handful of queries:

select
  count(*) filter (where call_intent <> 'spam')                    as real_calls,
  count(*) filter (where appointment_booked)                       as bookings,
  round(100.0 * count(*) filter (where appointment_booked)
        / nullif(count(*) filter (where call_intent in ('new_booking','reschedule')), 0), 1)
                                                                   as booking_rate_pct,
  count(*) filter (where escalated_to_human)                       as handed_to_staff,
  count(*) filter (where extract(hour from started_at at time zone 'America/New_York')
                   not between 9 and 16)                           as after_hours_calls
from call_analysis
where client_id = $1
  and started_at >= date_trunc('month', now() - interval '1 month')
  and started_at <  date_trunc('month', now());

Note the denominators: spam is excluded from call counts, and the booking rate divides by calls that wanted a booking, not by every call. Use the client's own time zone for anything hour based.

Test the extraction before a client relies on it

Analysis fields are model output, so test them like model output:

  1. Make a set of 15 to 20 test calls that cover each call_intent value plus awkward cases: a caller who asks about times and hangs up, a reschedule that becomes a cancel, a spam call.
  2. Write down the expected value of every field for each call before you look at the results.
  3. Compare. Any field that is wrong more than once gets a sharper description, then re-run the set.
  4. Spot check a few real calls each week after launch, sorted by user_sentiment = 'Negative' first.

Checklist

  • [ ] KPIs written on call_analyzed, not call_ended
  • [ ] Signature verified on the raw body
  • [ ] Idempotent upsert keyed on call_id
  • [ ] Every custom field has a type, a definition and a negative case
  • [ ] Client mapping from agent_id or metadata, never from the transcript
  • [ ] Spam and voicemail excluded from rates
  • [ ] Schema version stored with every row

References: Retell docs on post-call analysis, webhooks and the Get Call API.

Drafted with AI assistance and fact-checked against each vendor's docs.

來源:DEV Community · dev.to