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
Section titled “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.
Example input, chat-transcripts.csv. Note the customer’s message in ch-301 that runs over two lines.
chat_id,started_at,csat,transcriptch-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 openBOT: The Pearl store opens at 6:30 AM every day."Output
Section titled “Output”sessionId,userId,incoming,text,dimensionlabs_timestamp,conversationId,csatch-301,ch-301,false,Hi! I'm the Lumen Coffee helper. What can I do for you?,1767621720000,ch-301,2ch-301,ch-301,true,My subscription box came without the decaf. I ordered the Night Owl.,1767621721000,ch-301,2ch-301,ch-301,false,Sorry about that. Do you want a refund or a replacement?,1767621722000,ch-301,2ch-301,ch-301,true,Replacement please,1767621723000,ch-301,2ch-302,ch-302,true,what time does the Pearl store open,1767627600000,ch-302,5ch-302,ch-302,false,The Pearl store opens at 6:30 AM every day.,1767627601000,ch-302,5- The label decides the direction.
USERandCustomerareincoming: true;BOTandAgentarefalse. 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.
- 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 to stay in order.
- One
userIdfor a bot chat. For a bot conversation, the end user’s id goes on every row, the bot’s included (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.
csathere is a conversation-level score. Repeat it on each row; it is stored with the conversation as metadata.
Script
Section titled “Script”Node 18 or later, with npm install papaparse.
// Split a transcript cell into one row per turn.// Usage: node split-transcript.mjs chat-transcripts.csv turns.csvimport fs from 'node:fs';import Papa from 'papaparse';
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)`);node split-transcript.mjs chat-transcripts.csv turns.csv# 2 chats -> 6 turns (0 unlabeled lines skipped)In Python
Section titled “In Python”Python 3.9 or later, standard library only. It writes the same file.
# Split a transcript cell into one row per turn.# Usage: python3 split_transcript.py chat-transcripts.csv turns.csvimport csv, re, sysfrom 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
Section titled “With the toolkit”The toolkit does the same with a mapping file and no code to edit. It also adds a speaker column.
{ "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"] }}node to-dimensionlabs.mjs --config transcript.config.json --input chat-transcripts.csv --out out/turnsVariations
Section titled “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.
- Names instead of roles: see Turn call transcripts into turns.