AI EngineeringZero to ProductionHome·About·Contact
AWS AI Automation · Chapter W10

Textract & Comprehend

Textract (OCR, forms, tables) and Comprehend (entities, PII, sentiment) are cheap, deterministic AI APIs. Chain Textract → Claude for structured extraction beyond fixed fields.

⏱️ ~2 hours🧪 3 labs🎯 Intermediate
⚙️ To run this for realThe code here is complete and correct as written. To actually execute it you'll need:
  • AWS credentials (aws configure) + Bedrock model access enabled in your region + pip install boto3
  • AWS credentials (aws configure) + pip install boto3
Reading and learning works without any of this — run when you're ready. IDs/ARNs in examples are placeholders; swap in your own.

Learning objectives

  • Extract text, forms, and tables from documents with Textract.
  • Pull entities, PII, key phrases, and sentiment with Comprehend.
  • Chain Textract → Claude-on-Bedrock for structured extraction beyond fixed fields.
  • Recognize when a pre-built AI service beats an LLM (and vice versa).
▶ Runnable companionEvery code block in this lesson is also a standalone file under code/aws10-docs-textract-comprehend/ in the course, with a README on how to run it. Read here, run there — no need to copy-paste.

Pre-built AI services: no ML required intermediate

Textract and Comprehend are pay-per-use APIs that solve narrow problems extremely well: OCR and NLP. They are cheaper and more deterministic than an LLM for what they do. The modern pattern is a hybrid: Textract turns a PDF into text, then Claude does the flexible reasoning the fixed-function API cannot.

Textract: OCR, forms, tables intermediate

Example code for learning — review, test, and adapt it before running against real or production systems. Commands can create, change, or delete resources. See the Terms & Disclaimer.
Lab W10.1
textract.pyimport boto3
tx = boto3.client("textract", region_name="us-east-1")

# analyze_document with FORMS + TABLES extracts key/value pairs and cells,
# not just raw text. Point it at a document already in S3.
resp = tx.analyze_document(
    Document={"S3Object": {"Bucket": "my-docs", "Name": "invoice.pdf"}},
    FeatureTypes=["FORMS", "TABLES"],
)
# blocks are a graph; pull the raw lines as a quick check
lines = [b["Text"] for b in resp["Blocks"] if b["BlockType"] == "LINE"]
print("\n".join(lines[:5]))
▶ How this works

Textract is Amazon's OCR (optical character recognition) service: give it a scanned page or PDF and it reads the text back out — and with the right options it also understands forms (label/value pairs like "Total: $99") and tables (rows and columns). This lab points Textract at a file sitting in S3 and prints the first few lines it read.

  1. tx = boto3.client("textract", ...)boto3 is the AWS SDK for Python. This line builds a client object you use to call the Textract service in the us-east-1 region. (It needs AWS credentials to actually run.)
  2. tx.analyze_document(...) sends the request. Document={"S3Object": ...} tells Textract which file to read — here invoice.pdf in the bucket my-docs. You don't upload the file in the call; it's already in S3.
  3. FeatureTypes=["FORMS", "TABLES"] asks for the smart extraction — key/value pairs and table cells — not just loose text. Leave it out and you only get raw lines.
  4. The reply, resp, is a big graph of blocks. The last line is a list comprehension: it walks every block b and keeps b["Text"] only where b["BlockType"] == "LINE" — i.e. just the text lines — then lines[:5] takes the first five to print as a sanity check.

What the output means: You'd see the first five lines of text Textract found on the page, one per line — proof the OCR worked. The forms and tables are also in resp, waiting to be pulled out.

Try this: Change lines[:5] to lines[:20] to preview more, or drop FeatureTypes entirely and notice you can still read raw LINE blocks but lose the form/table structure.

Async for big documentsanalyze_document is synchronous (single page-ish). For multi-page PDFs use start_document_analysis → poll get_document_analysis. W13 wires that into an event-driven pipeline so you never poll by hand.

Comprehend: entities, PII, sentiment advanced

Lab W10.2
comprehend.pyimport boto3
cp = boto3.client("comprehend", region_name="us-east-1")
text = "Contact Jane Doe at jane@acme.com about the Seattle outage. Very frustrated."

print(cp.detect_sentiment(Text=text, LanguageCode="en")["Sentiment"])
print([e["Type"] for e in cp.detect_pii_entities(Text=text, LanguageCode="en")["Entities"]])
print([(e["Text"], e["Type"]) for e in cp.detect_entities(Text=text, LanguageCode="en")["Entities"]])
NEGATIVE
['NAME', 'EMAIL']
[('Jane Doe', 'PERSON'), ('Seattle', 'LOCATION')]
▶ How this works

Comprehend is Amazon's NLP (natural-language processing) service. Instead of reading pixels like Textract, it reads meaning out of text you already have: is it positive or negative? Does it contain personal data (PII)? What people, places, and things are mentioned? This lab runs one sample sentence through three different Comprehend calls.

  1. cp = boto3.client("comprehend", ...) makes the client for the Comprehend service, just like the Textract client in the last lab.
  2. text = "Contact Jane Doe at jane@acme.com ..." is the sentence we analyze. It was deliberately written to contain a name, an email, a place, and an emotion.
  3. cp.detect_sentiment(Text=text, LanguageCode="en") returns an overall mood; ["Sentiment"] pulls just the label (e.g. POSITIVE/NEGATIVE) out of the reply.
  4. detect_pii_entities(...) finds personal data — the list comprehension keeps each hit's ["Type"] (like NAME, EMAIL). detect_entities(...) finds real-world things and keeps a (text, type) pair for each, e.g. a person or a location.

What the output means: Three lines print. NEGATIVE is the sentiment ("Very frustrated"). ['NAME', 'EMAIL'] are the PII types found. The last line lists the entities with their categories: ('Jane Doe', 'PERSON') and ('Seattle', 'LOCATION').

Try this: Swap in your own sentence — try a happy one and watch the sentiment flip to POSITIVE, or add a phone number and see a new PII type appear.

The hybrid: Textract → Claude expert

Comprehend finds known entity types. When you need arbitrary structured extraction — line items, totals, custom fields — hand the Textract text to Claude with a tool schema (W3). Deterministic OCR + flexible reasoning.

Lab W10.3
extract.pyimport boto3
brt = boto3.client("bedrock-runtime", region_name="us-east-1")

def extract_invoice(doc_text):
    schema = {"tools": [{"toolSpec": {
        "name": "record_invoice",
        "description": "Record structured invoice fields.",
        "inputSchema": {"json": {"type": "object", "properties": {
            "vendor": {"type": "string"},
            "total": {"type": "number"},
            "due_date": {"type": "string"},
        }, "required": ["vendor", "total"]}},
    }}]}
    resp = brt.converse(
        modelId="anthropic.claude-3-5-sonnet-20241022-v2:0",
        messages=[{"role": "user", "content": [{"text": f"Invoice text:\n{doc_text}"}]}],
        toolConfig={**schema, "toolChoice": {"tool": {"name": "record_invoice"}}},
    )
    for b in resp["output"]["message"]["content"]:
        if "toolUse" in b:
            return b["toolUse"]["input"]
▶ How this works

Comprehend only finds pre-defined categories. When you need custom fields — a vendor name, an invoice total, a due date — you hand the OCR'd text to Claude (an LLM on Amazon Bedrock) and ask it to fill in a fixed shape. This is the "hybrid": Textract reads the document, Claude does the flexible reasoning. The trick is a tool schema that forces Claude to answer as clean structured data instead of prose.

  1. brt = boto3.client("bedrock-runtime", ...) is the client for Bedrock, the AWS service that hosts Claude and other models.
  2. schema defines a tool called record_invoice. Its inputSchema is the JSON shape we want back: a vendor string, a total number, an optional due_date — and required lists the fields Claude must not skip.
  3. brt.converse(...) sends the request. modelId picks the Claude model; messages passes the document text inside an f-string (f"Invoice text:\n{doc_text}"); toolChoice with {"tool": {"name": "record_invoice"}} forces Claude to reply by calling that tool — so you get structured fields, not a paragraph.
  4. The final loop scans the reply's content blocks; when it finds one with a "toolUse" key it returns b["toolUse"]["input"] — the dictionary Claude filled in, e.g. {"vendor": "Acme", "total": 99.0}.

What the output means: Calling extract_invoice(doc_text) returns a Python dict of the fields Claude pulled from the text — the same clean shape no matter how the invoice was laid out, which a rigid template parser could never manage.

Try this: Add a "currency" property to the schema (and to required) and re-run — Claude will start returning that field too, without any other code change.

Exercise W10.1 — Document pipeline

Context: Fixed-template parsers break the moment a vendor rearranges their invoice. The durable pattern is OCR-then-LLM: Textract reads the pixels and Claude maps whatever layout it sees into one stable schema — exactly what a template cannot do.

Your task: Build the pipeline pdf_in_s3 → Textract text → Claude structured extract → dict, then run it on three different invoice layouts and confirm the same schema comes out of all three.

Requirements:

  • OCR each PDF from S3 with Textract, collecting the text (e.g. the LINE blocks)
  • Pass that text to Claude on Bedrock with a forced tool / fixed schema so the output is a validated dict
  • Define one target schema (e.g. invoice number, total, date) and reuse it across all three documents
  • Run all three differing layouts through the identical pipeline
  • Assert the same keys come out of every layout — the win over a template parser
  • Handle a field a document genuinely lacks (null/flag) rather than crashing

💡 Hint: Let the model absorb the layout differences: keep one schema and one forced-tool call, and only the OCR input changes per document.

🪜 Practice ladder beginner → industry

Six graded exercises, easy to real-world. Try each before opening its solution.

Exercise 1 · OCR a document with TextractBeginner

Context: Most enterprise data still arrives as PDFs, scans, and photos of forms. Textract turns that pixel soup into structured text you can query, and it understands tables and key/value form fields, not just flat OCR.

Your task: Use Textract analyze_document on an S3 object with the FORMS and TABLES feature types, then print every readable line of text the document contains.

Requirements:

  • Call analyze_document on a boto3 textract client, pointing Document at an S3Object (Bucket/Name)
  • Request FeatureTypes=["FORMS", "TABLES"] so form and table structure is analyzed, not just raw text
  • Textract returns a flat graph under Blocks; iterate it rather than expecting nested text
  • Print only the human-readable lines by keeping blocks where BlockType == "LINE" and reading their Text

💡 Hint: Textract hands back a graph of typed Blocks; the readable prose lives on the LINE blocks, so filter before you print.

Show solution

Textract returns a graph of Blocks; filter on BlockType == "LINE" for readable text.

import boto3

tx = boto3.client("textract", region_name="us-east-1")
resp = tx.analyze_document(
    Document={"S3Object": {"Bucket": "my-docs", "Name": "invoice.png"}},
    FeatureTypes=["FORMS", "TABLES"],
)
for b in resp["Blocks"]:
    if b["BlockType"] == "LINE":
        print(b["Text"])
Exercise 2 · Detect PII with ComprehendIntermediate

Context: Before you store or forward extracted text, you often must know whether it contains personal data. Comprehend ships a managed PII detector that flags emails, names, card numbers and more without any model of your own.

Your task: Run Comprehend detect_pii_entities on a string and print the Type of every PII entity it finds.

Requirements:

  • Call detect_pii_entities on a boto3 comprehend client, passing Text and LanguageCode="en"
  • Iterate the returned Entities list
  • Print each entity's Type (a fixed category such as EMAIL or CREDIT_DEBIT_CARD_NUMBER)
  • Recognize the categories are a closed, service-defined set — you do not train or configure them

💡 Hint: The categories are predefined; each entry in Entities already carries its Type, so there is nothing to classify yourself.

Show solution

Comprehend returns fixed entity categories; each has a Type like EMAIL or NAME.

import boto3

cp = boto3.client("comprehend", region_name="us-east-1")
text = "Email jane@x.com or call about card 4111111111111111."
resp = cp.detect_pii_entities(Text=text, LanguageCode="en")
for e in resp["Entities"]:
    print(e["Type"])   # e.g. EMAIL, CREDIT_DEBIT_CARD_NUMBER
