# Transcripts in one block

> When a vendor's API gives a whole call or chat as one block of text, split it into turns, or send it as one message.

Some vendors return a conversation as one block of text rather than one record per message: a call transcript as a single string, a VTT caption file, a chat log in one field. The [universal API](/api/tracker) wants one message per turn, each with its direction, so you have two ways to send it.

| | Split it into turns | Send it as one message |
|---|---|---|
| When | The block says who spoke: role labels (`Agent:`, `Customer:`) or speaker names you can sort into your team and the customer | The block has no speaker information at all |
| What you get | Every turn with its direction, so the agent can compare what customers said with what your team said | Dimensions are still extracted from the whole conversation, but it counts as one message from one side |

Split whenever you can. Send one message when there is nothing to split on.

## Option 1: split it into turns

Check one transcript first and see how a turn starts:

- **Role labels**, such as `Agent: ...` and `Customer: ...`, or `BOT:` and `USER:`. The label is the direction.
- **Speaker names**, such as `Sam Rivera: ...`, sometimes after a time offset (`[00:00:09] Sam Rivera: ...`). A name doesn't say which side the speaker is on: compare it with your team's names, or with the agent's name the vendor returns with the call. Don't assume the first speaker is the customer; your agent often opens the call.
- **WebVTT** (a `.vtt` file), with cues like `00:00:09.000 --> 00:00:12.000` and the speaker as `<v Sam Rivera>` or a name prefix on the cue's text.

This function handles all three. Copy it into your guide's script, next to the other helpers.

```js
// transcript.mjs. Split one block of transcript text into turns.
// isCompany(speaker) returns true for your agent, bot or staff, false for the customer.
// isSpeaker(label), optional: false for a label that isn't a speaker ("Note: ..."), so the line continues the turn.
// Returns [{ speaker, incoming, text, offsetMs }] in order. offsetMs is null when the block has no times.
export function splitTranscript(block, isCompany, isSpeaker = () => true) {
  const turns = [];
  let cueStart = null;
  const toMs = (h, m, s, frac = '0') => ((Number(h || 0) * 60 + Number(m)) * 60 + Number(s)) * 1000 + Number(frac.padEnd(3, '0').slice(0, 3));
  for (const raw of String(block).split(/\r?\n/)) {
    const line = raw.trim();
    if (!line || line === 'WEBVTT' || /^\d+$/.test(line) || /^NOTE\b/.test(line)) continue;
    const cue = line.match(/^(?:(\d+):)?(\d{1,2}):(\d{2})[.,](\d{1,3})\s*-->/);   // VTT or SRT cue timing
    if (cue) { cueStart = toMs(cue[1], cue[2], cue[3], cue[4]); continue; }
    let rest = line, offsetMs = cueStart;
    const stamp = rest.match(/^\[?(?:(\d+):)?(\d{1,2}):(\d{2})\]?\s+/);           // [00:00:09] or 00:09
    if (stamp) { offsetMs = toMs(stamp[1], stamp[2], stamp[3]); rest = rest.slice(stamp[0].length); }
    const voice = rest.match(/^<v(?:\.[^ >]*)?\s+([^>]+)>\s*/);                    // VTT voice tag
    let label = !voice && rest.match(/^([^:]{1,60}):\s+/);                          // "Agent: " or "Sam Rivera: "
    if (label && !isSpeaker(label[1].trim())) label = null;
    const speaker = voice ? voice[1].trim() : label ? label[1].trim() : null;
    const text = rest.slice(voice ? voice[0].length : label ? label[0].length : 0).replace(/<\/?[^>]+>/g, '').trim();
    if (!text) continue;
    const last = turns[turns.length - 1];
    if (speaker === null || (last && last.speaker === speaker)) {
      if (last) last.text += ' ' + text;   // a line with no speaker, or the same speaker again, continues the turn
      continue;                            // text before the first speaker is skipped
    }
    turns.push({ speaker, incoming: !isCompany(speaker), text, offsetMs });
    cueStart = null;
  }
  return turns;
}
```

Decide `isCompany` from what your vendor gives you:

```js
// Role labels:
const ROLES = { agent: true, bot: true, rep: true, customer: false, user: false, caller: false };
const isCompany = (s) => ROLES[s.toLowerCase()] ?? false;
const isSpeaker = (s) => s.toLowerCase() in ROLES;   // pass as the third argument: other labels continue the turn

// Speaker names: your team's names, or the agent's name the vendor returns with the call.
const TEAM = new Set((process.env.COMPANY_SPEAKERS ?? '').split(',').map((s) => s.trim().toLowerCase()));
const isCompanyByName = (s) => TEAM.has(s.toLowerCase());
```

Then send each turn as its own message, in order, at least one second apart:

```js
const callStart = toMillis(call.startTime);
let last = 0;
for (const turn of splitTranscript(call.transcript, isCompany)) {
  const ts = Math.max(callStart + (turn.offsetMs ?? 0), last + 1000);
  last = ts;
  await sendToDimensionLabs({
    text: turn.text,
    userId: customerId ?? `vendor-${call.id}`,   // the customer's id on every turn, never a name
    incoming: turn.incoming,
    sessionId: `vendor-${call.id}`,
    conversationId: `vendor-${call.id}`,
    dimensionlabs_timestamp: ts,
    platformJson: { speaker_role: turn.incoming ? 'customer' : 'company' },
  }, { historical: historical || ts < Date.now() - HOUR });
}
```

**Personal data.** Speaker names are personal data: use them only to decide the direction, and don't send them. A speaker the function can't place is treated as the customer, so check a few calls, and add missing names or labels. Redact the text itself as well. See [PII redaction](/data/pii-redaction).

For a file export with the transcript in a column, the same idea is in two recipes: [Split a transcript into turns](/transform/recipe-split-transcript) (role labels) and [Turn call transcripts into turns](/transform/recipe-call-transcripts) (speaker names).

## Option 2: send it as one message

When the block carries no speaker information, send the whole conversation as a single message:

```js
await sendToDimensionLabs({
  text: call.transcript.replace(/\s+/g, ' ').trim(),
  userId: customerId ?? `vendor-${call.id}`,
  incoming: true,                          // it holds the customer's words
  sessionId: `vendor-${call.id}`,
  conversationId: `vendor-${call.id}`,
  dimensionlabs_timestamp: toMillis(call.startTime),
  platformJson: { transcript: 'unsplit', channel: 'voice' },
}, { historical: historical || toMillis(call.startTime) < Date.now() - HOUR });
```

- **Dimensions still work.** Enrichment reads a whole session at once, so topics, reasons, sentiment and the rest are extracted from the full transcript.
- **What you lose.** With no turns, nothing separates what the customer said from what your agent said, and message counts don't mean much. Mark these records (`transcript: 'unsplit'` above) so you can filter them, and say in your [custom prompts](/dimensions/custom) that the text is a full two-sided transcript.
- **Switch later.** If the vendor adds speaker information, or you find a way to tell the sides apart, send the turns under the same `sessionId`.

Need help with a format that doesn't fit either option? Email support@dimensionlabs.io with one sample transcript, with personal details removed.
