Meeting Transcription & Summary

Ma Mahalakshmi V Updated 19 Sep 2026
7 min read ·Lesson 216 of 224

Project 7: Build a Meeting Transcription and Summary System

This project builds a pipeline that turns a recorded meeting into a searchable transcript and a structured summary with action items, using the audio transcription and speech capabilities from Unit 19. The pipeline is designed around long recordings, which introduces a chunking problem that a short voice-memo example would not surface.

Scope and Design Decisions

The system accepts an audio file (typically 20 minutes to two hours), produces a full transcript, and generates a structured meeting summary: key discussion points, decisions made, and action items with owners when identifiable. It does not attempt real-time streaming transcription — that is a materially different architecture, built on a streaming transcription API rather than batch file upload, and is a reasonable follow-on extension rather than part of this base project.

Two decisions shape the design:

  1. Long recordings are chunked before transcription, not summarized in one call. Transcription models have practical file-size and duration limits, so a two-hour meeting must be split into segments, transcribed independently, and stitched back together with timestamps preserved.
  2. Summarization operates on the transcript text, not the audio directly. Once a clean, timestamped transcript exists, extracting action items and decisions is a text-understanding task best handled by the main language model with structured outputs (Unit 6), separate from the audio-to-text step.

Chunking and Transcribing Long Audio

from openai import OpenAI
from pydantic import BaseModel
from pydub import AudioSegment
import os

client = OpenAI()

CHUNK_DURATION_MS = 10 * 60 * 1000  # 10-minute chunks stay well under upload limits

def split_audio(file_path: str, output_dir: str) -> list[str]:
    audio = AudioSegment.from_file(file_path)
    os.makedirs(output_dir, exist_ok=True)
    chunk_paths = []
    for i, start_ms in enumerate(range(0, len(audio), CHUNK_DURATION_MS)):
        chunk = audio[start_ms:start_ms + CHUNK_DURATION_MS]
        chunk_path = os.path.join(output_dir, f"chunk_{i:03d}.mp3")
        chunk.export(chunk_path, format="mp3")
        chunk_paths.append(chunk_path)
    return chunk_paths

def transcribe_chunk(chunk_path: str, chunk_index: int, offset_seconds: float) -> dict:
    with open(chunk_path, "rb") as f:
        result = client.audio.transcriptions.create(
            model="gpt-5.6-terra-transcribe",
            file=f,
            response_format="verbose_json",
        )
    return {
        "chunk_index": chunk_index,
        "offset_seconds": offset_seconds,
        "text": result.text,
        "segments": getattr(result, "segments", []),
    }

split_audio uses fixed 10-minute chunks rather than trying to detect natural pause points — silence-based splitting is a reasonable refinement but adds complexity for a marginal gain, since even a split mid-sentence loses at most a few words at the boundary, which the summarization pass can tolerate. transcribe_chunk records offset_seconds alongside the transcribed text specifically so that timestamps from each chunk's segments can later be corrected back to their position in the original, full-length recording — without this offset, every chunk's internal timestamps would restart at zero and be meaningless once chunks are combined.

Note: Transcription model names, response_format options, and the exact shape of segments are part of the audio API surface covered in Unit 19 and can evolve between SDK versions. Verify current parameter names and the verbose JSON schema against the SDK documentation before relying on specific field names in production.

Stitching Chunks Into a Single Transcript

def transcribe_full_meeting(file_path: str, work_dir: str) -> dict:
    chunk_paths = split_audio(file_path, work_dir)
    chunk_results = []
    for i, chunk_path in enumerate(chunk_paths):
        offset = i * (CHUNK_DURATION_MS / 1000)
        chunk_results.append(transcribe_chunk(chunk_path, i, offset))

    full_text_parts = []
    all_segments = []
    for chunk in chunk_results:
        full_text_parts.append(chunk["text"])
        for segment in chunk["segments"]:
            adjusted_start = segment.get("start", 0) + chunk["offset_seconds"]
            adjusted_end = segment.get("end", 0) + chunk["offset_seconds"]
            all_segments.append({
                "start": adjusted_start,
                "end": adjusted_end,
                "text": segment.get("text", ""),
            })

    return {
        "full_text": " ".join(full_text_parts),
        "segments": all_segments,
    }

The offset correction — adding each chunk's offset_seconds to every segment's local start and end — is the detail that makes the stitched transcript actually usable for navigation (jumping to the moment a decision was discussed). Chunks are processed sequentially here for clarity; a production version handling many long meetings would parallelize this loop with a thread pool or async calls, since transcription chunks are fully independent of each other and there is no reason to wait for one to finish before starting the next.

