Migrate PO processor to Bedrock

Switch the PO email processor's AI extraction from the Anthropic SDK to
bedrock-runtime InvokeModel on the inference profile
us.anthropic.claude-haiku-4-5-20251001-v1:0 (BEDROCK_MODEL_ID env), so
it no longer needs a provider API key or Secrets Manager secret. PO
parsing stays fully AI -- only the provider changes. The EXTRACTION_PROMPT
is kept byte-identical and the Bedrock text output is still decoded with
json.loads(..., parse_float=Decimal), which DynamoDB requires (it rejects
floats). Replace the deprecated datetime.utcnow() with
datetime.now(timezone.utc) and drop the anthropic dependency.
This commit is contained in:
Adam Moussa 2026-07-15 20:10:14 -04:00
parent 5e1993f9e4
commit 3151de92af
No known key found for this signature in database
2 changed files with 24 additions and 29 deletions

View file

@ -11,11 +11,10 @@ import json
import logging
import os
import re
from datetime import datetime
from datetime import datetime, timezone
from decimal import Decimal
from email import policy
import anthropic
import boto3
logger = logging.getLogger()
@ -23,9 +22,12 @@ logger.setLevel(logging.INFO)
s3 = boto3.client("s3")
dynamodb = boto3.resource("dynamodb")
bedrock = boto3.client("bedrock-runtime")
PO_TABLE = os.environ.get("PO_TABLE", "purchase-orders")
ANTHROPIC_API_KEY_SECRET_ARN = os.environ.get("ANTHROPIC_API_KEY_SECRET_ARN")
BEDROCK_MODEL_ID = os.environ.get(
"BEDROCK_MODEL_ID", "us.anthropic.claude-haiku-4-5-20251001-v1:0"
)
EXTRACTION_PROMPT = """\
You are an email parser for a purchase order ingest pipeline.
@ -210,17 +212,6 @@ The Coupa commodity/category field if present in the email (e.g., "Maintenance -
"""
def get_anthropic_client() -> anthropic.Anthropic:
"""Create Anthropic client, fetching API key from Secrets Manager."""
if ANTHROPIC_API_KEY_SECRET_ARN:
secrets = boto3.client("secretsmanager")
secret = secrets.get_secret_value(SecretId=ANTHROPIC_API_KEY_SECRET_ARN)
api_key = secret["SecretString"]
return anthropic.Anthropic(api_key=api_key)
# Fall back to ANTHROPIC_API_KEY env var (for local testing)
return anthropic.Anthropic()
def parse_raw_email(raw_bytes: bytes) -> dict:
"""Parse a raw MIME email into subject, sender, and body text."""
msg = email.message_from_bytes(raw_bytes, policy=policy.default)
@ -252,9 +243,7 @@ def parse_raw_email(raw_bytes: bytes) -> dict:
def extract_with_claude(email_data: dict) -> dict:
"""Send parsed email to Claude for structured extraction."""
client = get_anthropic_client()
"""Send parsed email to Claude on Bedrock for structured extraction."""
email_text = (
f"Subject: {email_data['subject']}\n"
f"From: {email_data['sender']}\n"
@ -264,24 +253,29 @@ def extract_with_claude(email_data: dict) -> dict:
f"{email_data['body']}"
)
response = client.messages.create(
model="claude-haiku-4-5-20251001",
max_tokens=2048,
messages=[
resp = bedrock.invoke_model(
modelId=BEDROCK_MODEL_ID,
body=json.dumps(
{
"role": "user",
"content": f"{EXTRACTION_PROMPT}\n\nEMAIL:\n{email_text}",
"anthropic_version": "bedrock-2023-05-31",
"max_tokens": 2048,
"messages": [
{
"role": "user",
"content": f"{EXTRACTION_PROMPT}\n\nEMAIL:\n{email_text}",
}
],
}
],
),
)
response_text = response.content[0].text
response_text = json.loads(resp["body"].read())["content"][0]["text"]
# Extract JSON from response (handle markdown code blocks)
json_match = re.search(r"```(?:json)?\s*(.*?)```", response_text, re.DOTALL)
if json_match:
response_text = json_match.group(1)
# parse_float=Decimal is CRITICAL: DynamoDB rejects Python floats.
return json.loads(response_text.strip(), parse_float=Decimal)
@ -296,7 +290,7 @@ def pad_zip(zip_code: str | None) -> str | None:
def enrich_parsed(parsed: dict, s3_key: str, email_subject: str):
"""Add metadata and promote nested fields to top level."""
now = datetime.utcnow().isoformat()
now = datetime.now(timezone.utc).isoformat()
parsed["raw_s3_key"] = s3_key
parsed["processed_at"] = now
parsed["data_source"] = "email"
@ -346,7 +340,9 @@ def save_cancellation(parsed: dict):
UpdateExpression="SET po_status = :status, cancelled_at = :cancelled_at, raw_s3_key = :s3_key",
ExpressionAttributeValues={
":status": "Cancelled",
":cancelled_at": parsed.get("processed_at", datetime.utcnow().isoformat()),
":cancelled_at": parsed.get(
"processed_at", datetime.now(timezone.utc).isoformat()
),
":s3_key": parsed.get("raw_s3_key", ""),
},
)

View file

@ -1,2 +1 @@
anthropic>=0.116.0
boto3>=1.43.47