By the end of this guide you will have a Python script that takes a raw podcast transcript and writes out a markdown blog draft — a title, an intro, four or five sections with headings, and pull-quotes copied word for word from what the guest actually said. Budget about an hour to build it and roughly two minutes per episode to run it. The output is a draft an editor can polish, not a finished article, and that is deliberate: the script does the structural grunt work so your editing time goes into voice and accuracy instead of shaping.
This guide sits inside AI Transcription and Content Repurposing with Python, the series covering what to do with a recording once a machine has turned it into text. If you do not have a transcript yet, Transcribe Audio with the Whisper API and Python produces exactly the plain-text file this script expects.
Prerequisites
You need Python 3.10 or newer, an OpenAI API key, and a transcript saved as transcript.txt in your project folder. If you have not set up an isolated Python workspace yet, Create a Python Virtual Environment for AI walks through it.
python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install "openai>=1.40" "python-dotenv>=1.0"
Put your key in a .env file — a plain text file of NAME=value lines that your script reads at startup, so the key never appears in your code:
OPENAI_API_KEY=sk-your-key-here
Then add that file to .gitignore before you commit anything, so the key cannot travel to a public repository:
echo ".env" >> .gitignore
The transcript itself can be one paragraph per speaker turn or one line per sentence. Speaker labels like SPEAKER 1: or Alex: are helpful but not required. If your transcript runs past roughly 40,000 words you will need to work on it in pieces; Split Long Text into Chunks for AI APIs covers the mechanics.
Step 1 — Clean the transcript before the model reads it
Speech is not writing. A transcript is full of things nobody wants in an article: filler sounds, restarts where the speaker changed their mind mid-sentence, doubled words, machine timestamps, and speaker tags that read like a court record. Every one of those is a token you pay for and a distraction that makes the model's job harder. Worse, they poison quotes — a quote that includes "um, I mean" is unusable even if it is technically accurate.
Do this pass in plain Python with regular expressions, not with an API call. Regular expressions are patterns for finding text; they are fast, free, and, most importantly, they never invent anything. A model asked to "tidy this transcript" will quietly rewrite sentences, and once that happens your quotes no longer match the source.
Save this as clean.py:
import re
from pathlib import Path
FILLER = re.compile(r"(?:,\s*)?\b(?:um|uh|erm|you know|i mean|sort of|kind of)\b,?\s*", re.IGNORECASE)
TIMECODE = re.compile(r"\[?\b\d{1,2}:\d{2}(?::\d{2})?\b\]?\s*")
FALSE_START = re.compile(r"\b([\w']+(?:\s+[\w']+){0,3})\s*[-]+\s*(?=\1\b)", re.IGNORECASE)
STUTTER = re.compile(r"\b([\w']+)(?:\s+\1\b)+", re.IGNORECASE)
LOOSE_PUNCT = re.compile(r"\s+([,.;:!?])")
DOUBLE_PUNCT = re.compile(r"[,;:](\s*[,.;:!?])")
SPACES = re.compile(r"\s{2,}")
SPEAKER_LINE = re.compile(r"^\s*([A-Za-z][A-Za-z ]*\d*)\s*:\s*(.*)$")
NAMES = {"SPEAKER 1": "Host", "SPEAKER 2": "Guest"}
def clean(text: str) -> str:
"""Strip speech artefacts from one turn without rewording it."""
text = TIMECODE.sub("", text)
text = FILLER.sub(" ", text)
text = FALSE_START.sub("", text)
text = STUTTER.sub(r"\1", text)
text = LOOSE_PUNCT.sub(r"\1", text)
text = DOUBLE_PUNCT.sub(r"\1", text)
text = SPACES.sub(" ", text).strip()
return text[:1].upper() + text[1:] if text else text
def clean_transcript(raw: str) -> str:
lines = []
for line in raw.splitlines():
line = line.strip()
if not line:
continue
match = SPEAKER_LINE.match(line)
if match:
speaker = NAMES.get(match.group(1).upper().strip(), match.group(1).strip())
body = clean(match.group(2))
else:
speaker, body = "", clean(line)
if body:
lines.append(f"{speaker}: {body}" if speaker else body)
return "\n".join(lines)
if __name__ == "__main__":
raw = Path("transcript.txt").read_text(encoding="utf-8")
cleaned = clean_transcript(raw)
Path("cleaned.txt").write_text(cleaned, encoding="utf-8")
print(f"{len(raw.split())} words in, {len(cleaned.split())} words out")
Two of those patterns exist only to tidy up after the others. Deleting "you know" from "the big mistake, you know, is timing" would otherwise leave a stranded comma, so FILLER swallows a comma on either side and LOOSE_PUNCT pulls any remaining punctuation back against the word before it. DOUBLE_PUNCT removes the duplicate that appears when a filler phrase sat at the end of a sentence. None of these change a word; they only repair spacing and punctuation that the deletions disturbed.
Run python clean.py and read cleaned.txt yourself before going further. A ten to fifteen percent word drop is normal for conversational speech. Notice that "like" is deliberately absent from the FILLER list: removing it breaks sentences such as "it works like a queue". Cleaners that are too aggressive damage meaning, and from here on cleaned.txt is the only version of the truth your quote checker will compare against.
Step 2 — Outline first, with the quotes attached
This is the step that separates a usable draft from mush. Before any prose exists, make one API call whose entire job is to decide the shape of the article: a working title, an angle, and a list of sections. Crucially, each section also carries the exact transcript sentences that support it. Structure is decided once, in one place, by a model that can see the whole conversation at once.
Ask for JSON — a structured data format the model can emit and Python can load into a dictionary — rather than prose. Set response_format={"type": "json_object"} so the API guarantees valid JSON, and keep temperature low, because you want a consistent structural judgement rather than a creative one. If temperature is new to you, What Is Temperature in an LLM API? explains the dial in one page.
Add this to a new file, outline.py:
import json
import os
from pathlib import Path
from dotenv import load_dotenv
from openai import OpenAI
load_dotenv() # reads .env, which is listed in .gitignore
client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))
OUTLINE_SYSTEM = """You are a structural editor turning a podcast transcript into a blog post.
Return JSON only, matching this shape exactly:
{"title": str, "angle": str, "sections": [{"heading": str, "point": str, "quotes": [str]}]}
Rules:
- 4 to 6 sections, ordered as an argument, not as a replay of the conversation.
- "point" is one sentence naming what that section must prove.
- "quotes" holds 1 or 2 sentences COPIED CHARACTER FOR CHARACTER from the transcript.
- Never paraphrase, tidy or shorten a quote. If no sentence fits, return an empty list."""
def build_outline(transcript: str) -> dict:
response = client.chat.completions.create(
model="gpt-4o",
temperature=0.2,
response_format={"type": "json_object"},
messages=[
{"role": "system", "content": OUTLINE_SYSTEM},
{"role": "user", "content": f"Transcript:\n\n{transcript}"},
],
)
return json.loads(response.choices[0].message.content)
def verify_quotes(outline: dict, transcript: str) -> dict:
"""Delete any quote that is not literally present in the transcript."""
haystack = " ".join(transcript.split()).lower()
for section in outline.get("sections", []):
kept = []
for quote in section.get("quotes", []):
needle = " ".join(quote.split()).lower()
if needle in haystack:
kept.append(quote)
else:
print(f" dropped invented quote: {quote[:70]}")
section["quotes"] = kept
return outline
if __name__ == "__main__":
transcript = Path("cleaned.txt").read_text(encoding="utf-8")
outline = verify_quotes(build_outline(transcript), transcript)
Path("outline.json").write_text(json.dumps(outline, indent=2), encoding="utf-8")
for section in outline["sections"]:
print(f"{section['heading']} — {len(section['quotes'])} verified quote(s)")
verify_quotes is the most important function in this guide. Models are strong at finding the right moment in a conversation and weak at reproducing it letter-perfect; they will helpfully fix grammar, merge two sentences, or smooth an awkward phrase. Searching for the normalised quote inside the normalised transcript catches every one of those edits. Collapsing whitespace on both sides means a line break in the transcript will not cause a false rejection, while a changed word still will.
Resist the temptation to soften that check into a fuzzy match that accepts anything "close enough". A quote is either what the guest said or it is not, and a similarity score of ninety percent is exactly how a negation gets dropped or a hedge gets removed. Keep the comparison exact and accept that you will lose a few candidates; the ones that survive are the ones you can defend if the guest reads the published article and disagrees with it.
Open outline.json and read it before you draft. If the headings look wrong, fix the system prompt or rerun — regenerating an outline costs one call, whereas rewriting five drafted sections costs five. For more control over what a model returns, Write System Prompts that Control Output Format goes deeper on this pattern.
Step 3 — Draft one section per call
Now the outline is fixed, generate prose one section at a time. Each call sees the article title, its own heading, the single point that section must make, and only its own quotes. It never sees the other sections, so it cannot drift into them or repeat their material.
Two things improve immediately. Length becomes predictable, because a word target applied to 200 words is followed far more reliably than a word target applied to 1,200. And cost becomes visible per section, so a runaway request is obvious in the logs rather than buried in one enormous response. This is prompt chaining — feeding the output of one call into the next — and Chain Prompts Together in Python covers the general pattern.
Add these functions to outline.py, or import them from it, so the client and the outline loader are shared:
SECTION_SYSTEM = """You write one section of a blog post adapted from a podcast.
Write plain body prose in the third person, referring to the speaker as "the guest".
Do not write the heading. Do not write an introduction or a conclusion.
Do not add facts, numbers or claims that are absent from the supplied material.
You may reference a supplied quote, but never alter its wording."""
def draft_section(section: dict, title: str, angle: str, words: int = 200) -> str:
quotes = "\n".join(f"- {q}" for q in section["quotes"]) or "- (no quote available)"
user = (
f"Article title: {title}\n"
f"Angle: {angle}\n"
f"Section heading: {section['heading']}\n"
f"Point this section must make: {section['point']}\n"
f"Supporting quotes:\n{quotes}\n\n"
f"Write about {words} words of body prose for this section only."
)
response = client.chat.completions.create(
model="gpt-4o-mini",
temperature=0.4,
max_tokens=600,
messages=[
{"role": "system", "content": SECTION_SYSTEM},
{"role": "user", "content": user},
],
)
return response.choices[0].message.content.strip()
Note the model swap. The outline call used gpt-4o because choosing an argument and locating supporting evidence is genuinely hard; the drafting calls use gpt-4o-mini because every decision has already been made and the remaining work is writing tidy sentences. On a five-section article that is one expensive call and five cheap ones. To see what that actually costs you, Count Tokens in Python Before You Send shows how to measure a request before it leaves your machine.
Step 4 — Assemble the markdown and place the quotes verbatim
Assembly is pure Python string work, and it must stay that way. The drafting model has already written the prose; if you now hand the whole article back to a model and ask it to "polish and add the quotes", it will rewrite them. Instead, copy each verified quote out of outline.json — the same string your checker approved — and paste it into a markdown blockquote around the drafted prose.
The rule to internalise: a quote is data, not output. It travels from transcript to JSON to markdown by copying, and it is never regenerated at any stage. Anything the model produces goes into body paragraphs where it reads as your summary, not as the guest's words.
Save this as build_post.py. It ties the three steps together and writes the finished file:
import json
import os
from pathlib import Path
from dotenv import load_dotenv
from openai import OpenAI
from outline import build_outline, verify_quotes, draft_section
load_dotenv()
client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))
def draft_intro(outline: dict) -> str:
headings = ", ".join(s["heading"] for s in outline["sections"])
response = client.chat.completions.create(
model="gpt-4o-mini",
temperature=0.4,
max_tokens=250,
messages=[
{"role": "system", "content": "You write blog intros. Two short paragraphs, no heading."},
{"role": "user", "content": (
f"Title: {outline['title']}\nAngle: {outline['angle']}\n"
f"Sections to come: {headings}\n"
"Set up the argument. Do not quote anyone."
)},
],
)
return response.choices[0].message.content.strip()
def assemble(outline: dict, intro: str, bodies: list[str]) -> str:
parts = [f"# {outline['title']}", "", intro, ""]
for section, body in zip(outline["sections"], bodies):
parts += [f"## {section['heading']}", "", body, ""]
if section["quotes"]:
parts += [f"> {section['quotes'][0]}", "", "*— the guest*", ""]
return "\n".join(parts)
if __name__ == "__main__":
transcript = Path("cleaned.txt").read_text(encoding="utf-8")
outline = verify_quotes(build_outline(transcript), transcript)
Path("outline.json").write_text(json.dumps(outline, indent=2), encoding="utf-8")
bodies = []
for index, section in enumerate(outline["sections"], start=1):
print(f"drafting {index}/{len(outline['sections'])}: {section['heading']}")
bodies.append(draft_section(section, outline["title"], outline["angle"]))
post = assemble(outline, draft_intro(outline), bodies)
Path("post.md").write_text(post, encoding="utf-8")
print(f"wrote post.md — {len(post.split())} words")
Run python build_post.py and open post.md. You should see a titled article with an intro, one heading per outlined section, and a blockquote under any section that had a verified quote. Sections whose quotes were all rejected simply appear without one, which is the correct failure mode: fewer quotes, never invented ones. From here the draft is yours to edit — cut a weak section, reorder two, and rerun draft_section on anything that reads thin.
Three things are worth checking by hand on every run, because no script can judge them. Read each blockquote in context and confirm it still means what it meant in the conversation — a sentence can be word-perfect and still mislead once the question that prompted it is gone. Check that the drafted prose around a quote does not restate it, which happens when the model treats the quote as a summary target. And confirm the intro promises only what the sections deliver, since the intro was written from headings rather than from the finished text.
Quick reference: the four calls
| Call | Model | Temperature | Returns |
|---|---|---|---|
| Outline | gpt-4o | 0.2 | JSON: title, angle, sections, quotes |
| Section draft | gpt-4o-mini | 0.4 | ~200 words of body prose |
| Intro | gpt-4o-mini | 0.4 | Two short opening paragraphs |
| Cleaning | none | n/a | Regex only, runs locally and free |
Troubleshooting
json.decoder.JSONDecodeError: Expecting value: line 1 column 1 (char 0)— the reply was wrapped in a markdown code fence instead of being raw JSON. Cause:response_formatwas omitted or the model was told to "show" the JSON. Fix: keepresponse_format={"type": "json_object"}on the outline call, and see Fix JSONDecodeError with AI API Responses in Python for a defensive parser.openai.BadRequestError: ... maximum context length ... tokens— the transcript is longer than the model's input window. Cause: an episode over roughly an hour with no chunking. Fix: outline the episode in halves and merge the two section lists, following Fix the Context-Length-Exceeded Error in Python.KeyError: 'quotes'— a section object came back without the key. Cause: the model dropped a field it had nothing to put in. Fix: read every field withsection.get("quotes", [])rather than square brackets, asverify_quotesalready does.- Every quote prints
dropped invented quote— the checker is comparing against different text from the one the model saw. Cause:clean.pywas rerun with new rules afteroutline.jsonwas produced. Fix: always pass the samecleaned.txtstring tobuild_outlineandverify_quotesin a single run, which the script above does by design.
When to use this vs. alternatives
- Use this workflow when the episode contains a real argument and you want the guest's own phrasing to carry the article. The outline-then-draft split costs a handful of calls and gives you structure you can inspect before any prose is written.
- Use a straight summary instead when you only need the gist — a recap paragraph for a newsletter or show notes. Summarize a YouTube Video with Python does that in one call, with none of the quote machinery here.
- Start from a brief rather than a transcript when there is no interview at all. If the source is your own outline or a keyword list, Generate Blog Posts with the OpenAI API is the shorter path, and Generate Meta Descriptions in Bulk with Python finishes either route off.
The habit worth keeping from this page is the separation of jobs: regex for anything that must not change, one structured call for judgement, small independent calls for prose, and plain Python for assembly. Once one episode works, the same script handles a back catalogue in a loop, and the same cleaned transcript feeds other outputs — Generate SRT Subtitles with Python and AI reuses it for video captions without a second transcription bill.
Back to AI Transcription and Content Repurposing with Python.
Related guides
- Transcribe Audio with the Whisper API and Python — produce the
transcript.txtthis script starts from. - Generate SRT Subtitles with Python and AI — get captions out of the same recording.
- Summarize a YouTube Video with Python — the one-call option when you only need a recap.
- Chain Prompts Together in Python — the general pattern behind outline-then-draft.
- Split Long Text into Chunks for AI APIs — handle episodes too long for one request.