Extracting a Structured Summary

class ActionItem(BaseModel):
    description: str
    owner: str | None
    due_date_mentioned: str | None

class MeetingSummary(BaseModel):
    key_points: list[str]
    decisions: list[str]
    action_items: list[ActionItem]
    open_questions: list[str]

def summarize_meeting(full_text: str) -> MeetingSummary:
    response = client.responses.parse(
        model="gpt-5.6-terra",
        input=[
            {
                "role": "system",
                "content": (
                    "Summarize this meeting transcript. Distinguish decisions "
                    "that were finalized from open questions still under "
                    "discussion. For each action item, extract the owner only "
                    "if a specific person was named; otherwise leave it null "
                    "rather than guessing."
                ),
            },
            {"role": "user", "content": full_text},
        ],
        text_format=MeetingSummary,
    )
    return response.output_parsed

The instruction to leave owner as null rather than guessing addresses a real failure mode: meeting transcripts often contain action items where responsibility was implied but never explicitly assigned ("someone should follow up with the vendor"), and a summarization model under implicit pressure to fill in every field will confidently but incorrectly attribute the task to whoever spoke most recently. Making owner explicitly optional in the schema, combined with an explicit instruction not to guess, is what keeps the summary honest about what was actually said versus what the model inferred.

decisions and open_questions are kept as separate lists rather than one combined "topics discussed" list because they carry very different weight for a reader skimming the summary — a finalized decision needs no further discussion, while an open question is exactly the kind of item that should resurface on the next meeting's agenda. Conflating them would lose that distinction.

Full Pipeline

def process_meeting_recording(audio_path: str, work_dir: str) -> dict:
    transcript = transcribe_full_meeting(audio_path, work_dir)
    summary = summarize_meeting(transcript["full_text"])
    return {
        "transcript": transcript,
        "summary": summary,
    }

def format_summary_markdown(summary: MeetingSummary) -> str:
    lines = ["## Key Points"]
    lines += [f"- {p}" for p in summary.key_points]
    lines += ["", "## Decisions"]
    lines += [f"- {d}" for d in summary.decisions]
    lines += ["", "## Action Items"]
    for item in summary.action_items:
        owner_part = f" (Owner: {item.owner})" if item.owner else " (Owner: unassigned)"
        due_part = f" — due {item.due_date_mentioned}" if item.due_date_mentioned else ""
        lines.append(f"- {item.description}{owner_part}{due_part}")
    lines += ["", "## Open Questions"]
    lines += [f"- {q}" for q in summary.open_questions]
    return "\n".join(lines)

process_meeting_recording is the top-level entry point that ties transcription and summarization together, but the two remain independently callable — an application might re-summarize an already-transcribed meeting with a refined prompt without paying for re-transcription, which is the more expensive and slower of the two steps.

Testing the Summarization Contract

def test_owner_left_null_when_not_named(monkeypatch_parse):
    fake_summary = MeetingSummary(
        key_points=["Discussed Q3 roadmap"],
        decisions=["Move launch date to October"],
        action_items=[
            ActionItem(description="Follow up with the vendor", owner=None, due_date_mentioned=None)
        ],
        open_questions=["Who owns the vendor relationship going forward?"],
    )

    class FakeResponse:
        output_parsed = fake_summary

    monkeypatch_parse(lambda **kwargs: FakeResponse())
    result = summarize_meeting("some transcript text")
    assert result.action_items[0].owner is None
    assert "vendor" in result.open_questions[0].lower()
    print("PASS: unassigned action items keep owner as null rather than guessed")

def _make_monkeypatch():
    original = client.responses.parse
    def apply(fn):
        client.responses.parse = fn
    def restore():
        client.responses.parse = original
    return apply, restore

monkeypatch_parse, restore_parse = _make_monkeypatch()
test_owner_left_null_when_not_named(monkeypatch_parse)
restore_parse()

The test substitutes a fake client.responses.parse returning a pre-built MeetingSummary, which validates that summarize_meeting correctly passes through the parsed object without altering it — the actual guarantee this test protects is architectural (the function is a thin, faithful wrapper around the API call), while the harder-to-test guarantee (the model itself reliably avoiding guessed owners) belongs to prompt evaluation, a different kind of testing covered in Unit 13.

Extending This Project