Exercise 3 · Sentiment + entities togetherAdvanced

Context: Comprehend is really a family of narrow, deterministic classifiers. Real tasks usually want more than one at once — how a customer feels and what they named — so you compose several single-purpose calls into one record.

Your task: Call both detect_sentiment and detect_entities on a product review and combine their results into a single structured dict.

Requirements:

  • Issue two separate Comprehend calls on the same text, each with LanguageCode="en"
  • Read the overall label from detect_sentiment's Sentiment field (POSITIVE/NEGATIVE/…)
  • Pull (Text, Type) for each item in detect_entities' Entities list
  • Merge both into one dict keyed by e.g. sentiment and entities for a quick structured summary

💡 Hint: Each call is an independent classifier returning its own JSON; you are just stitching two responses together, not making one smarter call.

Show solution

Each Comprehend call is a narrow, deterministic classifier; combine them for a quick structured summary.

import boto3

cp = boto3.client("comprehend", region_name="us-east-1")
text = "Loved the Seattle store, but Acme support was slow."
s = cp.detect_sentiment(Text=text, LanguageCode="en")
e = cp.detect_entities(Text=text, LanguageCode="en")
summary = {
    "sentiment": s["Sentiment"],                          # POSITIVE/NEGATIVE/...
    "entities": [(x["Text"], x["Type"]) for x in e["Entities"]],
}
print(summary)
Exercise 4 · Textract to Claude for arbitrary extractionExpert

Context: Comprehend's fixed categories can't extract your invoice number or line-item total. The production pattern is a hybrid: let Textract produce clean text, then let a Claude tool on Bedrock coerce that text into a schema you define.

Your task: OCR a document with Textract, then force a Claude tool via Bedrock converse to pull custom fields (invoice number, total). Show the forced-tool call so the model must return schema-valid JSON.

Requirements:

  • Feed the Textract-derived text to converse on a bedrock-runtime client
  • Define a tool under toolConfig.tools with an inputSchema naming the custom fields and their types (e.g. invoice_number: string, total: number)
  • Force that tool with toolChoice={"tool": {"name": …}} so the reply is guaranteed structured, not prose
  • Recover the fields from the toolUse.input block in the response content
  • Explain why this beats Comprehend here: arbitrary, per-document fields Comprehend has no category for

💡 Hint: Textract gives you the text; the forced toolChoice is what turns free text into a validated object — read the answer off toolUse.input, not the model's prose.

Show solution

The hybrid: Textract gives clean text, then a forced tool gives schema-valid custom fields Comprehend can't produce.

import boto3

brt = boto3.client("bedrock-runtime", region_name="us-east-1")
ocr_text = "Invoice #INV-42  Total: $1,299.00"   # from Textract LINEs
TOOLS = {"tools": [{"toolSpec": {"name": "record_invoice",
  "description": "Extract invoice fields.",
  "inputSchema": {"json": {"type":"object","properties":{
     "invoice_number":{"type":"string"},
     "total":{"type":"number"}},"required":["invoice_number","total"]}}}}],
  "toolChoice": {"tool": {"name": "record_invoice"}}}

r = brt.converse(modelId="anthropic.claude-3-5-sonnet-20241022-v2:0",
    messages=[{"role":"user","content":[{"text": ocr_text}]}],
    toolConfig=TOOLS)
for b in r["output"]["message"]["content"]:
    if "toolUse" in b:
        print(b["toolUse"]["input"])   # {'invoice_number':'INV-42','total':1299.0}
Exercise 5 · Prebuilt vs LLM routing to control costProfessional

Context: Managed classifiers are pennies and milliseconds; a general LLM is neither. Sending sentiment or PII to Claude when Comprehend would do burns budget, so mature systems route each task to the cheapest service that can actually do it.

Your task: Encode route(task) that dispatches each task to the cheapest capable surface: sentiment/PII/known-entities to Comprehend, OCR to Textract, and arbitrary structured extraction to Claude on Bedrock.

