A producer forwards one attachment. It is 94 pages: an ACORD 125, an ACORD 126, two carrier loss runs, a driver schedule, a workers' comp supplemental, and, at the back, 11 pages of a tenant lease that nobody asked for. Somebody scanned the whole pile on one pass and the AMS has it filed as Scan_20260114.pdf.
Every extraction tutorial starts one step too late. Textract Queries on an ACORD 125 work well when you hand Textract an ACORD 125. Hand it a 94-page merge and the named-insured query answers from whichever page happened to look most like a form, and you will not know which one. Before you extract anything, you have to cut the file into documents and label each piece.
This tutorial builds that step: page-level classification, boundary detection, and a filing decision, with an explicit low-confidence path to a human. It assumes Python, an AWS account with Textract and Bedrock access, and pypdf.
Classify pages, then find boundaries
The instinct is to classify documents. You cannot: you do not know where the documents are yet. So invert it. Label every page independently, then infer boundaries from the sequence of labels.
That ordering matters because commercial submission packets are dense with repeated headers and footers, and a page-level label is a small, testable unit. A misread page is one wrong label you can see in a review queue, not a silently mis-split document.
Start by rendering per-page text. Textract's AnalyzeDocument with the LAYOUT feature gives you reading order and titles, which are the two things that distinguish an ACORD header block from a loss-run table:
import io
import boto3
from pypdf import PdfReader, PdfWriter
textract = boto3.client("textract")
def page_texts(pdf_path: str) -> list[str]:
reader = PdfReader(pdf_path)
out = []
for i in range(len(reader.pages)):
writer = PdfWriter()
writer.add_page(reader.pages[i])
buf = io.BytesIO()
writer.write(buf)
resp = textract.analyze_document(
Document={"Bytes": buf.getvalue()},
FeatureTypes=["LAYOUT"],
)
lines = [
b["Text"]
for b in resp["Blocks"]
if b["BlockType"] == "LINE"
]
out.append("\n".join(lines[:60]))
return out
Sixty lines is deliberate. The identifying marks of a submission page are at the top: form number, carrier name, column headers. Sending the full page costs more and classifies no better.
A label set you can defend
Keep the label set small and tied to what your pipeline does next. Ours, for a commercial packet:
| Label | What it is | Next step |
|---|---|---|
acord_app | ACORD 125/126/130/140 and similar | Field extraction, AMS submission build |
loss_run | Carrier loss listing | Claims parsing, loss summary |
supplemental | Class- or carrier-specific questionnaire | Gap list |
schedule | SOV, driver, or equipment schedule | Schedule normalization |
policy_doc | Expiring policy, dec page, endorsements | Endorsement checks |
correspondence | Emails, cover letters, fax cover sheets | File, no extraction |
other | Leases, financials, anything unrecognized | Human decides |
other is the point of the whole design. A classifier with no escape hatch guesses, and a guess on page 74 of a packet is the kind of thing nobody finds until a producer asks why the submission was short a schedule.
Classify with a model call per page, forced into a fixed shape:
LABELS = [
"acord_app", "loss_run", "supplemental", "schedule",
"policy_doc", "correspondence", "other",
]
SCHEMA = {
"type": "object",
"properties": {
"label": {"type": "string", "enum": LABELS},
"form_number": {"type": ["string", "null"]},
"carrier": {"type": ["string", "null"]},
"continues_previous": {"type": "boolean"},
"confidence": {"type": "number"},
},
"required": ["label", "continues_previous", "confidence"],
}
continues_previous is the field that does the splitting work. Ask the model whether this page is a continuation of the page before it, given both page texts, and you get boundaries for free: a new document starts wherever continues_previous is false or the label changes.
def segments(pages: list[dict]) -> list[dict]:
segs = []
for i, p in enumerate(pages):
new_doc = (
i == 0
or not p["continues_previous"]
or p["label"] != pages[i - 1]["label"]
)
if new_doc:
segs.append({"label": p["label"], "pages": [i], "scores": [p["confidence"]]})
else:
segs[-1]["pages"].append(i)
segs[-1]["scores"].append(p["confidence"])
return segs
Two consecutive loss runs from different carriers is the case this rule gets wrong, because the label does not change. Use the carrier field as a secondary boundary signal: same label, different carrier, new document. Page numbering inside the document ("Page 1 of 6") is the other cheap signal, and it is often in the first 60 lines you already captured.
Confidence rules, not a confidence number
A segment's score is the minimum of its page scores, not the average. One uncertain page in a six-page loss run is a reason to look at the loss run.
Three routes, and write them down before you tune anything:
- Split and extract. Every page at or above your threshold, label consistent across the segment. The segment goes to its extractor.
- Split and queue. Boundaries look right, label is uncertain. File the piece, hold the extraction, ask a human for the label.
- Do not split. More than about a fifth of pages below threshold, or a segment of one page between two long documents. Hand the original file to a person. A packet you cannot read confidently is not a packet to guess at.
Log the per-page label, score, and the boundary reason for every run. When a CSR says the splitter got it wrong, that log is the difference between a fix and an argument.
File the pieces where the work happens
Split with pypdf, name deterministically, and write back into the AMS attached to the account or the opportunity, not to a folder in your own system:
def write_segment(reader, seg, idx, out_dir, submission_id):
writer = PdfWriter()
for page_no in seg["pages"]:
writer.add_page(reader.pages[page_no])
name = f"{submission_id}_{idx:02d}_{seg['label']}_p{seg['pages'][0]+1}-{seg['pages'][-1]+1}.pdf"
with open(f"{out_dir}/{name}", "wb") as fh:
writer.write(fh)
return name
The page range in the filename is not cosmetic. It lets anyone open the original and check the split by eye in about five seconds, and it makes the write-back idempotent: same submission, same segmentation, same names, so a re-run overwrites rather than doubling the attachment count. That idempotency rule is the same one described in writing agent output back to the AMS without duplicates.
Keep the original file too, unsplit and untouched. The split is derived data. If your labels change in six months, you want to re-derive rather than reconstruct.
What to check before you trust it
Hold back 30 real packets from the agency's own book, spanning the carriers and the scanner they actually use, and measure two things separately: page label accuracy and boundary accuracy. They fail differently. Label errors send a document to the wrong extractor, which is usually loud. Boundary errors staple two documents together, which is quiet and worse, because extraction then runs on a file that is half loss run.
And the splitter is not the decision-maker. It routes paper. The gap list that goes back to the producer, and anything that leaves the agency, still gets approved by a licensed person, which is how our Quote-Intake Agent work is built and how the silent run scoring is judged.
If you are handling merged submission packets by hand and want a senior engineer to build this against your own AMS, contact us.