Add speaker diarization so the transcript attributes each segment to a specific speaker, which substantially improves the quality of owner attribution in action items, and add a searchable index over transcript segments (using the embeddings techniques from Project 8) so users can search across an entire meeting archive by topic.

Common Mistakes

  • Feeding an entire long recording to a transcription call without checking size or duration limits. This either fails outright or silently truncates; always chunk proactively for anything beyond a short clip.
  • Losing timestamp continuity across chunks. Each chunk's transcription starts its internal timestamps at zero; forgetting to add the chunk's offset makes the stitched transcript's timestamps meaningless for navigation.
  • Letting the summarization model guess action item owners when none was stated. This produces a plausible-looking but factually invented summary. Make the owner field optional and instruct the model explicitly not to infer one.

Best Practices

  • Separate the audio-to-text step from the text-understanding step. Transcription and summarization are different tasks with different failure modes; keeping them as independent, independently testable functions makes both easier to improve and to re-run selectively.
  • Preserve timestamps end to end. A transcript without navigable timestamps is far less useful for reviewing a specific part of a long meeting.
  • Make uncertainty representable in the schema. Optional fields like owner and due_date_mentioned let the model honestly report what was and wasn't specified, rather than being forced to fabricate a value.

0 Comments

Reviewed before they appear

No comments yet.

OpenAI SDK
Introduction to the OpenAI SDK Setting Up Python Creating an API Key Your First Call — client.responses.create() and response.output_text Understanding Billing, Credits, and What a Request Costs Why Responses Replaced Chat Completions Anatomy of a Request: model, input, and instructions Anatomy of a Response: The Typed output Array, Not Just Text Roles: User, Assistant, and Developer/System Choosing a Model, and Reading the Models Page Instead of Memorizing Names Instructions vs. Input Writing Prompts That Get Consistent Results Few-Shot Examples Reasoning Models and the reasoning Parameter Debugging a Prompt That Misbehaves Why Streaming Matters for User Experience stream=True and Iterating Over Events Handling the Event Types You Actually Care About Background Mode for Long-Running Jobs Project — Add Live Streaming to Your Chatbot The Problem With Parsing Free Text JSON Schema and Strict Mode Pydantic Models With the SDK's Parse Helpers Handling Refusals and Validation Failures Project — A Resume-to-JSON Extractor Working With input_image input_file, PDFs, and the Files API Image Generation Speech-to-Text and Text-to-Speech Project: A PDF Question-Answering Script What Function Calling Is Defining a Tool Schema The Full Loop Multiple Tools Errors, Timeouts, and Untrusted Arguments Project: A Weather Assistant Web Search File Search and Vector Stores Code Interpreter Remote MCP Servers and Connectors Project: A Research Assistant What an Embedding Is, Without the Maths Generating and Storing Embeddings Similarity Search From Scratch Hosted Vector Stores vs. Rolling Your Own A Small RAG App Over a Folder of Notes Agents vs. a Single API Call — When You Need One pip install openai Giving Agents Tools Handoffs and Multi-Agent Triage Guardrails and Approvals Tracing and Observing What Your Agent Did A Multi-Agent Support Desk Error Codes and What Each One Means Retries, Timeouts, and Backoff Rate Limits and Spend Limits Prompt Caching and Cost Optimisation The Batch API for Bulk Work Async Clients and Concurrency Moderation and Safety Best Practices Designing the App Backend With FastAPI Streaming to a Simple Frontend Deploying and a Cost/Safety Checklist Why Web Search Is Useful for Current Information Using the Web Search Tool with the Responses API Configuring Search Behavior for Application Use Cases Understanding Citations and Source Attribution Where to Go Next Building a Research Assistant with Web Search Combining Web Search with Structured Outputs Handling Conflicting or Low-Quality Web Sources Reducing Unsupported Claims with Grounded Generation Testing Freshness-Sensitive AI Answers Production Considerations for Web-Grounded Applications Understanding File Search and Retrieval-Augmented Generation Creating and Organizing Vector Stores Uploading Documents for Retrieval Connecting Vector Stores to Responses API Requests Designing Document Metadata and Filtering Strategies Building a PDF Question-Answering Application Improving Retrieval Quality With Better Document Preparation Handling Missing Evidence and Retrieval Failures Combining File Search With Web Search Building a Production Knowledge-Base Assistant What the Code Interpreter Tool Is Designed For Running Python-Based Analysis Through the OpenAI SDK Uploading Datasets for Analysis Analyzing CSV and Spreadsheet Data Generating Charts and Data Summaries Handling Generated Files and Downloadable Artifacts Building a Data-Analysis Assistant Combining Code Execution with Structured Outputs Validating Generated Calculations and Results Security and Sandbox Considerations for Code Execution Understanding Multimodal Input with the OpenAI SDK Sending Images to a Model Image Analysis from URLs and Uploaded Files Extracting Text and Information from Screenshots Building an Image-Question-Answering Application Combining Image Input with Structured Output Analyzing Multiple Images in One Request Handling Image Quality and Input Limitations Designing Multimodal Prompts for Reliable Results Building a Practical Vision-Powered Python Application Understanding Speech-to-Text and Text-to-Speech Workflows Transcribing Audio with the OpenAI SDK Working with Uploaded Audio Files Handling Timestamps and Transcription Metadata Building a Meeting Transcription Workflow Generating Spoken Responses from Text Handling Long Audio and Processing Failures Combining Audio with Text and Tool Calling Building an End-to-End Python Voice Application What Embeddings Are and When to Use Them Generating Embeddings With the OpenAI API Preparing Text for Embedding Comparing Vectors With Cosine Similarity Building a Simple Semantic Search Engine in Python Storing Embeddings in a Database Metadata Filtering for Semantic Search Chunking Strategies for Better Retrieval Evaluating Semantic Search Quality Building a Document Similarity Application When Batch Processing Makes Sense Designing Large-Volume AI Processing Pipelines Using Asynchronous Python with the OpenAI SDK Running Concurrent Requests Safely Controlling Concurrency and Avoiding Rate Limits Tracking Batch Job Progress Handling Partial Failures in Bulk Workloads Retrying Failed Items Without Duplicating Successful Work Designing Resumable AI Processing Jobs Building a Production Batch-Processing Pipeline Batch Processing Makes Sense Large-Scale AI Processing Pipelines Async Python with OpenAI SDK Safe Concurrent Requests Concurrency & Rate Limits Batch Progress Tracking Partial Failure Handling Safe Retry Handling Resumable AI Jobs Production Batch Pipeline System–User Data Separation Reusable App Instructions Prompt Templates & Variables Extraction & Classification Prompts Summarization & Transformation Prompts Explicit Output Requirements Prompt Version Management Prompt Testing & Evaluation Reusable Python Prompt Library API Key Security Secure API Key Storage Secure Secret Management Prompt Injection Prevention Trusted vs. Untrusted Content Tool Argument Validation Sensitive Data Handling Secure Logging AI Action Authorization Production AI Security Checklist Why AI Applications Need Evaluation Beyond Unit Tests Unit Testing OpenAI SDK Integration Code Mocking API Responses in Python Tests Testing Structured Outputs Against Schemas Testing Tool-Calling Workflows Building a Small Evaluation Dataset Measuring Accuracy, Consistency, and Failure Rates Regression Testing Prompts and Model Changes Human Evaluation Versus Automated Evaluation Creating a Repeatable Evaluation Pipeline AI Request Monitoring Token Cost Management Usage Metrics Design Reducing Model Calls Prompt & Context Optimization Model Selection & Optimization AI Caching Strategies Interactive Latency Optimization Usage Dashboards & Budget Alerts Performance & Cost Checklist Every API Call Starts Fresh Fixing API Statelessness Server-Side Conversation Memory Limits of Response Chaining What We're Building Conversation Memory Challenges Preparing an OpenAI SDK Application for Deployment Environment-Specific Configuration for Development and Production Deploying a Python AI Service with Docker Container Health Checks and Startup Configuration Managing Secrets in Cloud Deployments Background Workers for Long-Running AI Tasks Queues and Asynchronous Job Architectures Scaling AI Workloads Horizontally Monitoring Production Incidents and Failures Production Deployment Checklist for OpenAI SDK Applications Reusable OpenAI Service Classes AI Client Dependency Injection Typed AI Responses Python Configuration Management AI Request Decorators Centralized AI Error Handling Clean SDK Abstractions Reusable OpenAI Utilities Internal AI Python Libraries SDK Integration Maintenance Production AI Chatbot Document Q&A System Web Research Assistant Customer Support Agent AI Data Analysis Assistant Image Analysis App Meeting Transcription & Summary Semantic Document Search Multi-Tool AI Agent Production OpenAI SDK App Why "It Looked Fine When I Tested It" Isn't Enough Timing Note Status Note Pre-Decision Status Note Current Availability Note
Ask about this post
AI Ask about this post

Ask questions about Meeting Transcription & Summary and get answers drawn from it.

Signed-in readers only.