A 200-page annual report will not fit into a single API request, and pasting the first fifty pages into a chat window gives you a confident summary of a quarter of the document. This guide builds a script that handles the whole thing: it reads the PDF page by page, summarises each slice separately, merges those slices into one brief, and keeps a page number attached to every claim so you can check the original. Give it about an hour and you will have a reusable tool that turns any length of PDF into a one-page brief you can actually trust.
This guide is part of AI Document Processing with Python, the section on getting structured, reliable output out of business documents. If you want fixed fields rather than prose, Extract Invoice Data from PDFs with Python and AI covers that job instead.
Prerequisites
You need Python 3.10 or newer and an OpenAI API key. Work inside a virtual environment — an isolated folder holding this project's packages — so these installs do not collide with anything else on your machine. If you have not made one, follow Create a Python Virtual Environment for AI first.
python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install "pdfplumber>=0.11" "openai>=1.40" "tiktoken>=0.7" "python-dotenv>=1.0"
pip freeze > requirements.txt
Put your key in a .env file beside your script:
OPENAI_API_KEY=sk-your-key-here
Then keep that file out of version control:
echo ".env" >> .gitignore
A key committed to a public repository gets scraped and spent by strangers, often within the hour, so run that line before your first commit.
Finally, drop a PDF called report.pdf in the same folder. Any long text-based document works — an annual report, a research paper, a policy manual.
Step 1 — Pull the text out one page at a time
Most PDF tutorials read the whole document into a single string. Do not do that here. The page number is the only thing that will let a reader verify a claim later, and once you flatten 200 pages into one blob that information is gone forever.
pdfplumber gives you a list of page objects, so keep the number alongside the text from the start. Pages that hold only images return None from extract_text(), which is why the or "" below matters — without it the script crashes halfway through a long document.
from pathlib import Path
import pdfplumber
def extract_pages(pdf_path: str) -> list[dict]:
"""Return [{'page': 1, 'text': '...'}, ...] skipping pages with no text."""
pages = []
with pdfplumber.open(pdf_path) as pdf:
for number, page in enumerate(pdf.pages, start=1):
text = (page.extract_text() or "").strip()
if text:
pages.append({"page": number, "text": text})
return pages
if __name__ == "__main__":
pages = extract_pages("report.pdf")
print(f"{len(pages)} pages contain text")
print(pages[0]["text"][:300])
Run it with python extract.py. If the page count is far lower than the real document, your PDF is scanned images rather than digital text and needs optical character recognition before any of this works.
That page-tagged list is the input to everything that follows. The full pipeline has five stops, and it loops back on itself when the merged result is still too long for one request.
Step 2 — Group pages into token-sized chunks
Models measure input in tokens, not pages — a token is a fragment of text roughly three quarters of a word. A page of dense prose is often 400 to 600 tokens, but a page of tables can be far more, so counting pages is a poor proxy. Count tokens directly with tiktoken, the same tokeniser OpenAI models use.
The rule for grouping is simple: add whole pages to the current chunk until adding the next one would push it over your limit, then start a new chunk. Never split mid-page, because then a bullet's page citation becomes ambiguous. Each finished chunk carries its first and last page number and embeds a [page N] marker before each page's text, which is what the model will quote back to you.
import tiktoken
ENCODER = tiktoken.get_encoding("o200k_base")
def count_tokens(text: str) -> int:
return len(ENCODER.encode(text))
def _finish(pages: list[dict]) -> dict:
body = "\n\n".join(f"[page {p['page']}]\n{p['text']}" for p in pages)
return {
"first_page": pages[0]["page"],
"last_page": pages[-1]["page"],
"text": body,
}
def build_chunks(pages: list[dict], max_tokens: int = 3000) -> list[dict]:
"""Group whole pages into chunks of at most max_tokens each."""
chunks: list[dict] = []
current: list[dict] = []
total = 0
for page in pages:
size = count_tokens(page["text"])
if current and total + size > max_tokens:
chunks.append(_finish(current))
current, total = [], 0
current.append(page)
total += size
if current:
chunks.append(_finish(current))
return chunks
One page can exceed max_tokens on its own — a fold-out table, usually. This code lets that page through as an oversized chunk rather than mangling it; if your document is full of them, lower max_tokens or split that page's text on paragraph boundaries. Split Long Text into Chunks for AI APIs goes deeper on splitting strategies, and Count Tokens in Python Before You Send explains the counting side.
Step 3 — Summarise each chunk with a prompt that forbids invention
This is the map half of map-reduce: the same operation applied independently to every chunk. Three instructions do most of the work here. Tell the model to use only what is in front of it. Tell it to write bullets, not paragraphs, because bullets survive merging far better than prose. And tell it to end each bullet with the page it came from.
Set temperature=0 as well. Temperature controls how much randomness the model injects; at zero it sticks closest to the supplied text, which is exactly what you want for factual extraction. What Is Temperature in an LLM API? covers the parameter in full.
import os
import time
from dotenv import load_dotenv
from openai import OpenAI, RateLimitError
load_dotenv() # reads .env, which is listed in .gitignore
client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))
CHUNK_SYSTEM = (
"You summarise one slice of a longer report.\n"
"Use only facts stated in the supplied text. Do not add background "
"knowledge, do not infer, do not guess at anything the text omits.\n"
"Write 3 to 6 bullet points. Keep every number, date and name exactly "
"as written.\n"
"End each bullet with the page it came from, in the form (p. 42). The "
"text is marked with [page N] headers; use those numbers."
)
def summarise_chunk(chunk: dict, model: str = "gpt-4o-mini") -> str:
for attempt in range(4):
try:
response = client.chat.completions.create(
model=model,
temperature=0,
messages=[
{"role": "system", "content": CHUNK_SYSTEM},
{"role": "user", "content": chunk["text"]},
],
)
return response.choices[0].message.content.strip()
except RateLimitError:
time.sleep(2 ** attempt)
raise RuntimeError("Rate limited four times in a row; slow the loop down.")
def summarise_all(chunks: list[dict]) -> list[str]:
summaries = []
for index, chunk in enumerate(chunks, start=1):
print(f"summarising chunk {index}/{len(chunks)} "
f"(pages {chunk['first_page']}-{chunk['last_page']})")
summaries.append(summarise_chunk(chunk))
return summaries
The retry loop matters more than it looks. Thirty-odd calls fired back to back will trip a rate limit on a new account, and one unhandled error two thirds of the way through a long report means starting over. Fix the 429 Rate-Limit Error in Python explains the backoff pattern in detail. For a document you will process repeatedly, cache each chunk summary to a file keyed by the chunk's page range so a rerun costs nothing.
Step 4 — Merge the summaries, and repeat when the merge is too long
This is the reduce half. Joining forty chunk summaries can easily produce more text than a single request will take, so the merge itself has to be chunked — and the result of that merge might still be too long, which is why the function loops.
The merge prompt has a different job from the summarise prompt. It is not reading a report; it is reading notes about a report. Its instructions are to group related points, drop exact duplicates, and above all carry the (p. N) markers through untouched. Losing the citations at this stage is the single most common way this pipeline goes wrong.
MERGE_SYSTEM = (
"You merge bullet-point notes taken from different parts of one report.\n"
"Group related points together and remove exact duplicates. Keep every "
"number and every page citation such as (p. 42) exactly as written.\n"
"If two notes disagree, keep both and mark the disagreement.\n"
"Never add a fact that is not already in the notes."
)
def merge_group(parts: list[str], model: str = "gpt-4o-mini") -> str:
response = client.chat.completions.create(
model=model,
temperature=0,
messages=[
{"role": "system", "content": MERGE_SYSTEM},
{"role": "user", "content": "\n\n".join(parts)},
],
)
return response.choices[0].message.content.strip()
def group_by_tokens(parts: list[str], max_tokens: int) -> list[list[str]]:
groups: list[list[str]] = []
current: list[str] = []
total = 0
for part in parts:
size = count_tokens(part)
if current and total + size > max_tokens:
groups.append(current)
current, total = [], 0
current.append(part)
total += size
if current:
groups.append(current)
return groups
def reduce_until_short(summaries: list[str], max_tokens: int = 3000,
max_rounds: int = 4) -> str:
"""Merge repeatedly until one summary fits inside max_tokens."""
level = summaries
for round_number in range(1, max_rounds + 1):
if len(level) == 1 and count_tokens(level[0]) <= max_tokens:
break
groups = group_by_tokens(level, max_tokens)
print(f"merge round {round_number}: {len(level)} notes -> {len(groups)}")
level = [merge_group(group) for group in groups]
return "\n\n".join(level)
On a 200-page report with 3,000-token chunks you will typically see two merge rounds: forty-odd chunk summaries collapse into three or four grouped summaries, and those collapse into one. Each round is one more layer of compression, which is exactly the problem the next section addresses. This staged prompting is a general technique — Chain Prompts Together in Python shows other shapes it takes.
Step 5 — Write the final structured brief
The merged text is accurate but shapeless. One last call gives it a form a human will actually read: a short executive summary, the key figures pulled out as their own list, and any contradictions flagged for follow-up. Asking for a fixed structure here also makes the output diffable — run the same script on next quarter's report and you can compare section by section. Write System Prompts that Control Output Format covers how to make that structure stick.
BRIEF_SYSTEM = (
"You turn merged notes into a one-page brief in Markdown.\n"
"Use exactly these headings: ## Executive summary, ## Key figures, "
"## Open questions.\n"
"The executive summary is one paragraph of at most 120 words.\n"
"Key figures is a bullet list of the concrete numbers, each with its "
"page citation.\n"
"Open questions lists anything the notes contradict or leave unclear.\n"
"Keep every (p. N) citation. Add nothing that is not in the notes."
)
def write_brief(merged: str, title: str, model: str = "gpt-4o") -> str:
response = client.chat.completions.create(
model=model,
temperature=0,
messages=[
{"role": "system", "content": BRIEF_SYSTEM},
{"role": "user", "content": f"Report: {title}\n\n{merged}"},
],
)
return response.choices[0].message.content.strip()
if __name__ == "__main__":
pages = extract_pages("report.pdf")
chunks = build_chunks(pages, max_tokens=3000)
print(f"{len(pages)} pages -> {len(chunks)} chunks")
summaries = summarise_all(chunks)
merged = reduce_until_short(summaries)
brief = write_brief(merged, title="report.pdf")
Path("brief.md").write_text(brief, encoding="utf-8")
Path("notes.md").write_text(merged, encoding="utf-8")
print("wrote brief.md and notes.md")
Note the model change on this final call. The cheap model is fine for the repetitive chunk work, but the last pass is one call over a small amount of text and it decides how the whole thing reads, so paying for the stronger model here costs very little. Writing notes.md as well as brief.md is deliberate: the notes are your middle layer, and when a figure in the brief looks wrong the notes tell you which chunk it came from.
What each pass loses, and why the citations save you
Every summarisation pass is lossy compression. The first pass turns six pages into five bullets and throws away the wording, the caveats and the small examples. The second pass turns eight sets of bullets into one set and throws away whatever did not repeat. This is drift: the output stays fluent and confident while drifting further from what the document actually said. Nothing in the API warns you it is happening.
You cannot eliminate drift, but you can make it survivable. Keep the number of passes as low as the token budget allows. Ask explicitly for numbers, dates and names, because those are the first things generic summarisation discards. And carry a page citation on every claim, so a reader who doubts a line can open that page and settle it in ten seconds. A brief without citations is a claim about a document; a brief with citations is a map into it.
Chunk size quick reference
The numbers below assume a report of roughly 100,000 tokens, which is what a 200-page document of ordinary prose comes to. Measure your own with count_tokens before choosing.
| Chunk size | Chunk calls | Merge rounds | Best for |
|---|---|---|---|
| 1,500 tokens | ~67 | 2-3 | Dense contracts where small clauses matter |
| 3,000 tokens | ~34 | 2 | The sensible default for most reports |
| 8,000 tokens | ~13 | 1 | Narrative documents; fewer calls, flatter bullets |
| 20,000 tokens | ~5 | 1 | Near single-call behaviour with light chunking |
Fewer, larger chunks cost less and drift less at the merge stage, but each individual summary compresses harder. Smaller chunks preserve detail per section and then lose it again in the extra merge round. Three thousand tokens sits near the middle for a reason.
Troubleshooting
AttributeError: 'NoneType' object has no attribute 'strip'—extract_text()returnedNoneon an image-only page. The(page.extract_text() or "")in Step 1 fixes it; if you removed that guard, put it back.- Every page comes back empty — the PDF is a scan, so there is no text layer to extract. You need optical character recognition first;
pdfplumbercannot read pixels. openai.BadRequestError: ... maximum context length ...— one chunk is bigger than the model's input limit, almost always a single enormous page. Lowermax_tokensinbuild_chunks, and see Fix the Context-Length-Exceeded Error in Python.openai.RateLimitError— the chunk loop is firing faster than your account allows. The backoff insummarise_chunkabsorbs short bursts; for very long documents add atime.sleep(0.5)between chunks.- Citations vanish after the merge — the merge prompt is being ignored because the notes arrived without visible
(p. N)markers. Print one chunk summary and confirm the map pass is producing them before blaming the reduce pass.
When to use chunking vs. a single long-context call
- Use one long call when the whole document fits inside the model's input limit and you want maximum coherence. Nothing is merged, so nothing is lost twice, and the model can connect a point on page 12 to a caveat on page 180.
- Use chunking when the document is larger than the limit, when you want a per-section summary as well as an overall one, or when you need every claim traceable to a page. It also fails gracefully: one bad chunk costs you one bullet list, not the whole run.
- Use chunking for cost control on repeat work, because chunk summaries are cacheable and a single giant call is not. Estimate OpenAI API Costs with Python helps you compare the two before you commit.
You now have a script that survives any document length: extraction that keeps page numbers, chunking measured in tokens, a map pass that refuses to invent, a reduce pass that repeats until the result fits, and a final brief that carries its evidence with it. Point it at a report you already know well and read the brief critically — the citations make that check quick, and the mistakes you find will tell you which prompt to tighten. When you want to ask specific questions of the same PDF rather than summarise it whole, Build a Document Question-Answering Tool is the next step.
Back to AI Document Processing with Python.
Related guides
- AI Document Processing with Python — the main guide for this section, covering the whole document pipeline.
- Split Long Text into Chunks for AI APIs — chunking strategies beyond page boundaries.
- Extract Invoice Data from PDFs with Python and AI — when you need fixed fields instead of prose.
- Classify Incoming Documents with Python and AI — route each PDF to the right handler before summarising it.
- Schedule Python AI Jobs with GitHub Actions — run this script automatically when a new report lands.