LLM-Assisted Chapter Titling from Transcripts

This page sits beneath Automated Chapter Generation within the broader Transcription & Speaker Diarization pipeline, and it solves the last mile of chaptering: you already have well-placed chapter boundaries and the transcript span each one covers, and you need to turn each span into a short, accurate title that a listener will recognize in a player’s chapter list — without the model inventing a title that has nothing to do with what was said.

Problem Framing

Extractive titling — picking the most representative sentence from a chapter — is deterministic and safe, but it produces clumsy labels: a title like “so the thing about the ingest queue is that it kept falling over under load” is faithful but unusable. A language model can compress that into “Ingest queue overload,” which is what belongs in the chapter list. The risk is the opposite failure: given a low-signal span (crosstalk, a long tangent, mostly filler), a model will happily produce a confident, fluent title about a topic that was never discussed, because a title is exactly the kind of high-frequency, plausible-sounding text a decoder is good at fabricating.

In a titling run those hallucinations look like titles that name entities absent from the span:

WARN  title_ground  ep=142  chapter=4  title="Kubernetes autoscaling deep dive"  span_has_terms=false
WARN  title_length  ep=142  chapter=6  title="A wide-ranging and thoughtful conversation about the future of..."  chars=78  max=72
INFO  title_accept  ep=142  chapter=1  title="Why we rebuilt the transcoder"  chars=29  grounded=true

Two defects, two fixes. Chapter 4’s title mentions “Kubernetes autoscaling” that appears nowhere in the span — a grounding failure that must be caught and rejected. Chapter 6’s title is a fluent non-title that blew the length limit — a constraint failure. The goal is a titler that produces the clean chapter-1 result, catches both bad cases, and degrades to a plain extractive title rather than publishing a fabrication.

Solution Architecture

The titler is a bounded, deterministic call per chapter with a validation gate behind it. Each chapter’s transcript span becomes a tightly constrained prompt that supplies the span text and forbids anything outside it. The model is called with settings that minimize variance — low effort, thinking disabled, a small output cap — so the same span produces the same title on every run. The returned title then passes a validator: it must fit the length and style limits and must be grounded — its content words have to appear in the span. A title that fails either check is discarded and replaced by the extractive fallback, so the pipeline never blocks and never ships an ungrounded title.

Chapter titling flow with a grounding-and-length gate and extractive fallback A chapter's transcript span is turned into a grounded, constrained prompt, which is sent to Claude. The returned title reaches a decision: is it grounded in the span and within the length and style limits? If it passes, the title is accepted. If it fails, the pipeline falls back to an extractive title drawn directly from the span. Chapter span transcript text Grounded prompt span + constraints Claude claude-sonnet-5 · temperature 0 grounded & within limits? pass fail Accept title write to chapters JSON Extractive fallback representative sentence

Because the call is per chapter and stateless, titling parallelizes trivially across a chapter list and across an episode back catalogue — and because the gate always has a deterministic fallback, a slow or failed model call degrades the title quality of one chapter without ever failing the run.

Implementation

The titler assembles a constrained prompt, calls Claude through the official Anthropic API using the anthropic Python SDK, and validates the result before accepting it. Determinism comes from setting temperature=0, a tight and unambiguous prompt, and a small output budget, which together make the output effectively stable across runs.

# anthropic==0.69.0   python 3.10+
import os
import re
from anthropic import Anthropic

client = Anthropic()  # reads ANTHROPIC_API_KEY from the environment

MODEL = "claude-sonnet-5"
MAX_TITLE_CHARS = 72

SYSTEM = (
    "You write chapter titles for podcast episodes. Given the transcript of one "
    "chapter, output a single title of at most 8 words that names only what is "
    "actually discussed in that transcript. Do not add topics, names, or facts "
    "that are not present in the text. No quotation marks, no trailing "
    "punctuation, no preamble — output the title and nothing else."
)

def build_prompt(span_text: str) -> str:
    # Cap the span so a long chapter can't blow the input budget; the head and
    # tail of a chapter carry most of its topical signal.
    span = span_text.strip()
    if len(span) > 6000:
        span = span[:3000] + " […] " + span[-3000:]
    return f"Chapter transcript:\n\n{span}\n\nChapter title:"

def title_chapter(span_text: str) -> str | None:
    """Return a grounded, constrained title, or None if the model output fails the gate."""
    resp = client.messages.create(
        model=MODEL,
        max_tokens=40,                        # a title needs very few tokens
        temperature=0,                        # greedy decoding for stable titles
        system=SYSTEM,
        messages=[{"role": "user", "content": build_prompt(span_text)}],
    )
    text_blocks = [b.text for b in resp.content if b.type == "text"]
    if not text_blocks:
        return None
    return clean_title(text_blocks[0])

def clean_title(raw: str) -> str:
    title = raw.strip().strip('"“”').rstrip(".!?")
    return re.sub(r"\s+", " ", title)

Grounding is the load-bearing safeguard. After the model returns a title, verify that its content words actually appear in the chapter span; a title whose informative words are absent from the transcript is a fabrication and is rejected. The extractive fallback then guarantees every chapter still gets a faithful title.

# python 3.10+
_STOP = {"the","a","an","and","or","of","to","in","on","for","with","how",
         "why","what","we","our","is","are","this","that","about"}

def content_words(text: str) -> set[str]:
    return {w for w in re.findall(r"[a-z']+", text.lower()) if w not in _STOP and len(w) > 2}

def is_grounded(title: str, span_text: str, min_overlap: float = 0.6) -> bool:
    tw = content_words(title)
    if not tw:
        return False
    span_words = content_words(span_text)
    covered = sum(1 for w in tw if w in span_words)
    return covered / len(tw) >= min_overlap

def extractive_title(span_text: str) -> str:
    # Deterministic fallback: the first reasonably long sentence, truncated.
    sentences = re.split(r"(?<=[.!?])\s+", span_text.strip())
    pick = next((s for s in sentences if len(s) > 25), sentences[0] if sentences else "Chapter")
    return clean_title(pick)[:MAX_TITLE_CHARS]

def resolve_title(span_text: str) -> tuple[str, bool]:
    """Return (title, llm_used). Falls back to extractive on any failure."""
    candidate = title_chapter(span_text)
    if candidate and len(candidate) <= MAX_TITLE_CHARS and is_grounded(candidate, span_text):
        return candidate, True
    return extractive_title(span_text), False

For a full episode, title the chapters concurrently — the calls are independent — and for a large back catalogue, submit them through the Message Batches API at half price rather than one synchronous call each.

# python 3.10+
from concurrent.futures import ThreadPoolExecutor

def title_all(spans: list[str], workers: int = 8) -> list[tuple[str, bool]]:
    with ThreadPoolExecutor(max_workers=workers) as pool:
        return list(pool.map(resolve_title, spans))

Verification

Confirm the titler produces grounded, in-spec titles and that the fallback engages on low-signal input.

# 1. A clean span yields a short, grounded, LLM-generated title.
python -c "
from titler import resolve_title
t, used = resolve_title(open('span_clean.txt').read())
print(repr(t), 'llm=', used, 'len=', len(t))
assert used and len(t) <= 72
"

# 2. A low-signal span (filler / crosstalk) must fall back, never fabricate.
python -c "
from titler import resolve_title
t, used = resolve_title('um yeah so anyway right exactly totally yeah for sure')
print(repr(t), 'llm=', used)
assert used is False   # grounding gate rejected any invented title
"

# 3. Every emitted title fits the length limit across a whole episode.
jq -e 'all(.[]; (.title | length) <= 72 and (.title | length) > 0)' chapters.json

A healthy run shows clean spans producing concise generated titles, low-signal spans falling back to an extractive sentence rather than a confident fabrication, and every title within the 72-character limit. Export the fallback rate to your metrics stack — a rising chap_titler_fallback_total means either the grounding threshold is too strict or the upstream boundaries are cutting chapters mid-topic.

Failure Modes & Edge Cases

Edge case Symptom Remediation
Ungrounded title on a weak span Title names entities absent from the transcript Keep the grounding gate; lower min_overlap only if faithful titles over-reject
Title exceeds the length limit Fluent non-title over 72 chars Reject on length and fall back; tighten the “at most 8 words” system instruction
Grounding too strict Good paraphrases rejected, fallback rate spikes Lower min_overlap toward 0.5; expand the stop-word set so function words don’t count
Model returns a preamble Title prefixed with “Here is the title:” The system prompt forbids preamble; clean_title also strips quotes and trailing punctuation
Very long chapter span Input budget blown, latency climbs Truncate to head + tail (already done in build_prompt); topical signal lives at the edges
Non-English chapter Title generated in the wrong language Add a language hint to the system prompt from the transcript’s detected language
API call fails or times out One chapter has no LLM title resolve_title returns the extractive fallback; the run never blocks

FAQ

How do I get deterministic titles without a temperature parameter?

Current Claude models do not accept temperature, top_p, or top_k — sending them returns an error — so determinism comes from constraining the task instead of the sampler. A tight system prompt that fixes the length and forbids anything outside the transcript, a small max_tokens budget, thinking disabled, and effort set to low together leave the model almost no latitude, so the same chapter span produces the same title run to run. For a scoped task like titling, that is more reliable than a temperature knob ever was.

What stops the model from inventing a title about something never discussed?

The grounding gate. After the model returns a title, its content words are checked against the words actually present in the chapter span, and a title whose informative words are largely absent is discarded as a fabrication. This is a deterministic post-check that does not trust the model's fluency — a confident, well-formed title is exactly what a decoder produces over a low-signal span, so the pipeline verifies rather than assumes, and replaces any ungrounded title with the extractive fallback.

Is this different from mapping boundaries to timestamps?

Yes — they are two distinct stages. Mapping boundaries to timestamps converts a topic boundary into a precise chapter start time and is purely arithmetic; this stage takes the resulting chapter span and produces its title, which is a summarization problem. Boundary mapping decides when a chapter starts; titling decides what it is called. The two never overlap: the titler receives fully-placed spans and only writes the title field of each chapter object.