mirror of
https://github.com/Sea-Haven-Industries/procurement-ingest.git
synced 2026-10-07 16:08:54 +00:00
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:
parent
5e1993f9e4
commit
3151de92af
2 changed files with 24 additions and 29 deletions
|
|
@ -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", ""),
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,2 +1 @@
|
|||
anthropic>=0.116.0
|
||||
boto3>=1.43.47
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue