Document intelligence pipeline
A serverless document-intelligence pipeline: S3 → Textract → Claude-on-Bedrock (with a Guardrail) → DynamoDB, provisioned with Terraform. Ties together W2, W3, W6, W10, W13.
- AWS credentials (
aws configure) + Bedrock model access enabled in your region +pip install boto3 - AWS credentials (
aws configure) +pip install boto3
Learning objectives
- Architect an event-driven pipeline: S3 → Textract → Claude → DynamoDB.
- Provision every resource with Terraform (buckets, Lambdas, table, IAM, guardrail).
- Make each step idempotent and observable.
- Add a Guardrail and invocation logging so it is production-shaped.
code/proj-aws-docintel/ in the course, with a README on how to run it. Read here, run there — no need to copy-paste.Architecture advanced
Two Lambdas, connected by an SNS notification so nothing polls:
S3 (upload)
└─(event)→ Lambda: start_textract ──→ Textract (async)
└─(SNS on done)→ Lambda: extract_and_store
├─ Bedrock Converse (Claude + Guardrail)
└─ DynamoDB put (keyed by doc id → idempotent)
This little map is the whole project on one screen. Nothing here is a program you run — it shows who calls whom, and the key idea is that nothing polls (nothing sits in a loop asking "are we done yet?"). Each step wakes up the next one with an event, so the pipeline only does work when there is a document to process.
- A file lands in an S3 bucket (S3 is AWS's file storage). That upload automatically fires an event — think of it as a doorbell.
- The doorbell wakes the first Lambda (a Lambda is a small function AWS runs for you, no server to manage),
start_textract, which hands the file to Textract — AWS's OCR service that reads text out of PDFs and images. async means Textract works in the background and tells us later, so we don't wait. - When Textract finishes it sends an SNS message (a notification), which wakes the second Lambda,
extract_and_store. That one asks Claude on Bedrock to pull out the useful fields (with a Guardrail checking for unsafe content), then saves the result to DynamoDB, a fast key-value database. - "keyed by doc id → idempotent" means each document is stored under its own id. If the same event arrives twice, the second write just overwrites the first — no duplicates, no harm.
Try this: Trace one PDF through the arrows top to bottom out loud. If you can say what each arrow's event is (upload, done-notification) you already understand event-driven architecture — the backbone of serverless.
Step 1 — the extraction Lambda advanced
The second Lambda fires when Textract finishes. It gathers the text, asks Claude for structured fields via a tool schema (W3), applies the Guardrail (W6), and writes to DynamoDB keyed by document id so a duplicate event is harmless (W13).
extract_and_store.pyimport boto3, os
tx = boto3.client("textract")
brt = boto3.client("bedrock-runtime")
ddb = boto3.resource("dynamodb").Table(os.environ["TABLE"])
INVOICE_TOOL = {"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"}, "doc_id": {"type": "string"},
}, "required": ["vendor", "total"]}},
}}]}
def handler(event, context):
job_id = event["Records"][0]["Sns"]["Message"] # simplified
pages, token = [], None
while True:
kw = {"JobId": job_id}
if token: kw["NextToken"] = token
resp = tx.get_document_analysis(**kw)
pages += [b["Text"] for b in resp["Blocks"] if b["BlockType"] == "LINE"]
token = resp.get("NextToken")
if not token: break
text = "\n".join(pages)
ans = brt.converse(
modelId="anthropic.claude-3-5-sonnet-20241022-v2:0",
messages=[{"role": "user", "content": [{"text": f"Invoice:\n{text}"}]}],
toolConfig={**INVOICE_TOOL, "toolChoice": {"tool": {"name": "record_invoice"}}},
guardrailConfig={"guardrailIdentifier": os.environ["GUARDRAIL"], "guardrailVersion": "1"},
)
fields = next(b["toolUse"]["input"] for b in ans["output"]["message"]["content"] if "toolUse" in b)
ddb.put_item(Item={"doc_id": fields.get("doc_id", job_id), **fields}) # idempotent by key
return {"stored": fields}
This is the heart of the pipeline: the second Lambda. It runs after Textract has read the document, and its job is to turn a wall of raw text into clean, structured fields (vendor, total, due date) and save them. Read it in four movements — set-up, gather text, ask Claude, store.
- Set-up (top).
boto3is the AWS SDK for Python;boto3.client("textract")andclient("bedrock-runtime")create objects that talk to those AWS services.os.environ["TABLE"]reads the DynamoDB table name from an environment variable that Terraform will set later — so the code isn't hard-wired to one table. - INVOICE_TOOL is a tool schema: a JSON contract that tells Claude exactly what shape of answer you want. It defines four fields and marks
vendorandtotalasrequired. This is how you get reliable machine-readable output instead of a paragraph of prose. - Gather text. The
while True:loop callsget_document_analysisto pull Textract's results one page at a time.NextTokenis AWS's "there's more — here's your bookmark" marker; when it comes back empty,breakends the loop. It keeps only theLINEblocks and joins them into onetextstring. - Ask Claude.
brt.converse(...)sends that text to Claude on Bedrock.toolChoiceforces Claude to answer by filling in therecord_invoicetool (not free text), andguardrailConfigruns the safety Guardrail (W6) on the exchange. ThemodelIdstring is the specific Claude 3.5 Sonnet version on Bedrock. - Store.
next(b["toolUse"]["input"] for ... if "toolUse" in b)grabs the structured fields Claude filled in.ddb.put_itemwrites them to DynamoDB usingdoc_idas the key — so re-running on the same document overwrites rather than duplicates (that's the "idempotent" promise).
What the output means: A dictionary like {"stored": {"vendor": "Acme", "total": 1250.0, ...}}. Those same fields are now a row in DynamoDB, queryable by doc_id.
Try this: Add "currency": {"type": "string"} to the tool's properties. Claude will start returning a currency too — changing the schema is how you teach the pipeline to extract new fields, no prompt rewording needed.
Step 2 — provision with Terraform expert
main.tf# The DynamoDB table + the notification Lambda. (Bucket, Textract-SNS,
# guardrail, and IAM omitted for space — same patterns as W7.)
resource "aws_dynamodb_table" "invoices" {
name = "invoices"
billing_mode = "PAY_PER_REQUEST"
hash_key = "doc_id"
attribute { name = "doc_id"; type = "S" }
}
resource "aws_lambda_function" "extract" {
function_name = "extract-and-store"
runtime = "python3.12"
handler = "extract_and_store.handler"
role = aws_iam_role.extract.arn
filename = "extract.zip"
timeout = 120
environment {
variables = {
TABLE = aws_dynamodb_table.invoices.name
GUARDRAIL = aws_bedrock_guardrail.pii.guardrail_id
}
}
}
This is Terraform — infrastructure as code. Instead of clicking around the AWS console, you declare the resources you want in these .tf files, and Terraform creates or updates them to match. Here we declare the two pieces that store and process data: the database table and the Lambda function.
- Each
resource "aws_..." "name" { }block describes one AWS thing. The first string is the type (what AWS resource); the second is a local nickname you use to reference it elsewhere in the file. aws_dynamodb_table.invoicesdefines the table.billing_mode = "PAY_PER_REQUEST"means you pay per read/write (no capacity to plan).hash_key = "doc_id"makesdoc_idthe primary key — the same key the Lambda writes by, which is what makes storage idempotent.aws_lambda_function.extractdefines the Lambda: which file/function to run (handler), the Pythonruntime, a 120-secondtimeout, and thefilenameof the zipped code to deploy.- The
environment { variables { ... } }block is the payoff: it feedsaws_dynamodb_table.invoices.nameand the guardrail id into the Lambda as theTABLEandGUARDRAILenv vars the Python read withos.environ. Terraform wires the two files together automatically.
Try this: Notice the code never types the table name twice. Change the table's name once and both the table and the Lambda's env var update — that reference (aws_dynamodb_table.invoices.name) is why infra-as-code stays consistent.
Step 3 — verify end to end expert
smoke_test.pyimport boto3, time
s3 = boto3.client("s3")
ddb = boto3.resource("dynamodb").Table("invoices")
s3.upload_file("sample-invoice.pdf", "my-docs", "sample-invoice.pdf") # triggers the pipeline
time.sleep(30) # let it run
print(ddb.scan(Limit=5)["Items"]) # should show the extracted fields
The final step: prove the whole chain actually works, end to end, from the outside. This tiny script does what a real user would — drop a file in S3 — then checks that a result showed up in the database a moment later. If it does, every arrow in the architecture map fired correctly.
s3.upload_file("sample-invoice.pdf", "my-docs", ...)puts a test PDF into the bucket. That single upload is the doorbell that triggers the pipeline — you don't call any Lambda yourself; the S3 event does it for you.time.sleep(30)waits 30 seconds. Textract's OCR and the Claude call happen in the background, so you give the pipeline a moment to finish before you look.ddb.scan(Limit=5)["Items"]reads up to 5 rows straight from DynamoDB and prints them. If the pipeline worked, you'll see the extracted invoice fields there.
What the output means: A list of DynamoDB items, e.g. the vendor/total/due_date Claude pulled out of your sample PDF. An empty list [] means the document hasn't finished processing (wait longer) or a step failed (check the Lambda's CloudWatch logs).
Try this: Run it twice with the same file. Because storage is keyed by doc_id, you still see one row, not two — live proof that the idempotency you built into the design actually holds.
🪜 Practice ladder beginner → industry
Six graded exercises, easy to real-world. Try each before opening its solution.
Context: Textract returns an async job's output in pages behind a NextToken. Before you can extract anything you have to collect every page of LINE text with the standard pagination loop.
Your task: Collect all LINE text from an async Textract analysis job by following the NextToken pagination loop. Show the documented boto3 shape and mark this rung as needing AWS credentials.
Requirements:
- Call
get_document_analysiswith the job id - Follow
NextTokenuntil it is absent — the standard AWS pagination loop - Filter blocks to
LINEtype and pull their text - Join the lines into one flat text string for downstream extraction
- Label the rung as requiring AWS credentials
💡 Hint: Loop while a NextToken is present, passing it back on each call, and accumulate only the LINE blocks' text.
Show solution
Textract pagination — needs AWS creds + a running Textract job (documented boto3):
import boto3
tx = boto3.client("textract")
def collect_text(job_id):
pages, token = [], None
while True:
kw = {"JobId": job_id}
if token:
kw["NextToken"] = token
resp = tx.get_document_analysis(**kw)
pages += [b["Text"] for b in resp["Blocks"] if b["BlockType"] == "LINE"]
token = resp.get("NextToken")
if not token:
break
return "\n".join(pages)
# text = collect_text("<textract-job-id>")
Async Textract paginates, so you must loop on NextToken until it is absent or you silently truncate long documents. Collecting only LINE blocks gives the flat text the extraction model needs — the OCR stage that turns a PDF into something Claude can read.
Context: To get typed fields instead of prose, force a tool call. A Bedrock record_invoice toolSpec with required fields plus a forcing toolChoice makes Claude return structured output every time.
Your task: Define the record_invoice tool spec with required fields and set toolChoice to force it, so the model must return typed fields. Show the documented Converse tool config and mark the rung as needing AWS credentials.
Requirements:
- A toolSpec with an input schema (vendor, total, due date, doc id)
- Mark the essential fields (e.g. vendor, total) as required
- Set
toolChoiceto force this specific tool - The config guarantees structured JSON fields rather than free-text prose
- Label the rung as requiring AWS credentials
💡 Hint: The required array plus a forcing toolChoice is what turns "please return JSON" into a guarantee.
Show solution
Tool schema + forced choice — needs AWS creds (documented Bedrock Converse toolConfig):
INVOICE_TOOL = {
"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"},
"doc_id": {"type": "string"},
},
"required": ["vendor", "total"],
}},
}
}],
# force the model to call this tool -> guaranteed structured output
"toolChoice": {"tool": {"name": "record_invoice"}},
}
print(INVOICE_TOOL["toolChoice"])
Declaring a tool with a JSON schema and forcing it via toolChoice guarantees Claude returns typed fields instead of free text you'd have to parse. required makes the model commit to the must-have fields (vendor, total), turning extraction into validated structured data.
Context: Running the extraction means calling Bedrock Converse with both the tool config and a Guardrail attached, then pulling the tool-use input out of the response. The Guardrail screens content at the model boundary.
Your task: Call bedrock-runtime Converse with the tool config AND a Guardrail attached, then extract the toolUse input from the response. Show the documented call with a pinned model id and mark the rung as needing AWS credentials.
Requirements:
- Call Converse with a pinned model id for reproducibility
- Pass the user message plus the tool config
- Attach a
guardrailConfigwith the guardrail id and version - Extract the structured fields from the response's toolUse block
- Label the rung as requiring AWS credentials
💡 Hint: The tool output lives in the content block whose key is toolUse — pull its input; the guardrail rides along in guardrailConfig.
Show solution
Converse + Guardrail — needs AWS creds + a provisioned Guardrail (documented boto3):
import boto3, os
brt = boto3.client("bedrock-runtime")
def extract(text):
ans = brt.converse(
modelId="anthropic.claude-3-5-sonnet-20241022-v2:0",
messages=[{"role": "user", "content": [{"text": f"Invoice:\n{text}"}]}],
toolConfig={**INVOICE_TOOL, "toolChoice": {"tool": {"name": "record_invoice"}}},
guardrailConfig={"guardrailIdentifier": os.environ["GUARDRAIL"],
"guardrailVersion": "1"},
)
# the forced tool call carries the structured fields
return next(b["toolUse"]["input"]
for b in ans["output"]["message"]["content"] if "toolUse" in b)
# fields = extract("ACME Corp ... total 1234.50 ...")
Converse attaches the tool config and a Guardrail in one call: the Guardrail screens for PII/unsafe content at the model boundary, and the forced tool call yields the structured fields. Pinning the exact model id (not "latest") keeps extractions reproducible across deploys.
Context: In an at-least-once pipeline the same document can be processed twice. Keying the DynamoDB write on doc_id makes a re-run overwrite rather than duplicate — turning at-least-once delivery into an exactly-once effect.
Your task: Store the extracted fields in DynamoDB keyed by doc_id so re-processing the same document overwrites instead of duplicating. Show the documented put_item and mark the rung as needing AWS credentials.
Requirements:
- Resolve the table from an environment variable
- Derive the
doc_idfrom the extracted fields (with a sensible fallback) - Write with
put_itemusingdoc_idas the hash key - A second write with the same id overwrites rather than creating a duplicate
- Explain how idempotent writes + at-least-once delivery give an exactly-once effect
- Label the rung as requiring AWS credentials
💡 Hint: A keyed put_item is inherently idempotent — same key, same slot — so no dedup bookkeeping is needed.
Show solution
Idempotent DynamoDB write — needs AWS creds + a DynamoDB table (documented boto3):
import boto3, os
table = boto3.resource("dynamodb").Table(os.environ["TABLE"])
def store(fields, fallback_id):
doc_id = fields.get("doc_id") or fallback_id
# put_item on the same hash key overwrites -> re-processing is harmless
table.put_item(Item={"doc_id": doc_id, **fields})
return doc_id
# store({"vendor": "ACME", "total": 1234.5}, fallback_id="job-123")
Keying the table on doc_id and using put_item makes the write idempotent: the same document processed twice overwrites the same row instead of duplicating it. That property is what lets you safely retry after a Lambda failure without corrupting the store.
Context: The pipeline should be config-as-infra, not click-ops. Terraform provisions the DynamoDB table and the extraction Lambda wired to it, with the Guardrail id injected through the Lambda's environment.
Your task: Write the HCL that provisions a DynamoDB table and the extraction Lambda wired to it, injecting the table name and Guardrail id via environment variables. This is config-as-infra, not runnable Python.
Requirements:
- A DynamoDB table resource with
doc_idas the hash key and on-demand billing - A Lambda function resource with runtime, handler, role, and timeout
- Inject the table name and guardrail id as Lambda environment variables
- Wire resources by reference/interpolation, not hard-coded strings
- Changing the table name in one place updates both the table and the Lambda's env var
💡 Hint: Reference the table resource's attributes inside the Lambda's environment block so the two stay in sync through interpolation.
Show solution
Terraform for the table + Lambda (literal HCL, accurate resource types):
resource "aws_dynamodb_table" "invoices" {
name = "invoices"
billing_mode = "PAY_PER_REQUEST"
hash_key = "doc_id"
attribute {
name = "doc_id"
type = "S"
}
}
resource "aws_lambda_function" "extract" {
function_name = "extract-and-store"
runtime = "python3.12"
handler = "extract_and_store.handler"
role = aws_iam_role.extract.arn
filename = "extract.zip"
timeout = 120
environment {
variables = {
TABLE = aws_dynamodb_table.invoices.name
GUARDRAIL = aws_bedrock_guardrail.pii.guardrail_id
}
}
}
Terraform wires the pieces so the handler reads config from the environment, never hard-coded: the table name and Guardrail id flow in as references. PAY_PER_REQUEST billing suits bursty document loads, and a 120s timeout covers Textract-scale documents.
Context: As owner you wire the whole event-driven pipeline and reason about the production concern: exactly-once effect under retries. S3 uploads trigger Textract, SNS fans out to the extract Lambda, and DynamoDB keying makes the business effect exactly-once.
Your task: Wire the full event-driven flow (S3 upload → start Textract → SNS → extract Lambda → Bedrock → DynamoDB) and model the exactly-once-effect guarantee under at-least-once delivery.
Requirements:
- Model the stages: S3 ObjectCreated → start-Textract Lambda → Textract async → SNS → extract Lambda → Bedrock+Guardrail → DynamoDB
- The start-Textract Lambda kicks off the async job with an SNS notification channel
- The extract Lambda reads the job id from the SNS message, collects text, extracts, and stores
- Show that idempotent keyed writes + at-least-once delivery yield an exactly-once effect
- Frame the production narrative: documents land in S3 and flow to DynamoDB with no polling
💡 Hint: SNS and Lambda are at-least-once; the exactly-once effect comes entirely from the doc_id-keyed write absorbing the duplicate.
Show solution
The end-to-end flow with an idempotency guarantee (pure stdlib model of the wiring):
FLOW = [
("S3 ObjectCreated", "triggers start_textract Lambda"),
("start_textract", "tx.start_document_analysis(...) -> JobId, NotificationChannel=SNS"),
("Textract async", "publishes completion to SNS topic"),
("SNS -> extract Lambda", "handler reads JobId from Sns.Message"),
("extract Lambda", "collect_text -> Bedrock Converse+Guardrail -> fields"),
("DynamoDB put_item", "keyed by doc_id (idempotent)"),
]
def describe_flow():
for stage, detail in FLOW:
print(f"{stage:22s} -> {detail}")
def effect_is_exactly_once(doc_id, seen):
# at-least-once delivery + idempotent put_item = exactly-once EFFECT
return "overwrite (safe)" if doc_id in seen else "insert"
describe_flow()
print(effect_is_exactly_once("doc-1", {"doc-1"})) # overwrite (safe)
Industry scenario: insurance claims land in S3, get OCR'd, extracted, and stored automatically. SNS and Lambda both deliver at least once, so a document can be processed twice — but the doc_id-keyed put_item makes the effect exactly-once. Designing for idempotency, not for perfect delivery, is the production concern that keeps the store clean under retries.
✓ Checkpoint — you can move on when you can…
- Explain the event-driven, no-polling architecture.
- Provision the pipeline (Lambdas, table, guardrail, IAM) with Terraform.
- Explain how DynamoDB keying makes the pipeline idempotent.
- Verify the pipeline end to end from an S3 upload.
| Dimension | Meets the bar | Above the bar (staff) |
|---|---|---|
| Extraction accuracy | The Claude tool-schema returns the required fields on your sample docs; required vs optional fields are marked. | Extraction is measured against a labelled set (field-level precision/recall), and low-confidence extractions are flagged rather than silently stored. |
| Pipeline robustness | The Textract→Bedrock→DynamoDB chain is event-driven (nothing polls) and each step is idempotent by doc id. | Multi-page pagination, Textract async failure, malformed PDFs, and duplicate SNS deliveries are all handled; a poison document lands in a DLQ, not a retry storm. |
| IaC correctness | Every resource (buckets, Lambdas, table, guardrail, IAM) is provisioned in Terraform; names/ARNs are wired by reference, not hard-coded. | State is managed safely, IAM is least-privilege per Lambda, and a fresh apply in a clean account reproduces the whole pipeline. |
| Guardrails & data safety | A Bedrock Guardrail runs on the model exchange; PII / unsafe content is filtered. | The guardrail is versioned and tested, redaction of sensitive fields is deliberate, and invocation logging captures what was sent to the model. |
| Cost & scale | Billing mode is pay-per-request; you know the rough cost per document processed. | Cost per doc is measured across Textract + Bedrock + storage, and behaviour is understood at a burst of thousands of uploads. |
| Observability | CloudWatch logs let you trace one document from upload to stored row. | Metrics/alarms cover extraction failures and latency, and a failed doc is diagnosable without re-running the whole pipeline. |
Score each dimension 0 (missing), 1 (meets bar), or 2 (above bar). 0–5: a demo pipeline. 6–9: shippable for a bounded workload. 10–12: staff-level — accurate, robust to bad input, reproducible, and priced. A 0 on Guardrails or IaC correctness blocks shipping.
Knowledge check check yourself
Why are the two Lambdas connected by an SNS notification from Textract rather than the first Lambda polling for completion?
Show answer
Why is the DynamoDB write keyed by document id, and what property does that give the pipeline?