Textract Lambda

This Lambda function receives input parameters from Step Functions, invokes the Amazon Textract AnalyzeExpense API to perform raw OCR processing, and maps the output raw fields using dictionary mapping rules before passing them to the AI normalization step.


Step-by-Step Console Configuration

  1. Create the Lambda function:

    • Go to Lambda ➔ Click Create function.

    image1.png

    • Choose Author from scratch (default).

    image2.png

    • Function name: Enter docuflow-dev-ai-textract-lambda.

    image3.png

    • Runtime: Select Node.js 24.x.

    image4.png

    • Expand Additional settings and under Architecture select arm64 for cost optimization.

    image5.png

    • Under PermissionsChange default execution role, choose Use an existing role and select docuflow-dev-ai-textract-lambda-role.

    image6.png

    • Scroll down to the Tags tab and add the following standard tags for cost allocation and governance:

      • Project: DocuFlowAI
      • Environment: dev
      • ManagedBy: SAM
      • Module: ai
      • Owner: Team
      • CostCenter: Workshop image7.png image8.png image9.png
    • Click Save to save the tags. image10.png

    • Then click Create function. image11.png

  2. Configure General settings:

    • Go to the Configuration tab ➔ General configuration ➔ Click Edit. image12.png

    • Change Timeout to 30 seconds. Click Save. image13.png

  3. Deploy Code:

    • In the Code tab, paste the following Node.js code over the boilerplate code in the index.mjs editor.
    • Click Deploy.
import {
  TextractClient,
  AnalyzeExpenseCommand,
} from "@aws-sdk/client-textract";

const textractClient = new TextractClient({
  region: process.env.AWS_REGION || "ap-southeast-1",
});

const summaryFieldMap = {
  VENDOR_NAME: "vendorName",
  INVOICE_DATE: "invoiceDate",
  INVOICE_RECEIPT_DATE: "invoiceDate",
  ORDER_DATE: "invoiceDate",
  PURCHASE_DATE: "invoiceDate",
  RECEIPT_DATE: "invoiceDate",
  TRANSACTION_DATE: "invoiceDate",
  INVOICE_RECEIPT_ID: "invoiceNumber",
  DUE_DATE: "dueDate",
  SUBTOTAL: "subtotalAmount",
  TAX: "taxAmount",
  TOTAL: "totalAmount",
  CURRENCY: "currency",
};

const lineItemFieldMap = {
  ITEM: "description",
  EXPENSE_ROW: "description",
  QUANTITY: "quantity",
  UNIT_PRICE: "unitPriceAmount",
  PRICE: "totalAmount",
  TAX: "taxAmount",
};

export const handler = async (event) => {
  const rawS3Bucket = event?.rawS3Bucket || event?.bucket;
  const rawS3Key = event?.rawS3Key || event?.key;
  const documentId =
    event?.documentId || rawS3Key?.split("/")?.[2] || null;

  log("INFO", {
    documentId,
    rawS3Bucket,
    rawS3Key,
    declaredPageCount: finiteNumber(event?.pageCount),
    message: "Textract extraction started",
  });

  if (!rawS3Bucket || !rawS3Key) {
    throw createError(
      "INVALID_WORKFLOW_INPUT",
      "Missing rawS3Bucket/rawS3Key or bucket/key in event payload"
    );
  }

  assertSynchronousDocumentSupported(event, rawS3Key);

  try {
    const textractResponse = await textractClient.send(
      new AnalyzeExpenseCommand({
        Document: {
          S3Object: { Bucket: rawS3Bucket, Name: rawS3Key },
        },
      })
    );
    const detectedPageCount = finiteNumber(
      textractResponse?.DocumentMetadata?.Pages
    );

    if (detectedPageCount !== null && detectedPageCount > 1) {
      throw createError(
        "MULTI_PAGE_REQUIRES_ASYNC_TEXTRACT",
        "Synchronous AnalyzeExpense supports only one-page PDF or TIFF documents."
      );
    }

    const parsed = parseExpenseDocuments(
      textractResponse?.ExpenseDocuments || []
    );

    log("INFO", {
      documentId,
      detectedPageCount,
      expenseCount: parsed.expenseDocuments.length,
      primaryExpenseIndex: parsed.primaryExpenseIndex,
      summaryFieldCount: Object.keys(parsed.summaryFields).length,
      lineItemCount: parsed.lineItems.length,
      message: "Textract extraction completed",
    });

    return {
      ...event,
      rawS3Bucket,
      rawS3Key,
      extractedData: {
        documentId,
        documentMetadata: {
          pages: detectedPageCount,
          expenseCount: parsed.expenseDocuments.length,
          primaryExpenseIndex: parsed.primaryExpenseIndex,
        },
        summaryFields: parsed.summaryFields,
        summaryFieldCandidates: parsed.summaryFieldCandidates,
        rawSummaryFields: parsed.rawSummaryFields,
        lineItems: parsed.lineItems,
        expenseDocuments: parsed.expenseDocuments,
      },
    };
  } catch (error) {
    log("ERROR", {
      documentId,
      errorName: error?.name,
      errorCode: error?.code,
      errorMessage: error?.message,
      message: "Textract extraction failed",
    });
    throw error;
  }
};

