Skip to content

02 Get your data in

Split a transcript into turns

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

Your export · one row per chat
chat_idcustomertranscriptstarted
2201cust-88Customer: Hi, my order never showed upAgent: Sorry! Can you share the order number?Customer: LC-488121/1/26 12:35 PM
Ready to upload · one row per message
sessionIduserIdincomingtextdimensionlabs_timestamp
lc-2201cust-88trueHi, my order never showed up1767299700000
lc-2201cust-88falseSorry! Can you share the order number?1767299701000
lc-2201cust-88trueLC-488121767299702000
From export to upload-ready rows. Times are read in the time zone you choose and written as Unix milliseconds, one second apart.

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-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."
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
  • 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.
  • 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 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). 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.

Node 18 or later, with npm install papaparse.

split-transcript.mjs
// Split a transcript cell into one row per turn.
// Usage: node split-transcript.mjs chat-transcripts.csv turns.csv
import 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)`);
Terminal window
node split-transcript.mjs chat-transcripts.csv turns.csv
# 2 chats -> 6 turns (0 unlabeled lines skipped)

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

split_transcript.py
# Split a transcript cell into one row per turn.
# Usage: python3 split_transcript.py chat-transcripts.csv turns.csv
import csv, re, sys
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")

The toolkit does the same with a mapping file and no code to edit. It also adds a speaker column.

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"] }
}
Terminal window
node to-dimensionlabs.mjs --config transcript.config.json --input chat-transcripts.csv --out out/turns
  • 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.