# Split a transcript into turns

> Recipe: turn one row per conversation, with the whole transcript in a cell, into one row per message.

## When you need it


Your export has one row per chat, ticket or call, and the conversation itself is in one cell, one message per line, each line starting with a role label: `BOT:` and `USER:`, `Agent:` and `Customer:`. Chatbot platforms, call summarization tools and spreadsheet exports from contact centers often look like this.

Uploaded as is, the whole transcript would be one message from one side. Split into turns, each message gets its direction and its place in the conversation.

## Input

Example input, `chat-transcripts.csv`. Note the customer's message in `ch-301` that runs over two lines.

```csv title="chat-transcripts.csv"
chat_id,started_at,csat,transcript
ch-301,2026-01-05T14:02:00Z,2,"BOT: Hi! I'm the Lumen Coffee helper. What can I do for you?
USER: My subscription box came without the decaf.
I ordered the Night Owl.
BOT: Sorry about that. Do you want a refund or a replacement?
USER: Replacement please"
ch-302,2026-01-05T15:40:00Z,5,"USER: what time does the Pearl store open
BOT: The Pearl store opens at 6:30 AM every day."
```

## Output

```csv title="turns.csv"
sessionId,userId,incoming,text,dimensionlabs_timestamp,conversationId,csat
ch-301,ch-301,false,Hi! I'm the Lumen Coffee helper. What can I do for you?,1767621720000,ch-301,2
ch-301,ch-301,true,My subscription box came without the decaf. I ordered the Night Owl.,1767621721000,ch-301,2
ch-301,ch-301,false,Sorry about that. Do you want a refund or a replacement?,1767621722000,ch-301,2
ch-301,ch-301,true,Replacement please,1767621723000,ch-301,2
ch-302,ch-302,true,what time does the Pearl store open,1767627600000,ch-302,5
ch-302,ch-302,false,The Pearl store opens at 6:30 AM every day.,1767627601000,ch-302,5
```

## Rules