export function parseExpenseDocuments(documents) {
  const expenseDocuments = (Array.isArray(documents) ? documents : []).map(
    (document, index) => parseExpenseDocument(document, index + 1)
  );
  const primaryExpense = selectPrimaryExpense(expenseDocuments);

  return {
    primaryExpenseIndex: primaryExpense?.expenseIndex ?? null,
    summaryFields: primaryExpense?.summaryFields || {},
    summaryFieldCandidates: collectSummaryFieldCandidates(expenseDocuments),
    rawSummaryFields: primaryExpense?.rawSummaryFields || [],
    lineItems: primaryExpense?.lineItems || [],
    expenseDocuments,
  };
}

function parseExpenseDocument(document, fallbackExpenseIndex) {
  const summaryFieldCandidates = {};
  const rawSummaryFields = [];
  const lineItems = [];

  for (const field of document?.SummaryFields || []) {
    const type = normalizeType(field?.Type?.Text);
    const label = field?.LabelDetection?.Text || null;
    const value = field?.ValueDetection?.Text;
    const confidenceScore = normalizeConfidence(field?.ValueDetection?.Confidence);
    const sourceField = {
      value: value ?? null,
      confidenceScore,
      type,
      label,
      pageNumber: field?.PageNumber || null,
      groupProperties: normalizeGroupProperties(field?.GroupProperties),
    };
    rawSummaryFields.push(sourceField);

    const docuflowKey = summaryFieldMap[type] || inferSummaryFieldKey(field);

    if (docuflowKey && value !== undefined) {
      addCandidate(summaryFieldCandidates, docuflowKey, {
        value,
        confidenceScore,
        type,
        label,
        pageNumber: sourceField.pageNumber,
        groupProperties: sourceField.groupProperties,
      });
    }

    const currencyCode = field?.Currency?.Code;
    if (currencyCode) {
      addCandidate(summaryFieldCandidates, "currency", {
        value: String(currencyCode).trim().toUpperCase(),
        confidenceScore: normalizeConfidence(field?.Currency?.Confidence),
        type: "CURRENCY_METADATA",
        label: field?.LabelDetection?.Text || null,
        pageNumber: field?.PageNumber || null,
        groupProperties: normalizeGroupProperties(field?.GroupProperties),
      });
    }
  }

  for (const group of document?.LineItemGroups || []) {
    for (const lineItem of group?.LineItems || []) {
      const parsedItem = parseLineItem(lineItem, group?.LineItemGroupIndex);
      if (Object.keys(parsedItem.fields).length) lineItems.push(parsedItem);
    }
  }

  return {
    expenseIndex: document?.ExpenseIndex ?? fallbackExpenseIndex,
    summaryFields: selectBestCandidates(summaryFieldCandidates),
    summaryFieldCandidates,
    rawSummaryFields,
    lineItems: lineItems.map((item) => item.fields),
    lineItemDetails: lineItems,
  };
}

function inferSummaryFieldKey(field) {
  const label = normalizeLabel(field?.LabelDetection?.Text);
  const type = normalizeType(field?.Type?.Text);
  const value = field?.ValueDetection?.Text;

  if (!looksLikeDateText(value)) return null;
  if (/\b(DUE|PAYMENT DUE|BALANCE DUE)\b/.test(label) || type === "DUE_DATE") {
    return "dueDate";
  }
  if (
    [
      "DATE",
      "INVOICE_DATE",
      "ORDER_DATE",
      "PURCHASE_DATE",
      "RECEIPT_DATE",
      "SALE_DATE",
      "TRANSACTION_DATE",
    ].includes(type)
  ) {
    return "invoiceDate";
  }
  if (
    /\b(INVOICE|RECEIPT|ORDER|PURCHASE|SALE|TRANSACTION)?\s*DATE\b/.test(label) &&
    !/\b(DUE|EXPIR|SHIP|DELIVERY)\b/.test(label)
  ) {
    return "invoiceDate";
  }

  return null;
}

