Building a Meeting Transcription Workflow
From a Single API Call to a Real Pipeline
Everything covered so far in this unit — transcribing a file, handling uploads, extracting timestamps — are the individual components of a larger system. This lesson assembles them into something closer to a real application: an end-to-end workflow that takes a recorded meeting and produces a structured, useful artifact — a transcript with timestamps, speaker-oriented sections, and a concise summary — rather than just a wall of raw text.
A meeting transcription workflow needs to handle several concerns that a single transcription call does not address on its own: meetings are often long (potentially exceeding single-request audio limits, which Lesson 8 covers in depth), they benefit from structural organization (who said what, roughly, even without true speaker diarization), and raw transcripts are rarely the actual deliverable — most people want a summary and action items, not a full verbatim dump. Building this workflow means combining the transcription endpoint with a chat completion call that processes the transcript afterward.
Step 1: Transcribing with Segment Metadata
The workflow begins with transcription using segment-level timestamps, since a meeting transcript is far more useful when readers can jump to specific moments:
from openai import OpenAI
client = OpenAI()
def transcribe_meeting(audio_path: str):
with open(audio_path, "rb") as audio_file:
transcript = client.audio.transcriptions.create(
model="gpt-5.6-terra",
file=audio_file,
response_format="verbose_json",
timestamp_granularities=["segment"],
prompt="This is a business meeting. Expect names, project names, and technical terms.",
)
return transcript
The prompt here is doing meaningful work, as explained in Lesson 2: meetings are full of proper nouns — project codenames, colleague names, internal tool names — that a general-purpose transcription model has no way to anticipate without a hint. A short, generic prompt like this one measurably reduces misspellings of names and jargon compared to no prompt at all.
Step 2: Formatting the Transcript into Readable Sections
Raw segments are typically short (a sentence or a partial sentence each), so displaying them one by one produces a choppy, hard-to-read wall of tiny timestamped fragments. Grouping nearby segments into readable paragraphs, while preserving a timestamp anchor for each group, produces a much more usable transcript:
def group_segments_into_paragraphs(segments, max_gap_seconds: float = 2.0, max_paragraph_seconds: float = 45.0):
"""
Groups consecutive segments into paragraphs. A new paragraph starts when
there is a pause longer than max_gap_seconds, or when the current
paragraph would exceed max_paragraph_seconds in length.
"""
paragraphs = []
current_texts = []
paragraph_start = None
previous_end = None
for segment in segments:
if paragraph_start is None:
paragraph_start = segment.start
gap_too_large = previous_end is not None and (segment.start - previous_end) > max_gap_seconds
paragraph_too_long = (segment.end - paragraph_start) > max_paragraph_seconds
if gap_too_large or paragraph_too_long:
paragraphs.append({
"start": paragraph_start,
"text": " ".join(current_texts).strip(),
})
current_texts = []
paragraph_start = segment.start
current_texts.append(segment.text.strip())
previous_end = segment.end
if current_texts:
paragraphs.append({
"start": paragraph_start,
"text": " ".join(current_texts).strip(),
})
return paragraphs
This function walks through segments in order, accumulating their text into current_texts until one of two conditions triggers a new paragraph: either a long silence (gap_too_large, meaning more than max_gap_seconds passed between one segment ending and the next starting — often a real pause between topics or speakers) or the current paragraph growing too long (paragraph_too_long). When either condition is met, the accumulated text is joined into a single paragraph string, tagged with the timestamp of its first segment (paragraph_start), and the accumulator resets. The final if current_texts: block after the loop is essential — without it, whatever segments accumulated after the last paragraph break would be silently dropped, since the loop only flushes a paragraph when a new one starts, not automatically at the end.
This heuristic-based grouping is not true speaker diarization (identifying which of several speakers said what) — the transcription endpoint used here does not provide per-speaker labels. It approximates readable structure using pause timing alone, which works reasonably well for meetings with natural conversational pauses, but will not distinguish who is speaking. Applications that require true multi-speaker labeling need a separate diarization step or model, which is beyond the scope of what this endpoint provides.
Step 3: Summarizing the Transcript with a Chat Completion
Once you have a clean, paragraph-structured transcript, generating a summary and action items is a text-processing task, not an audio task — this is where the transcription pipeline hands off to a standard chat completion call:
def summarize_meeting_transcript(client, full_transcript_text: str) -> str:
response = client.chat.completions.create(
model="gpt-5.6-terra",
messages=[
{
"role": "system",
"content": (
"You summarize meeting transcripts. Produce a brief summary "
"paragraph followed by a bulleted list of action items with "
"the responsible person named when mentioned in the transcript."
),
},
{"role": "user", "content": full_transcript_text},
],
)
return response.choices[0].message.content
This step reuses the same client object already used for transcription, since both audio and chat endpoints live under the same OpenAI client instance — there is no need for a separate connection or authentication setup. The system message defines the exact output shape you want (a summary paragraph plus a bulleted action list), which is a form of prompt engineering covered more thoroughly elsewhere in this course; the key point for this lesson is that combining STT output with a subsequent chat completion call is what turns a raw transcript into a genuinely useful meeting artifact.
Assembling the Full Workflow
from openai import OpenAI
client = OpenAI()
def run_meeting_transcription_workflow(audio_path: str) -> dict:
transcript = transcribe_meeting(audio_path)
paragraphs = group_segments_into_paragraphs(transcript.segments)
full_text = "\n\n".join(p["text"] for p in paragraphs)
summary = summarize_meeting_transcript(client, full_text)
return {
"duration_seconds": transcript.duration,
"paragraphs": paragraphs,
"summary": summary,
}
The return value is a plain dictionary combining three distinct pieces of value: the total meeting duration (useful metadata for a meeting archive), the structured, timestamp-anchored paragraphs (useful for a reader who wants to review or jump around the full transcript), and the generated summary (useful for someone who just wants the highlights without reading the whole thing). Returning a dictionary rather than, say, printing results directly, keeps this function reusable — a caller can render it to HTML, save it to a database, email it, or feed it into another process, none of which this function needs to know about.
Handling Long Meetings
A one-hour or longer meeting recording may exceed the transcription endpoint's per-request duration or file size limits. This workflow, as written, assumes the audio fits in a single request. Lesson 8 covers chunking strategies for long audio in detail; the key adjustment needed here is that transcribe_meeting would be replaced with a loop that transcribes sequential chunks and concatenates their segments (adjusting timestamps by each chunk's offset) before the grouping step runs. It is worth flagging this limitation now, because assuming any meeting audio will always fit in one request is one of the most common gaps in a first implementation of this workflow.
Testing the Workflow Logic
The parts of this workflow that do not call an API — segment grouping — can and should be tested directly with fake segment objects:
class FakeSegment:
def __init__(self, start, end, text):
self.start = start
self.end = end
self.text = text
def test_group_segments_splits_on_long_pause():
segments = [
FakeSegment(0.0, 2.0, "Let's get started."),
FakeSegment(2.5, 4.0, "First item on the agenda."),
FakeSegment(10.0, 12.0, "Moving on to the next topic."),
]
paragraphs = group_segments_into_paragraphs(segments, max_gap_seconds=2.0)
assert len(paragraphs) == 2
assert paragraphs[0]["text"] == "Let's get started. First item on the agenda."
assert paragraphs[1]["text"] == "Moving on to the next topic."
print("PASS: group_segments_splits_on_long_pause")
def test_group_segments_splits_on_max_length():
segments = [FakeSegment(i * 10, i * 10 + 5, f"Segment {i}.") for i in range(6)]
paragraphs = group_segments_into_paragraphs(segments, max_gap_seconds=100.0, max_paragraph_seconds=20.0)
assert len(paragraphs) > 1
print("PASS: group_segments_splits_on_max_length")
test_group_segments_splits_on_long_pause()
test_group_segments_splits_on_max_length()
The first test confirms that a 6-second silence (between the segment ending at 4.0 and the next one starting at 10.0) correctly triggers a paragraph break given a 2-second gap threshold, and that the resulting text is joined correctly with spaces. The second test confirms the length-based fallback trigger works even when there is no meaningful pause at all, by using a very large max_gap_seconds (effectively disabling pause-based splitting) and a small max_paragraph_seconds, forcing the length check to be the only thing that can produce more than one paragraph. Testing each triggering condition independently, rather than only testing a combined "realistic" case, makes it much clearer which piece of logic is broken if a future change causes one of these tests to fail.
Common Mistakes
Treating pause-based paragraph grouping as true speaker identification, which causes incorrect assumptions in any downstream feature that tries to attribute paragraphs to specific people. This heuristic groups by pacing, not by speaker identity — a single speaker giving a long, pause-free explanation will appear as one long block, and two speakers talking without pausing will appear merged into one block.
Forgetting the final flush after the grouping loop, which causes the last paragraph of the meeting to be silently dropped from the output. Any accumulate-then-flush loop pattern like this one needs an explicit post-loop step to handle whatever remains in the accumulator.
Sending the entire raw transcript to the summarization call without first grouping or cleaning it, which works but produces noisier input (many tiny fragments) than necessary and can make it harder for the summarization step to correctly attribute context across sentence fragments. Cleaning and structuring the transcript first, as shown, generally produces a more coherent summary.
Best Practices
Keep each pipeline stage as an independently callable, independently testable function, as done here with transcribe_meeting, group_segments_into_paragraphs, and summarize_meeting_transcript. This makes it possible to swap or improve one stage (say, adding real diarization later) without rewriting the others.
Always pass a domain-relevant prompt to the transcription call for specialized content like meetings, since it meaningfully reduces name and jargon misspellings for a small, essentially free addition to the request.
Design the workflow's output as structured data (a dictionary or a well-defined object), not as a pre-formatted string. This keeps the workflow reusable across different presentation contexts — a web page, an email digest, a Slack message — without needing to change the underlying pipeline.