- **The label decides the direction.** `USER` and `Customer` are `incoming: true`; `BOT` and `Agent` are `false`. List every label your export uses; a line that starts with an unknown label is read as a continuation.
- **Lines without a label belong to the turn above.** A customer who pressed Enter mid-message wrote one message, not two. Join the lines with a space. Text before the first label is skipped and counted.
- **Split first, then flatten.** The transcript needs its line breaks to find the turns; each finished message must not have any, because [line breaks inside a field split records](/data/conversation-format#rules-that-break-uploads).
- **Timestamps one second apart, in order.** The export has one time per conversation, so each turn gets the start time plus one second per turn. Turns must be [at least 1,000 ms apart](/data/conversation-format#small-rules-that-keep-results-clean) to stay in order.
- **One `userId` for a bot chat.** For a bot conversation, the end user's id goes on every row, the bot's included ([the userId rule](/data/sessions-and-conversations#the-userid-rule)). This export has no user id, so the session id is used. If you have a user id column, use it instead.
- **Conversation fields on every turn.** `csat` here is a conversation-level score. Repeat it on each row; it is stored with the conversation as metadata.

## Script

Node 18 or later, with `npm install papaparse`.

```js title="split-transcript.mjs"
// Split a transcript cell into one row per turn.
// Usage: node split-transcript.mjs chat-transcripts.csv turns.csv



const [input, output] = process.argv.slice(2);

// Which label means who. Labels are matched at the start of a line, case-insensitively.
const SPEAKERS = { user: true, customer: true, bot: false, agent: false };
const LABEL = /^\s*(user|customer|bot|agent)\s*:\s*/i;

const { data } = Papa.parse(fs.readFileSync(input, 'utf8'), { header: true, skipEmptyLines: 'greedy' });
const rows = [];
let skipped = 0;

for (const chat of data) {
  const start = Date.parse(chat.started_at);
  const turns = [];
  for (const line of chat.transcript.split(/\r?\n/)) {
    const m = line.match(LABEL);
    if (m) turns.push({ incoming: SPEAKERS[m[1].toLowerCase()], text: line.slice(m[0].length) });
    else if (turns.length && line.trim()) turns[turns.length - 1].text += ' ' + line.trim(); // a message that spans lines
    else if (line.trim()) skipped++; // text before the first label
  }
  turns.forEach((turn, i) => {
    const text = turn.text.replace(/\s+/g, ' ').trim();
    if (!text) return;
    rows.push({
      sessionId: chat.chat_id,
      userId: chat.chat_id, // bot chat: the end user's id on every row; we have none, so the session id
      incoming: turn.incoming ? 'true' : 'false',
      text,
      dimensionlabs_timestamp: start + i * 1000, // turns in order, one second apart
      conversationId: chat.chat_id,
      csat: chat.csat,
    });
  });
}

fs.writeFileSync(output, Papa.unparse(rows, { newline: '\n' }) + '\n');
console.log(`${data.length} chats -> ${rows.length} turns (${skipped} unlabeled ${skipped === 1 ? 'line' : 'lines'} skipped)`);
```

```bash
node split-transcript.mjs chat-transcripts.csv turns.csv
# 2 chats -> 6 turns (0 unlabeled lines skipped)
```

## In Python

Python 3.9 or later, standard library only. It writes the same file.

```python title="split_transcript.py"
# Split a transcript cell into one row per turn.
# Usage: python3 split_transcript.py chat-transcripts.csv turns.csv

from datetime import datetime

SPEAKERS = {"user": True, "customer": True, "bot": False, "agent": False}
LABEL = re.compile(r"^\s*(user|customer|bot|agent)\s*:\s*", re.I)

def to_millis(iso):
    return int(datetime.fromisoformat(iso.replace("Z", "+00:00")).timestamp() * 1000)

src, dst = sys.argv[1], sys.argv[2]
rows = []
with open(src, newline="", encoding="utf-8") as f:
    for chat in csv.DictReader(f):
        start = to_millis(chat["started_at"])
        turns = []
        for line in chat["transcript"].splitlines():
            m = LABEL.match(line)
            if m:
                turns.append([SPEAKERS[m.group(1).lower()], line[m.end():]])
            elif turns and line.strip():
                turns[-1][1] += " " + line.strip()
        for i, (incoming, text) in enumerate(turns):
            text = " ".join(text.split())
            if text:
                rows.append({"sessionId": chat["chat_id"], "userId": chat["chat_id"],
                             "incoming": "true" if incoming else "false", "text": text,
                             "dimensionlabs_timestamp": start + i * 1000,
                             "conversationId": chat["chat_id"], "csat": chat["csat"]})

with open(dst, "w", newline="", encoding="utf-8") as f:
    w = csv.DictWriter(f, fieldnames=list(rows[0].keys()), lineterminator="\n")
    w.writeheader()
    w.writerows(rows)
print(f"{len(rows)} turns written")
```

## With the toolkit

The [toolkit](/transform/toolkit) does the same with a mapping file and no code to edit. It also adds a `speaker` column.

```json title="transcript.config.json"
{
  "shape": "transcript",
  "columns": { "sessionId": "chat_id", "timestamp": "started_at" },
  "transcript": {
    "column": "transcript",
    "speakerPattern": "^\\s*(user|customer|bot|agent)\\s*:\\s*",
    "incomingSpeakers": ["user", "customer"],
    "outgoingSpeakers": ["bot", "agent"]
  },
  "metadata": { "keep": ["csat"] }
}
```

```bash
node to-dimensionlabs.mjs --config transcript.config.json --input chat-transcripts.csv --out out/turns
```

## Variations

- **Labels with names**, such as `Agent (Sam):`: widen the pattern, for example `^\s*(agent|customer)\b[^:]*:\s*`.
- **Labels in quotes**, such as `BOT: "Hello"`: strip the quotes after removing the label.
- **No labels at all**: the direction can't be recovered. Ask the source system for a per-message export, or [ask support](/transform#get-help-from-dimension-labs).
- **Names instead of roles**: see [Turn call transcripts into turns](/transform/recipe-call-transcripts).