function parseLineItem(lineItem, lineItemGroupIndex) {
  const fieldCandidates = {};
  const sourceFields = [];

  for (const field of lineItem?.LineItemExpenseFields || []) {
    const type = normalizeType(field?.Type?.Text);
    const value = field?.ValueDetection?.Text;
    if (value === undefined) continue;

    const candidate = {
      value,
      confidenceScore: normalizeConfidence(field.ValueDetection.Confidence),
      type,
      label: field?.LabelDetection?.Text || null,
      pageNumber: field?.PageNumber || null,
      currency: normalizeCurrency(field?.Currency),
    };
    sourceFields.push(candidate);

    const docuflowKey = lineItemFieldMap[type];
    if (!docuflowKey) continue;

    // ITEM is more precise than the full OCR row when both are present.
    const priority = type === "ITEM" ? 2 : type === "EXPENSE_ROW" ? 1 : 2;
    addCandidate(fieldCandidates, docuflowKey, { ...candidate, priority });
  }

  return {
    lineItemGroupIndex: lineItemGroupIndex ?? null,
    fields: selectBestCandidates(fieldCandidates),
    sourceFields,
  };
}

function selectPrimaryExpense(expenseDocuments) {
  if (!expenseDocuments.length) return null;

  return [...expenseDocuments].sort((left, right) => {
    const scoreDifference = expenseCompletenessScore(right) - expenseCompletenessScore(left);
    if (scoreDifference) return scoreDifference;
    return Number(left.expenseIndex || 0) - Number(right.expenseIndex || 0);
  })[0];
}

function expenseCompletenessScore(expense) {
  const requiredSummaryFields = [
    "vendorName",
    "invoiceDate",
    "invoiceNumber",
    "totalAmount",
  ];
  const summaryScore = requiredSummaryFields.reduce(
    (score, key) => score + (expense?.summaryFields?.[key]?.value ? 2 : 0),
    0
  );
  return summaryScore + Math.min(expense?.lineItems?.length || 0, 5);
}

function collectSummaryFieldCandidates(expenseDocuments) {
  const candidates = {};

  for (const expense of expenseDocuments) {
    for (const [key, values] of Object.entries(
      expense.summaryFieldCandidates || {}
    )) {
      for (const value of values) {
        addCandidate(candidates, key, {
          ...value,
          expenseIndex: expense.expenseIndex,
        });
      }
    }
  }

  return candidates;
}

function addCandidate(candidateMap, key, candidate) {
  if (!candidateMap[key]) candidateMap[key] = [];
  candidateMap[key].push(candidate);
}

function selectBestCandidates(candidateMap) {
  return Object.fromEntries(
    Object.entries(candidateMap).map(([key, candidates]) => {
      const selected = { ...[...candidates].sort(compareCandidates)[0] };
      delete selected.priority;
      return [key, selected];
    })
  );
}

function compareCandidates(left, right) {
  const priorityDifference = (right.priority || 0) - (left.priority || 0);
  if (priorityDifference) return priorityDifference;
  return (right.confidenceScore || 0) - (left.confidenceScore || 0);
}

function normalizeGroupProperties(groupProperties) {
  if (!Array.isArray(groupProperties)) return [];
  return groupProperties.map((group) => ({
    id: group?.Id || null,
    types: Array.isArray(group?.Types) ? group.Types : [],
  }));
}

function normalizeCurrency(currency) {
  if (!currency?.Code) return null;
  return {
    code: String(currency.Code).trim().toUpperCase(),
    confidenceScore: normalizeConfidence(currency.Confidence),
  };
}

function assertSynchronousDocumentSupported(event, rawS3Key) {
  const declaredPageCount = finiteNumber(event?.pageCount);
  const isPdfOrTiff = /\.(pdf|tiff?)$/i.test(rawS3Key);

  if (isPdfOrTiff && declaredPageCount !== null && declaredPageCount > 1) {
    throw createError(
      "MULTI_PAGE_REQUIRES_ASYNC_TEXTRACT",
      "Multi-page PDF or TIFF documents must use asynchronous Textract expense analysis."
    );
  }
}

function normalizeType(value) {
  return String(value || "").trim().toUpperCase();
}

