diff --git a/lambdas/po/email_processor/handler.py b/lambdas/po/email_processor/handler.py index 9c5b620..169cadb 100644 --- a/lambdas/po/email_processor/handler.py +++ b/lambdas/po/email_processor/handler.py @@ -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", ""), }, ) diff --git a/lambdas/po/email_processor/requirements.txt b/lambdas/po/email_processor/requirements.txt index 28fbe99..dd0c8a3 100644 --- a/lambdas/po/email_processor/requirements.txt +++ b/lambdas/po/email_processor/requirements.txt @@ -1,2 +1 @@ -anthropic>=0.116.0 boto3>=1.43.47