Requirements:

  • Keep the set of Comprehend-suitable tasks (sentiment, PII, entities, language) as data, not scattered ifs
  • Return "comprehend" for those narrow, deterministic tasks
  • Return "textract" for OCR
  • Fall through to "claude-bedrock" only for open-ended/custom extraction
  • Runs fully offline — it is a routing decision, no AWS calls

💡 Hint: The rule is capability-and-cost: reserve the LLM for what the prebuilt services genuinely cannot express, and default everything narrow to Comprehend.

Show solution

Pre-built services are cheaper and faster for narrow tasks; reserve the LLM for open-ended extraction.

COMPREHEND_TASKS = {"sentiment", "pii", "entities", "language"}

def route(task):
    if task in COMPREHEND_TASKS:
        return "comprehend"      # cheap, deterministic
    if task == "ocr":
        return "textract"
    return "claude-bedrock"      # arbitrary/custom extraction

for t in ("sentiment", "ocr", "custom_line_items"):
    print(t, "->", route(t))
Exercise 6 · An invoice pipeline with a confidence gateIndustry scenario

Context: Finance wants invoices posted automatically, but a wrong total is worse than a slow one. The accepted pattern is to automate the confident cases and escalate the rest: OCR, extract, then a gate that decides auto-post vs. human review.

Your task: Build the document pipeline — Textract OCR → Claude extraction → a gate that routes to human review whenever a required field is missing or OCR confidence is low. Model the gate so it runs offline.

Requirements:

  • Define the required fields (e.g. invoice_number, total) in one place
  • The gate flags any required field that is missing or falsy in the extracted dict
  • The gate also fails documents whose OCR confidence is below a threshold (e.g. min_conf=0.90)
  • Return human_review (with what's missing / the confidence) when either check fails, else auto_post with the data
  • Demonstrate both a clean auto-post case and an escalated case offline

💡 Hint: The gate is a pure function of (extracted fields, ocr_confidence); make it return a route plus the reason so a reviewer sees why it was escalated.

Show solution

Automate the clean cases; escalate the risky ones. The gate protects downstream systems from bad extractions.

REQUIRED = ["invoice_number", "total"]

def gate(extracted, ocr_confidence, min_conf=0.90):
    missing = [f for f in REQUIRED if not extracted.get(f)]
    if missing or ocr_confidence < min_conf:
        return {"route": "human_review",
                "missing": missing, "confidence": ocr_confidence}
    return {"route": "auto_post", "data": extracted}

good = gate({"invoice_number":"INV-42","total":1299.0}, 0.98)
bad  = gate({"invoice_number":"INV-42"}, 0.97)   # total missing
print(good["route"], "|", bad["route"])   # auto_post | human_review

✓ Checkpoint — you can move on when you can…

  • Extract forms and tables with Textract.
  • Detect entities, PII, and sentiment with Comprehend.
  • Explain the Textract→Claude hybrid and when to use it.
  • Decide between a pre-built AI service and an LLM for a task.

Knowledge check check yourself

✓ Knowledge check

What is the Textract -> Claude hybrid pattern, and why not just use Comprehend for everything?

Show answer
Textract does deterministic OCR (text, forms, tables) and Claude does the flexible reasoning to pull arbitrary structured fields (line items, totals, custom fields). Comprehend only finds pre-defined entity types, so it can't do open-ended structured extraction the way a tool-schema'd LLM can.
✓ Knowledge check

Why is analyze_document with FeatureTypes=['FORMS','TABLES'] not enough for a large multi-page PDF, and what is the production shape?

Show answer
analyze_document is synchronous and essentially single-page. For multi-page documents you use the async start_document_analysis and then get_document_analysis; in production (W13) that is wired event-driven via SNS so you never poll by hand.
© 2026 studybydoing.in · AI Engineering: Zero to Production · All rights reserved. · About · Privacy Policy · Terms · Contact
Educational content, provided as-is and without warranty. Code samples are examples — review, test, and adapt them before using in production. See the Terms of Use & Disclaimer. Use at your own risk.
© studybydoing.in