function normalizeLabel(value) {
  return String(value || "")
    .trim()
    .replace(/\s+/g, " ")
    .toUpperCase();
}

function looksLikeDateText(value) {
  const text = String(value || "").trim();
  if (!text) return false;
  if (/\b\d{4}-\d{1,2}-\d{1,2}\b/.test(text)) return true;
  if (/\b\d{1,2}[/-]\d{1,2}[/-]\d{2,4}\b/.test(text)) return true;
  if (
    /\b(?:JAN|FEB|MAR|APR|MAY|JUN|JUL|AUG|SEP|SEPT|OCT|NOV|DEC)[A-Z]*\.?\s+\d{1,2},?\s+\d{4}\b/i.test(text)
  ) {
    return true;
  }
  return false;
}

function normalizeConfidence(value) {
  const numeric = finiteNumber(value);
  if (numeric === null) return 0;
  const normalized = numeric > 1 ? numeric / 100 : numeric;
  return Number(Math.max(0, Math.min(1, normalized)).toFixed(4));
}

function finiteNumber(value) {
  if (value === null || value === undefined || value === "") return null;
  const numeric = Number(value);
  return Number.isFinite(numeric) ? numeric : null;
}

function createError(code, message) {
  const error = new Error(message);
  error.name = "DocuFlowTextractError";
  error.code = code;
  return error;
}

function log(level, data) {
  const writer = level === "ERROR" ? console.error : console.log;
  writer(
    JSON.stringify({
      level,
      service: "docuflow-dev-ai-textract-lambda",
      ...data,
    })
  );
}
  1. Test the Lambda function:
    • Switch to the Test tab.
    • Select Create new event. image14.png
    • Select Synchronous. image15.png
    • Enter Test in the Event name field. image16.png
    • In the Event JSON block, paste the following JSON payload. (Note: Make sure the file exists in your S3 raw bucket at raw/user-001/doc-20260628-001/original.pdf).
      {
        "rawS3Bucket": "docuflow-dev-raw-<AWS_ACCOUNT_ID>-ap-southeast-1",
        "rawS3Key": "raw/user-001/doc-20260628-001/original.pdf"
      }
      
    image17.png
    • Click Save and then click Test. If the execution succeeds and returns output similar to the following, the Lambda is configured correctly: image18.png
      {
        "extractedData": {
          "documentId": "doc-20260628-001",
          "summaryFields": {
            "dueDate": {
              "value": "26/02/2019",
              "confidenceScore": 0.9998512268066406
            },
            "invoiceDate": {
              "value": "11/02/2019",
              "confidenceScore": 0.9997476959228515
            },
            "invoiceNumber": {
              "value": "US-001",
              "confidenceScore": 0.9965461730957031
            },
            "subtotalAmount": {
              "value": "145.00",
              "confidenceScore": 0.9998919677734375
            },
            "taxAmount": {
              "value": "9.06",
              "confidenceScore": 0.9991468048095703
            },
            "totalAmount": {
              "value": "$154.06",
              "confidenceScore": 0.9999777221679688
            },
            "vendorName": {
              "value": "East Repair Inc.",
              "confidenceScore": 0.9880215454101563
            }
          },
          "lineItems": [
            {
              "quantity": {
                "value": "1",
                "confidenceScore": 0.9984848785400391
              },
              "description": {
                "value": "Front and rear brake cables",
                "confidenceScore": 0.9999493408203125
              },
              "unitPriceAmount": {
                "value": "100.00",
                "confidenceScore": 0.9999201202392578
              },
              "totalAmount": {
                "value": "100.00",
                "confidenceScore": 0.9999593353271484
              }
            },
            {
              "quantity": {
                "value": "2",
                "confidenceScore": 0.9997554779052734
              },
              "description": {
                "value": "New set of pedal arms",
                "confidenceScore": 0.9998876953125
              },
              "unitPriceAmount": {
                "value": "15.00",
                "confidenceScore": 0.9997454071044922
              },
              "totalAmount": {
                "value": "30.00",
                "confidenceScore": 0.9999018859863281
              }
            },
            {
              "quantity": {
                "value": "3",
                "confidenceScore": 0.9997983551025391
              },
              "description": {
                "value": "Labor 3hrs",
                "confidenceScore": 0.9998870086669922
              },
              "unitPriceAmount": {
                "value": "5.00",
                "confidenceScore": 0.9998588562011719
              },
              "totalAmount": {
                "value": "15.00",
                "confidenceScore": 0.9999015045166015
              }
            }
          ]
        }
      }