Every commercial submission needs five years of loss history, and every carrier prints it differently. Travelers gives you a clean table. A regional carrier gives you a fax-quality scan with the claimant name wrapped onto a second line. The MGA gives you a spreadsheet export with a merged header row. The producer needs one number out of all of it: incurred by policy year, plus anything open that an underwriter will ask about.
We covered pulling named fields off ACORD forms with Textract Queries and fetching documents out of a carrier portal. Loss runs are a different problem. There is no form number, no fixed field position, and the unit of extraction is a row, not a field. This tutorial builds the middle piece for a Quote-Intake Agent: PDF in, validated claim rows out, with a gap list for the producer when the document does not support a number.
You need an AWS account with Textract access, Python 3.11, and three or four real loss runs from different carriers. Do not build this against one carrier's format. The second carrier is where the design gets decided.
What "structured" has to mean here
Decide the target schema before you touch the document. Ours is deliberately small, because every field you add is a field you have to validate:
from dataclasses import dataclass
from datetime import date
from decimal import Decimal
@dataclass
class ClaimRow:
claim_number: str | None
date_of_loss: date | None
status: str # open | closed | reopened | unknown
cause: str | None # carrier's own wording, not normalized
paid_indemnity: Decimal | None
paid_expense: Decimal | None
reserve: Decimal | None
incurred: Decimal | None
page: int
row_index: int
confidence: float
Two things in that schema matter more than the rest.
cause keeps the carrier's own wording. Mapping "SLIP/FALL - CUST" to a normalized cause code feels like the valuable part, and it is where a document agent quietly invents things. The producer reads the carrier's words fine.
page and row_index are not metadata. They are how a CSR checks a disputed number in eight seconds instead of re-reading the PDF. Every row we emit points back at where it came from.
Step 1: classify the document before you parse it
A "loss run" in the shared mailbox is often a no-loss letter, a claims acknowledgment, or the third page of a renewal proposal. Parsing those produces zero rows, which downstream code reads as "no claims" and the submission goes out clean when it should not.
So classify first, on the first page's text:
def classify(page_text: str) -> str:
t = page_text.lower()
if "no losses" in t or "no claims" in t or "loss free" in t:
return "no_loss_letter"
if any(k in t for k in ("claim number", "claim no", "date of loss", "incurred")):
return "loss_run"
return "unknown"
Keyword matching is fine here and easier to defend than a model call. Three outcomes, three paths: loss_run goes to extraction, no_loss_letter produces an explicit zero-claim result the producer still has to confirm, unknown goes on the gap list and stops.
Step 2: extract tables, not text
Run Textract with TABLES and FORMS. Text-only extraction destroys column alignment on exactly the documents you most need it for.
import boto3
textract = boto3.client("textract")
def analyze(bucket: str, key: str) -> dict:
job = textract.start_document_analysis(
DocumentLocation={"S3Object": {"Bucket": bucket, "Name": key}},
FeatureTypes=["TABLES", "FORMS"],
)
return job["JobId"]
Use the async API even for short documents. A six-page loss run is fine synchronously until the day a carrier sends forty pages of workers' comp history and the sync call fails at page limits.
Reassembling table blocks into rows is mechanical: walk BlockType == "TABLE", follow CHILD relationships to CELL, and index cells by RowIndex / ColumnIndex. Keep the raw cell text. Do not strip currency symbols yet; a leading ( is the difference between a reserve and a recovery.
Step 3: map columns once per carrier layout
Header wording varies more than the data does. Across a dozen carriers you will see Incurred, Total Incurred, Net Incurred, and Ttl Inc for the same column. Map headers to your schema with a synonym table, and log every header you could not map:
HEADER_MAP = {
"claim number": "claim_number", "claim no": "claim_number", "claim #": "claim_number",
"date of loss": "date_of_loss", "loss date": "date_of_loss", "dol": "date_of_loss",
"status": "status", "claim status": "status", "open/closed": "status",
"paid": "paid_indemnity", "paid indemnity": "paid_indemnity", "indemnity paid": "paid_indemnity",
"expense": "paid_expense", "paid expense": "paid_expense", "alae": "paid_expense",
"reserve": "reserve", "outstanding": "reserve", "o/s reserve": "reserve",
"incurred": "incurred", "total incurred": "incurred", "net incurred": "incurred",
}
def map_headers(header_cells: list[str]) -> dict[int, str]:
mapping = {}
for i, cell in enumerate(header_cells):
key = " ".join(cell.lower().split()).strip(":")
if key in HEADER_MAP:
mapping[i] = HEADER_MAP[key]
else:
log_unmapped_header(key)
return mapping
The unmapped-header log is the maintenance surface. After three months it tells you exactly which synonyms to add, and it is the reason this stays deterministic instead of drifting into a model call per document.
Use a model only for the residue: a scanned run whose headers never resolved, or a layout where a claim spans two visual rows. Send that page's cells with the schema attached, constrain the output to JSON, and hold the result to the same validation as everything else. The controls in defending a document agent against prompt injection apply here without modification; a loss run is an untrusted document from outside the agency.
Step 4: validate with arithmetic the carrier already did
This is the step that separates a parser you can put in front of a producer from a demo. Loss runs are internally redundant, so check the math:
def validate(row: ClaimRow) -> list[str]:
problems = []
parts = [row.paid_indemnity, row.paid_expense, row.reserve]
if row.incurred is not None and all(p is not None for p in parts):
if abs(sum(parts) - row.incurred) > Decimal("1.00"):
problems.append("incurred does not equal paid + expense + reserve")
if row.status == "closed" and row.reserve and row.reserve > 0:
problems.append("closed claim carries an open reserve")
if row.date_of_loss and row.date_of_loss > date.today():
problems.append("date of loss in the future")
if row.claim_number is None:
problems.append("no claim number")
return problems
A row that fails is not discarded and it is not silently fixed. It is emitted with its problems attached and marked for review. Column-shift errors — the classic failure where a wrapped claimant name pushes every dollar figure one column left — show up here as an arithmetic mismatch on every row of a page, which is a much louder signal than a slightly wrong total.
Then check the document against itself. If the loss run prints a total incurred, compare it to the sum of your rows. If they disagree, the document-level result is needs_review, no matter how confident the individual rows look.
Step 5: roll up by policy year, and say what you could not read
The producer wants incurred by policy year, claim count, open claim count, and largest single claim. Bucket by policy term dates from the AMS, not by calendar year, or a 7/1 effective date will split every year's losses in half.
The output object we hand to the Quote-Intake Agent has three parts: the rolled-up figures, the claim rows behind them, and a gaps list. Gaps are literal:
pages 4-5 unreadable, scan quality2 rows failed the incurred check on page 3carrier total 148,200, our sum 131,900loss run covers 3 years, submission needs 5
That last one is worth building deliberately. Missing years is the most common real problem with a loss run packet, and it is invisible in any output that only reports what it found.
The agent writes this to the submission in the AMS and stops. It does not decide the account is clean, it does not fill the ACORD 125 loss section, and it does not email the underwriter. A licensed person reads the gap list, works the exceptions, and approves. When the roll-up is wrong, it is wrong in a place a person was already looking.
What this does not solve
Handwritten annotations in the margin. Loss runs where the carrier's own totals are wrong, which happens, and where the right answer is a call to the claims unit. Subrogation recoveries printed as a footnote rather than a column. Workers' comp runs that report both a claim-level and a class-level view on the same page.
Each of those is a specific carrier and a specific format, and each takes an afternoon once you have the row model and the validation harness above. What you should not do is push them into a prompt and hope. A parser that abstains on 6% of pages and is arithmetically checked on the other 94% is worth more to an agency than one that returns a number for everything.
Building submission intake on Applied Epic, HawkSoft, or EZLynx? Tell us your AMS and where the keying time goes. A senior engineer replies within one business day.