AI EngineeringZero to ProductionHome·About·Contact
AWS AI Automation · Project A

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.

⏱️ ~4 hours🏗️ End-to-end project🎯 Advanced
⚙️ 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.
What you'll buildA serverless document-intelligence pipeline: a PDF lands in S3, Textract extracts the text, Claude-on-Bedrock pulls structured fields (with a Guardrail), and the result is written to DynamoDB — all event-driven, all provisioned with Terraform. This ties together W2, W3, W6, W10, and W13.

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.
▶ Runnable companionEvery code block in this lesson is also a standalone file under 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:

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.
Architecture
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)
▶ How this works

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.

  1. 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.
  2. 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.
  3. 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.
  4. "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).

Step 1
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}
▶ How this works

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.

  1. Set-up (top). boto3 is the AWS SDK for Python; boto3.client("textract") and client("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.
  2. 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 vendor and total as required. This is how you get reliable machine-readable output instead of a paragraph of prose.
  3. Gather text. The while True: loop calls get_document_analysis to pull Textract's results one page at a time. NextToken is AWS's "there's more — here's your bookmark" marker; when it comes back empty, break ends the loop. It keeps only the LINE blocks and joins them into one text string.
  4. Ask Claude. brt.converse(...) sends that text to Claude on Bedrock. toolChoice forces Claude to answer by filling in the record_invoice tool (not free text), and guardrailConfig runs the safety Guardrail (W6) on the exchange. The modelId string is the specific Claude 3.5 Sonnet version on Bedrock.
  5. Store. next(b["toolUse"]["input"] for ... if "toolUse" in b) grabs the structured fields Claude filled in. ddb.put_item writes them to DynamoDB using doc_id as 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

Step 2
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
    }
  }
}
▶ How this works

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.

  1. 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.
  2. aws_dynamodb_table.invoices defines the table. billing_mode = "PAY_PER_REQUEST" means you pay per read/write (no capacity to plan). hash_key = "doc_id" makes doc_id the primary key — the same key the Lambda writes by, which is what makes storage idempotent.
  3. aws_lambda_function.extract defines the Lambda: which file/function to run (handler), the Python runtime, a 120-second timeout, and the filename of the zipped code to deploy.
  4. The environment { variables { ... } } block is the payoff: it feeds aws_dynamodb_table.invoices.name and the guardrail id into the Lambda as the TABLE and GUARDRAIL env vars the Python read with os.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

Step 3
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
▶ How this works

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.

  1. 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.
  2. 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.
  3. 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.

Extend itAdd a Step Functions wrapper (W13) for retries/DLQ, a CloudWatch dashboard (W14), and a second document type (receipts) with its own tool schema. That is a portfolio-grade project.

🪜 Practice ladder beginner → industry

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

Exercise 1 · Milestone 1 — page through Textract results (needs AWS creds)Beginner

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_analysis with the job id
  • Follow NextToken until it is absent — the standard AWS pagination loop
  • Filter blocks to LINE type 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.

Exercise 2 · Milestone 2 — define a forced extraction tool (needs AWS creds)Intermediate

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 toolChoice to 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.

Exercise 3 · Milestone 3 — call Bedrock Converse with a Guardrail (needs AWS creds)Advanced

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 guardrailConfig with 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.

Exercise 4 · Milestone 4 — idempotent store keyed by doc_id (needs AWS creds)Expert

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_id from the extracted fields (with a sensible fallback)
  • Write with put_item using doc_id as 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.

Exercise 5 · Milestone 5 — provision the pipeline with TerraformProfessional

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_id as 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.

Exercise 6 · Milestone 6 — the event-driven flow end to end as ownerIndustry scenario

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.
📋 Staff-level self-scoring — is this document-intelligence pipeline production-shaped?
DimensionMeets the barAbove the bar (staff)
Extraction accuracyThe 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 robustnessThe 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 correctnessEvery 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 safetyA 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 & scaleBilling 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.
ObservabilityCloudWatch 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

✓ Knowledge check

Why are the two Lambdas connected by an SNS notification from Textract rather than the first Lambda polling for completion?

Show answer
Textract runs asynchronously; polling would keep a Lambda alive waiting (wasted compute and timeouts). Instead Textract fires an SNS message on completion that wakes the second Lambda, so the pipeline is event-driven and only does work when there's a result to process.
✓ Knowledge check

Why is the DynamoDB write keyed by document id, and what property does that give the pipeline?

Show answer
Keying the put_item by doc id makes writes idempotent: if the same event is delivered twice, the second write simply overwrites the first with identical data — no duplicate records and no harm from at-least-once event delivery.
© 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