mirror of
https://github.com/Sea-Haven-Industries/workorder-ingest.git
synced 2026-05-18 20:20:12 +00:00
Initial commit: work order email ingestion pipeline
Serverless AWS pipeline that receives Amazon APM work order emails via SES, parses them with Claude AI, and stores structured data in DynamoDB. Includes a web UI dashboard for viewing work orders and event history. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
commit
df65d34eb0
17 changed files with 914 additions and 0 deletions
11
.gitignore
vendored
Normal file
11
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
__pycache__/
|
||||
*.py[cod]
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
.venv/
|
||||
venv/
|
||||
node_modules/
|
||||
cdk.out/
|
||||
.env
|
||||
*.eml
|
||||
70
README.md
Normal file
70
README.md
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
# Work Order Ingest
|
||||
|
||||
AWS serverless pipeline that ingests work order emails from Amazon's APM system (Hexagon EAM / HxGN SmartCloud), parses them with Claude AI, and stores structured data in DynamoDB.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Email (amazon@seahavenind.com)
|
||||
→ Gmail filter (from: noreply@hxgnsmartcloud.com)
|
||||
→ Forwards to apm@int.seahaven.com (SES)
|
||||
→ S3 bucket (raw email storage)
|
||||
→ Lambda (email processor)
|
||||
→ Claude Haiku (structured extraction)
|
||||
→ DynamoDB (WorkOrders + WorkOrderComments)
|
||||
```
|
||||
|
||||
## Components
|
||||
|
||||
- **Email Processor Lambda** (`lambdas/email_processor/`) - Parses raw emails, sends to Claude for structured extraction, writes to DynamoDB
|
||||
- **Web UI Lambda** (`lambdas/web_ui/`) - Server-rendered HTML dashboard for viewing work orders and event history
|
||||
- **CDK Stack** (`cdk/`) - Infrastructure as code for all AWS resources
|
||||
- **Shared Models** (`shared/`) - Data models for work orders, comments, and parsed emails
|
||||
|
||||
## Extracted Fields
|
||||
|
||||
- Work Order ID, Description, Status
|
||||
- Site Code, Building, Address
|
||||
- Severity, Priority
|
||||
- Due Date, Date Reported, Scheduled Start
|
||||
- Assigned To, Commenter, Comment Text
|
||||
- Record Type (new_work_order, update, comment, cancellation)
|
||||
|
||||
## DynamoDB Tables
|
||||
|
||||
- **WorkOrders** - Latest state of each work order (PK: `work_order_id`)
|
||||
- **WorkOrderComments** - Event history per work order (PK: `work_order_id`, SK: `comment_id`)
|
||||
|
||||
## Local Testing
|
||||
|
||||
```bash
|
||||
python3 -m venv .venv
|
||||
source .venv/bin/activate
|
||||
pip install anthropic boto3
|
||||
export ANTHROPIC_API_KEY=sk-ant-...
|
||||
python test_local.py
|
||||
```
|
||||
|
||||
## Deployment
|
||||
|
||||
```bash
|
||||
cd cdk
|
||||
pip install aws-cdk-lib constructs
|
||||
npx cdk bootstrap # first time only
|
||||
npx cdk deploy
|
||||
```
|
||||
|
||||
After deploying, store your Anthropic API key in Secrets Manager:
|
||||
|
||||
```bash
|
||||
aws secretsmanager put-secret-value \
|
||||
--secret-id "workorder-ingest/anthropic-api-key" \
|
||||
--secret-string "sk-ant-..." \
|
||||
--region us-east-1
|
||||
```
|
||||
|
||||
## SES Configuration
|
||||
|
||||
- Domain: `int.seahaven.com` (MX record pointing to `inbound-smtp.us-east-1.amazonaws.com`)
|
||||
- Receipt Rule Set: `INBOUND_MAIL`
|
||||
- Recipient: `apm@int.seahaven.com`
|
||||
0
cdk/__init__.py
Normal file
0
cdk/__init__.py
Normal file
9
cdk/app.py
Normal file
9
cdk/app.py
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
#!/usr/bin/env python3
|
||||
import aws_cdk as cdk
|
||||
from stack import WorkorderIngestStack
|
||||
|
||||
app = cdk.App()
|
||||
WorkorderIngestStack(app, "WorkorderIngestStack",
|
||||
env=cdk.Environment(region="us-east-1"),
|
||||
)
|
||||
app.synth()
|
||||
6
cdk/cdk.json
Normal file
6
cdk/cdk.json
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"app": "python3 app.py",
|
||||
"context": {
|
||||
"@aws-cdk/core:bootstrapQualifier": "hnb659fds"
|
||||
}
|
||||
}
|
||||
172
cdk/stack.py
Normal file
172
cdk/stack.py
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
"""CDK stack for the work order email ingestion pipeline."""
|
||||
|
||||
import aws_cdk as cdk
|
||||
from aws_cdk import (
|
||||
Duration,
|
||||
RemovalPolicy,
|
||||
Stack,
|
||||
aws_dynamodb as dynamodb,
|
||||
aws_lambda as lambda_,
|
||||
aws_s3 as s3,
|
||||
aws_s3_notifications as s3n,
|
||||
aws_ses as ses,
|
||||
aws_ses_actions as ses_actions,
|
||||
aws_secretsmanager as secretsmanager,
|
||||
aws_iam as iam,
|
||||
)
|
||||
from constructs import Construct
|
||||
|
||||
|
||||
class WorkorderIngestStack(Stack):
|
||||
def __init__(self, scope: Construct, construct_id: str, **kwargs):
|
||||
super().__init__(scope, construct_id, **kwargs)
|
||||
|
||||
# --- S3 bucket for raw emails ---
|
||||
email_bucket = s3.Bucket(
|
||||
self, "EmailBucket",
|
||||
bucket_name=f"workorder-ingest-emails-{self.account}",
|
||||
removal_policy=RemovalPolicy.RETAIN,
|
||||
lifecycle_rules=[
|
||||
s3.LifecycleRule(expiration=Duration.days(90)),
|
||||
],
|
||||
)
|
||||
|
||||
# --- DynamoDB tables ---
|
||||
work_orders_table = dynamodb.Table(
|
||||
self, "WorkOrdersTable",
|
||||
table_name="WorkOrders",
|
||||
partition_key=dynamodb.Attribute(
|
||||
name="work_order_id",
|
||||
type=dynamodb.AttributeType.STRING,
|
||||
),
|
||||
billing_mode=dynamodb.BillingMode.PAY_PER_REQUEST,
|
||||
removal_policy=RemovalPolicy.RETAIN,
|
||||
)
|
||||
# GSI for querying by site code
|
||||
work_orders_table.add_global_secondary_index(
|
||||
index_name="site-code-index",
|
||||
partition_key=dynamodb.Attribute(
|
||||
name="site_code",
|
||||
type=dynamodb.AttributeType.STRING,
|
||||
),
|
||||
sort_key=dynamodb.Attribute(
|
||||
name="updated_at",
|
||||
type=dynamodb.AttributeType.STRING,
|
||||
),
|
||||
)
|
||||
# GSI for querying by status
|
||||
work_orders_table.add_global_secondary_index(
|
||||
index_name="status-index",
|
||||
partition_key=dynamodb.Attribute(
|
||||
name="wo_status",
|
||||
type=dynamodb.AttributeType.STRING,
|
||||
),
|
||||
sort_key=dynamodb.Attribute(
|
||||
name="updated_at",
|
||||
type=dynamodb.AttributeType.STRING,
|
||||
),
|
||||
)
|
||||
|
||||
comments_table = dynamodb.Table(
|
||||
self, "CommentsTable",
|
||||
table_name="WorkOrderComments",
|
||||
partition_key=dynamodb.Attribute(
|
||||
name="work_order_id",
|
||||
type=dynamodb.AttributeType.STRING,
|
||||
),
|
||||
sort_key=dynamodb.Attribute(
|
||||
name="comment_id",
|
||||
type=dynamodb.AttributeType.STRING,
|
||||
),
|
||||
billing_mode=dynamodb.BillingMode.PAY_PER_REQUEST,
|
||||
removal_policy=RemovalPolicy.RETAIN,
|
||||
)
|
||||
|
||||
# --- Secrets Manager for Anthropic API key ---
|
||||
anthropic_secret = secretsmanager.Secret(
|
||||
self, "AnthropicApiKey",
|
||||
secret_name="workorder-ingest/anthropic-api-key",
|
||||
description="Anthropic API key for work order email parsing",
|
||||
)
|
||||
|
||||
# --- Lambda function ---
|
||||
email_processor = lambda_.Function(
|
||||
self, "EmailProcessor",
|
||||
function_name="workorder-email-processor",
|
||||
runtime=lambda_.Runtime.PYTHON_3_12,
|
||||
handler="handler.handler",
|
||||
code=lambda_.Code.from_asset(
|
||||
"../lambdas/email_processor",
|
||||
bundling=cdk.BundlingOptions(
|
||||
image=lambda_.Runtime.PYTHON_3_12.bundling_image,
|
||||
platform="linux/amd64",
|
||||
command=[
|
||||
"bash", "-c",
|
||||
"pip install --platform manylinux2014_x86_64 --only-binary=:all: "
|
||||
"-r requirements.txt -t /asset-output && "
|
||||
"cp -r . /asset-output/"
|
||||
],
|
||||
),
|
||||
),
|
||||
timeout=Duration.seconds(60),
|
||||
memory_size=256,
|
||||
environment={
|
||||
"WORK_ORDERS_TABLE": work_orders_table.table_name,
|
||||
"COMMENTS_TABLE": comments_table.table_name,
|
||||
"ANTHROPIC_API_KEY_SECRET_ARN": anthropic_secret.secret_arn,
|
||||
},
|
||||
)
|
||||
|
||||
# Grant permissions
|
||||
email_bucket.grant_read(email_processor)
|
||||
work_orders_table.grant_read_write_data(email_processor)
|
||||
comments_table.grant_read_write_data(email_processor)
|
||||
anthropic_secret.grant_read(email_processor)
|
||||
|
||||
# S3 event notification → Lambda
|
||||
email_bucket.add_event_notification(
|
||||
s3.EventType.OBJECT_CREATED,
|
||||
s3n.LambdaDestination(email_processor),
|
||||
s3.NotificationKeyFilter(prefix="inbound/"),
|
||||
)
|
||||
|
||||
# --- SES Receipt Rule ---
|
||||
rule_set = ses.ReceiptRuleSet.from_receipt_rule_set_name(
|
||||
self, "ExistingRuleSet", "INBOUND_MAIL",
|
||||
)
|
||||
|
||||
rule_set.add_rule(
|
||||
"WorkorderEmailRule",
|
||||
recipients=["apm@int.seahaven.com"],
|
||||
actions=[
|
||||
ses_actions.S3(
|
||||
bucket=email_bucket,
|
||||
object_key_prefix="inbound/",
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
# --- Web UI Lambda ---
|
||||
web_ui = lambda_.Function(
|
||||
self, "WebUI",
|
||||
function_name="workorder-web-ui",
|
||||
runtime=lambda_.Runtime.PYTHON_3_12,
|
||||
handler="handler.handler",
|
||||
code=lambda_.Code.from_asset("../lambdas/web_ui"),
|
||||
timeout=Duration.seconds(15),
|
||||
memory_size=128,
|
||||
environment={
|
||||
"WORK_ORDERS_TABLE": work_orders_table.table_name,
|
||||
"COMMENTS_TABLE": comments_table.table_name,
|
||||
},
|
||||
)
|
||||
|
||||
work_orders_table.grant_read_data(web_ui)
|
||||
comments_table.grant_read_data(web_ui)
|
||||
|
||||
# Function URL for direct access
|
||||
web_url = web_ui.add_function_url(
|
||||
auth_type=lambda_.FunctionUrlAuthType.NONE,
|
||||
)
|
||||
|
||||
cdk.CfnOutput(self, "WebUIUrl", value=web_url.url, description="Work Order Dashboard URL")
|
||||
0
lambdas/__init__.py
Normal file
0
lambdas/__init__.py
Normal file
0
lambdas/email_processor/__init__.py
Normal file
0
lambdas/email_processor/__init__.py
Normal file
270
lambdas/email_processor/handler.py
Normal file
270
lambdas/email_processor/handler.py
Normal file
|
|
@ -0,0 +1,270 @@
|
|||
"""
|
||||
Email processor Lambda.
|
||||
|
||||
Triggered by S3 events when SES delivers an email.
|
||||
Parses the raw email, sends it to Claude for structured extraction,
|
||||
then writes the result to DynamoDB.
|
||||
"""
|
||||
|
||||
import email
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from datetime import datetime
|
||||
from email import policy
|
||||
from typing import Optional
|
||||
|
||||
import anthropic
|
||||
import boto3
|
||||
|
||||
logger = logging.getLogger()
|
||||
logger.setLevel(logging.INFO)
|
||||
|
||||
s3 = boto3.client("s3")
|
||||
dynamodb = boto3.resource("dynamodb")
|
||||
|
||||
WORK_ORDERS_TABLE = os.environ.get("WORK_ORDERS_TABLE", "WorkOrders")
|
||||
COMMENTS_TABLE = os.environ.get("COMMENTS_TABLE", "WorkOrderComments")
|
||||
ANTHROPIC_API_KEY_SECRET_ARN = os.environ.get("ANTHROPIC_API_KEY_SECRET_ARN")
|
||||
|
||||
EXTRACTION_PROMPT = """\
|
||||
You are an email parser for a facilities maintenance work order system.
|
||||
The emails come from Amazon's APM system (via Hexagon EAM / HxGN SmartCloud).
|
||||
|
||||
Analyze the following email and extract structured data. Return ONLY valid JSON with these fields:
|
||||
|
||||
{
|
||||
"email_type": "new_work_order" | "update" | "comment" | "cancellation",
|
||||
"work_order_id": "string or null",
|
||||
"description": "work order description or null",
|
||||
"status": "new" | "assigned" | "in_progress" | "on_hold" | "completed" | "cancelled" | "unknown",
|
||||
"site_code": "building/site code like WIL1, ZDL8, etc. or null",
|
||||
"building": "full building identifier or null",
|
||||
"address": "physical address or null",
|
||||
"severity": "severity level or null",
|
||||
"priority": "priority level or null",
|
||||
"date_reported": "ISO 8601 date or null",
|
||||
"scheduled_start": "ISO 8601 date or null",
|
||||
"due_date": "ISO 8601 date or null",
|
||||
"assigned_to": "person/team assigned or null",
|
||||
"commenter": "person who left a comment or null",
|
||||
"comment_text": "the comment text or null",
|
||||
"comment_time": "ISO 8601 datetime of the comment or null"
|
||||
}
|
||||
|
||||
Rules:
|
||||
- "email_type" detection:
|
||||
- "new_work_order": email announces a new WO assignment
|
||||
- "comment": email contains a new comment on an existing WO
|
||||
- "cancellation": email announces a WO has been cancelled
|
||||
- "update": any other update to an existing WO (status change, reassignment, etc.)
|
||||
- Extract the site_code from the building field (e.g., "WIL1" from "building WIL1")
|
||||
- Dates should be converted to ISO 8601 format
|
||||
- If a field is not present in the email, set it to null
|
||||
- Do NOT invent or infer data that is not explicitly in the email
|
||||
"""
|
||||
|
||||
|
||||
def get_anthropic_client() -> anthropic.Anthropic:
|
||||
"""Create Anthropic client, fetching API key from Secrets Manager if configured."""
|
||||
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 email into subject, sender, body text."""
|
||||
msg = email.message_from_bytes(raw_bytes, policy=policy.default)
|
||||
|
||||
subject = msg.get("Subject", "")
|
||||
sender = msg.get("From", "")
|
||||
to = msg.get("To", "")
|
||||
cc = msg.get("Cc", "")
|
||||
date = msg.get("Date", "")
|
||||
|
||||
body = ""
|
||||
if msg.is_multipart():
|
||||
for part in msg.walk():
|
||||
content_type = part.get_content_type()
|
||||
if content_type == "text/plain":
|
||||
body = part.get_content()
|
||||
break
|
||||
elif content_type == "text/html" and not body:
|
||||
body = part.get_content()
|
||||
else:
|
||||
body = msg.get_content()
|
||||
|
||||
return {
|
||||
"subject": subject,
|
||||
"sender": sender,
|
||||
"to": to,
|
||||
"cc": cc,
|
||||
"date": date,
|
||||
"body": body,
|
||||
}
|
||||
|
||||
|
||||
def extract_with_claude(email_data: dict) -> dict:
|
||||
"""Send parsed email to Claude for structured extraction."""
|
||||
client = get_anthropic_client()
|
||||
|
||||
email_text = (
|
||||
f"Subject: {email_data['subject']}\n"
|
||||
f"From: {email_data['sender']}\n"
|
||||
f"To: {email_data['to']}\n"
|
||||
f"CC: {email_data['cc']}\n"
|
||||
f"Date: {email_data['date']}\n"
|
||||
f"\n---\n\n"
|
||||
f"{email_data['body']}"
|
||||
)
|
||||
|
||||
response = client.messages.create(
|
||||
model="claude-haiku-4-5-20251001",
|
||||
max_tokens=1024,
|
||||
messages=[
|
||||
{
|
||||
"role": "user",
|
||||
"content": f"{EXTRACTION_PROMPT}\n\nEMAIL:\n{email_text}",
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
response_text = response.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)
|
||||
|
||||
return json.loads(response_text.strip())
|
||||
|
||||
|
||||
def save_work_order(parsed: dict, s3_key: str):
|
||||
"""Create or update a work order in DynamoDB."""
|
||||
table = dynamodb.Table(WORK_ORDERS_TABLE)
|
||||
work_order_id = parsed["work_order_id"]
|
||||
now = datetime.utcnow().isoformat()
|
||||
|
||||
# Build update expression dynamically from non-null fields
|
||||
field_map = {
|
||||
"description": "description",
|
||||
"status": "wo_status", # 'status' is a DynamoDB reserved word
|
||||
"site_code": "site_code",
|
||||
"building": "building",
|
||||
"address": "address",
|
||||
"severity": "severity",
|
||||
"priority": "priority",
|
||||
"date_reported": "date_reported",
|
||||
"scheduled_start": "scheduled_start",
|
||||
"due_date": "due_date",
|
||||
"assigned_to": "assigned_to",
|
||||
}
|
||||
|
||||
update_parts = ["#updated_at = :updated_at", "#source_key = :source_key"]
|
||||
attr_names = {
|
||||
"#updated_at": "updated_at",
|
||||
"#source_key": "source_email_s3_key",
|
||||
}
|
||||
attr_values = {
|
||||
":updated_at": now,
|
||||
":source_key": s3_key,
|
||||
}
|
||||
|
||||
for src_field, dynamo_field in field_map.items():
|
||||
value = parsed.get(src_field)
|
||||
if value is not None:
|
||||
placeholder = f":{dynamo_field}"
|
||||
name_placeholder = f"#{dynamo_field}"
|
||||
update_parts.append(f"{name_placeholder} = {placeholder}")
|
||||
attr_names[name_placeholder] = dynamo_field
|
||||
attr_values[placeholder] = value
|
||||
|
||||
# For new items, set created_at
|
||||
update_parts.append("#created_at = if_not_exists(#created_at, :created_at)")
|
||||
attr_names["#created_at"] = "created_at"
|
||||
attr_values[":created_at"] = now
|
||||
|
||||
# Customer is always AMAZON for now
|
||||
update_parts.append("#customer = :customer")
|
||||
attr_names["#customer"] = "customer"
|
||||
attr_values[":customer"] = "AMAZON"
|
||||
|
||||
# Track the record type (new_work_order, update, comment)
|
||||
email_type = parsed.get("email_type")
|
||||
if email_type:
|
||||
update_parts.append("#record_type = :record_type")
|
||||
attr_names["#record_type"] = "record_type"
|
||||
attr_values[":record_type"] = email_type
|
||||
|
||||
table.update_item(
|
||||
Key={"work_order_id": work_order_id},
|
||||
UpdateExpression="SET " + ", ".join(update_parts),
|
||||
ExpressionAttributeNames=attr_names,
|
||||
ExpressionAttributeValues=attr_values,
|
||||
)
|
||||
|
||||
logger.info(f"Saved work order {work_order_id}")
|
||||
|
||||
|
||||
def save_event(parsed: dict, s3_key: str):
|
||||
"""Save an event to the events table. Every email creates an event entry."""
|
||||
table = dynamodb.Table(COMMENTS_TABLE)
|
||||
|
||||
work_order_id = parsed["work_order_id"]
|
||||
email_type = parsed.get("email_type", "unknown")
|
||||
event_time = parsed.get("comment_time") or datetime.utcnow().isoformat()
|
||||
event_id = f"{work_order_id}#{event_time}"
|
||||
|
||||
item = {
|
||||
"work_order_id": work_order_id,
|
||||
"comment_id": event_id, # keeping key name for table compatibility
|
||||
"record_type": email_type,
|
||||
"commenter": parsed.get("commenter") or "",
|
||||
"text": parsed.get("comment_text") or "",
|
||||
"created_at": event_time,
|
||||
"source_email_s3_key": s3_key,
|
||||
"ingested_at": datetime.utcnow().isoformat(),
|
||||
}
|
||||
|
||||
table.put_item(Item=item)
|
||||
logger.info(f"Saved event {event_id} (type={email_type})")
|
||||
|
||||
|
||||
def handler(event, context):
|
||||
"""Lambda entry point. Triggered by S3 ObjectCreated events."""
|
||||
for record in event.get("Records", []):
|
||||
bucket = record["s3"]["bucket"]["name"]
|
||||
key = record["s3"]["object"]["key"]
|
||||
|
||||
logger.info(f"Processing email: s3://{bucket}/{key}")
|
||||
|
||||
# Fetch raw email from S3
|
||||
response = s3.get_object(Bucket=bucket, Key=key)
|
||||
raw_email = response["Body"].read()
|
||||
|
||||
# Parse the raw email
|
||||
email_data = parse_raw_email(raw_email)
|
||||
logger.info(f"Subject: {email_data['subject']}")
|
||||
|
||||
# Extract structured data with Claude
|
||||
parsed = extract_with_claude(email_data)
|
||||
logger.info(f"Parsed: type={parsed.get('email_type')}, wo={parsed.get('work_order_id')}")
|
||||
|
||||
if not parsed.get("work_order_id"):
|
||||
logger.warning(f"No work order ID found in email, skipping: {key}")
|
||||
continue
|
||||
|
||||
s3_key = f"s3://{bucket}/{key}"
|
||||
|
||||
# Always upsert the work order with any new info
|
||||
save_work_order(parsed, s3_key)
|
||||
|
||||
# Save every email as an event for history tracking
|
||||
save_event(parsed, s3_key)
|
||||
|
||||
return {"statusCode": 200, "body": "OK"}
|
||||
2
lambdas/email_processor/requirements.txt
Normal file
2
lambdas/email_processor/requirements.txt
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
anthropic>=0.42.0
|
||||
boto3>=1.35.0
|
||||
0
lambdas/web_ui/__init__.py
Normal file
0
lambdas/web_ui/__init__.py
Normal file
241
lambdas/web_ui/handler.py
Normal file
241
lambdas/web_ui/handler.py
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
"""
|
||||
Web UI Lambda.
|
||||
|
||||
Serves a simple HTML dashboard for viewing work orders and comments.
|
||||
Accessed via Lambda Function URL.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import urllib.parse
|
||||
|
||||
import boto3
|
||||
|
||||
dynamodb = boto3.resource("dynamodb")
|
||||
|
||||
WORK_ORDERS_TABLE = os.environ.get("WORK_ORDERS_TABLE", "WorkOrders")
|
||||
COMMENTS_TABLE = os.environ.get("COMMENTS_TABLE", "WorkOrderComments")
|
||||
|
||||
|
||||
def get_work_orders():
|
||||
table = dynamodb.Table(WORK_ORDERS_TABLE)
|
||||
response = table.scan()
|
||||
items = response.get("Items", [])
|
||||
# Sort by updated_at descending
|
||||
items.sort(key=lambda x: x.get("updated_at", ""), reverse=True)
|
||||
return items
|
||||
|
||||
|
||||
def get_comments(work_order_id):
|
||||
table = dynamodb.Table(COMMENTS_TABLE)
|
||||
response = table.query(
|
||||
KeyConditionExpression="work_order_id = :woid",
|
||||
ExpressionAttributeValues={":woid": work_order_id},
|
||||
)
|
||||
items = response.get("Items", [])
|
||||
items.sort(key=lambda x: x.get("created_at", ""), reverse=True)
|
||||
return items
|
||||
|
||||
|
||||
def render_badge(value, color_map):
|
||||
color = color_map.get(value, "#9ca3af")
|
||||
label = value.replace("_", " ").title()
|
||||
return f'<span style="background:{color};color:#fff;padding:2px 10px;border-radius:12px;font-size:12px;font-weight:500;">{label}</span>'
|
||||
|
||||
|
||||
STATUS_COLORS = {
|
||||
"new": "#3b82f6",
|
||||
"assigned": "#8b5cf6",
|
||||
"in_progress": "#f59e0b",
|
||||
"on_hold": "#6b7280",
|
||||
"completed": "#10b981",
|
||||
"cancelled": "#ef4444",
|
||||
"unknown": "#9ca3af",
|
||||
}
|
||||
|
||||
RECORD_TYPE_COLORS = {
|
||||
"new_work_order": "#3b82f6",
|
||||
"comment": "#8b5cf6",
|
||||
"update": "#f59e0b",
|
||||
"cancellation": "#ef4444",
|
||||
"unknown": "#9ca3af",
|
||||
}
|
||||
|
||||
|
||||
def render_work_order_detail(wo, events):
|
||||
events_html = ""
|
||||
if events:
|
||||
for e in events:
|
||||
record_type = e.get("record_type", "unknown")
|
||||
commenter = e.get("commenter", "")
|
||||
created = e.get("created_at", "")
|
||||
text = e.get("text", "")
|
||||
badge = render_badge(record_type, RECORD_TYPE_COLORS)
|
||||
commenter_str = f"<strong>{commenter}</strong> — " if commenter else ""
|
||||
|
||||
border_colors = {
|
||||
"new_work_order": "#3b82f6",
|
||||
"comment": "#8b5cf6",
|
||||
"update": "#f59e0b",
|
||||
"cancellation": "#ef4444",
|
||||
}
|
||||
border = border_colors.get(record_type, "#94a3b8")
|
||||
|
||||
events_html += f"""
|
||||
<div style="border-left:3px solid {border};padding:8px 12px;margin-bottom:10px;background:#f8fafc;border-radius:0 6px 6px 0;">
|
||||
<div style="font-size:12px;color:#64748b;margin-bottom:4px;">
|
||||
{badge} {commenter_str}{created}
|
||||
</div>
|
||||
{"<div style='font-size:14px;color:#1e293b;margin-top:6px;'>" + text + "</div>" if text else ""}
|
||||
</div>"""
|
||||
else:
|
||||
events_html = '<p style="color:#94a3b8;font-style:italic;">No events yet.</p>'
|
||||
|
||||
wo_id = wo.get("work_order_id", "")
|
||||
fields = [
|
||||
("Description", wo.get("description")),
|
||||
("Status", render_badge(wo.get("wo_status", "unknown"), STATUS_COLORS)),
|
||||
("Record Type", render_badge(wo.get("record_type", "unknown"), RECORD_TYPE_COLORS)),
|
||||
("Site Code", wo.get("site_code")),
|
||||
("Building", wo.get("building")),
|
||||
("Address", wo.get("address")),
|
||||
("Severity", wo.get("severity")),
|
||||
("Priority", wo.get("priority")),
|
||||
("Due Date", wo.get("due_date")),
|
||||
("Date Reported", wo.get("date_reported")),
|
||||
("Scheduled Start", wo.get("scheduled_start")),
|
||||
("Assigned To", wo.get("assigned_to")),
|
||||
("Created", wo.get("created_at")),
|
||||
("Last Updated", wo.get("updated_at")),
|
||||
]
|
||||
|
||||
details_html = ""
|
||||
for label, value in fields:
|
||||
if value:
|
||||
details_html += f"""
|
||||
<div style="display:flex;padding:8px 0;border-bottom:1px solid #f1f5f9;">
|
||||
<div style="width:140px;font-size:13px;color:#64748b;font-weight:500;">{label}</div>
|
||||
<div style="flex:1;font-size:14px;color:#1e293b;">{value}</div>
|
||||
</div>"""
|
||||
|
||||
return f"""<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>WO {wo_id} - Sea Haven</title>
|
||||
<style>
|
||||
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
|
||||
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #f1f5f9; color: #1e293b; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div style="max-width:800px;margin:0 auto;padding:20px;">
|
||||
<div style="margin-bottom:20px;">
|
||||
<a href="/" style="color:#3b82f6;text-decoration:none;font-size:14px;">← All Work Orders</a>
|
||||
</div>
|
||||
<div style="background:#fff;border-radius:10px;padding:24px;box-shadow:0 1px 3px rgba(0,0,0,0.08);margin-bottom:20px;">
|
||||
<h1 style="font-size:20px;margin-bottom:16px;">Work Order {wo_id}</h1>
|
||||
{details_html}
|
||||
</div>
|
||||
<div style="background:#fff;border-radius:10px;padding:24px;box-shadow:0 1px 3px rgba(0,0,0,0.08);">
|
||||
<h2 style="font-size:16px;margin-bottom:16px;">Events ({len(events)})</h2>
|
||||
{events_html}
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
|
||||
def render_work_orders_list(work_orders):
|
||||
rows = ""
|
||||
for wo in work_orders:
|
||||
wo_id = wo.get("work_order_id", "")
|
||||
desc = wo.get("description", "")
|
||||
site = wo.get("site_code", "")
|
||||
status = wo.get("wo_status", "unknown")
|
||||
record_type = wo.get("record_type", "unknown")
|
||||
due = wo.get("due_date", "")
|
||||
updated = wo.get("updated_at", "")[:16]
|
||||
|
||||
rows += f"""
|
||||
<tr style="border-bottom:1px solid #f1f5f9;cursor:pointer;" onclick="window.location='/wo?id={wo_id}'">
|
||||
<td style="padding:12px;font-weight:500;color:#3b82f6;">{wo_id}</td>
|
||||
<td style="padding:12px;max-width:250px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">{desc}</td>
|
||||
<td style="padding:12px;">{site}</td>
|
||||
<td style="padding:12px;">{render_badge(record_type, RECORD_TYPE_COLORS)}</td>
|
||||
<td style="padding:12px;">{render_badge(status, STATUS_COLORS)}</td>
|
||||
<td style="padding:12px;">{due}</td>
|
||||
<td style="padding:12px;color:#64748b;font-size:13px;">{updated}</td>
|
||||
</tr>"""
|
||||
|
||||
return f"""<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Work Orders - Sea Haven</title>
|
||||
<style>
|
||||
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
|
||||
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #f1f5f9; color: #1e293b; }}
|
||||
table {{ width: 100%; border-collapse: collapse; }}
|
||||
tr:hover {{ background: #f8fafc; }}
|
||||
th {{ text-align: left; padding: 12px; font-size: 12px; text-transform: uppercase; color: #64748b; border-bottom: 2px solid #e2e8f0; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div style="max-width:1100px;margin:0 auto;padding:20px;">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:20px;">
|
||||
<h1 style="font-size:22px;">Work Orders</h1>
|
||||
<span style="color:#64748b;font-size:14px;">{len(work_orders)} total</span>
|
||||
</div>
|
||||
<div style="background:#fff;border-radius:10px;box-shadow:0 1px 3px rgba(0,0,0,0.08);overflow:hidden;">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>WO #</th>
|
||||
<th>Description</th>
|
||||
<th>Site</th>
|
||||
<th>Last Action</th>
|
||||
<th>Status</th>
|
||||
<th>Due Date</th>
|
||||
<th>Updated</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows if rows else '<tr><td colspan="7" style="padding:40px;text-align:center;color:#94a3b8;">No work orders yet.</td></tr>'}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
|
||||
def handler(event, context):
|
||||
path = event.get("rawPath", "/")
|
||||
qs = event.get("queryStringParameters") or {}
|
||||
|
||||
if path == "/wo" and "id" in qs:
|
||||
wo_id = qs["id"]
|
||||
# Get work order
|
||||
table = dynamodb.Table(WORK_ORDERS_TABLE)
|
||||
result = table.get_item(Key={"work_order_id": wo_id})
|
||||
wo = result.get("Item")
|
||||
if not wo:
|
||||
return {
|
||||
"statusCode": 404,
|
||||
"headers": {"Content-Type": "text/html"},
|
||||
"body": "<h1>Work order not found</h1>",
|
||||
}
|
||||
events = get_comments(wo_id)
|
||||
html = render_work_order_detail(wo, events)
|
||||
else:
|
||||
work_orders = get_work_orders()
|
||||
html = render_work_orders_list(work_orders)
|
||||
|
||||
return {
|
||||
"statusCode": 200,
|
||||
"headers": {"Content-Type": "text/html"},
|
||||
"body": html,
|
||||
}
|
||||
1
lambdas/web_ui/requirements.txt
Normal file
1
lambdas/web_ui/requirements.txt
Normal file
|
|
@ -0,0 +1 @@
|
|||
boto3>=1.35.0
|
||||
2
requirements.txt
Normal file
2
requirements.txt
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
aws-cdk-lib>=2.170.0
|
||||
constructs>=10.0.0
|
||||
0
shared/__init__.py
Normal file
0
shared/__init__.py
Normal file
82
shared/models.py
Normal file
82
shared/models.py
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
"""Data models for work order ingestion."""
|
||||
|
||||
from dataclasses import dataclass, field, asdict
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class EmailType(str, Enum):
|
||||
NEW_WORK_ORDER = "new_work_order"
|
||||
UPDATE = "update"
|
||||
COMMENT = "comment"
|
||||
UNKNOWN = "unknown"
|
||||
|
||||
|
||||
class WorkOrderStatus(str, Enum):
|
||||
NEW = "new"
|
||||
ASSIGNED = "assigned"
|
||||
IN_PROGRESS = "in_progress"
|
||||
ON_HOLD = "on_hold"
|
||||
COMPLETED = "completed"
|
||||
CANCELLED = "cancelled"
|
||||
UNKNOWN = "unknown"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Comment:
|
||||
work_order_id: str
|
||||
comment_id: str # generated: {work_order_id}#{timestamp}
|
||||
commenter: str
|
||||
text: str
|
||||
created_at: str # ISO 8601
|
||||
source_email_s3_key: str
|
||||
ingested_at: str = field(default_factory=lambda: datetime.utcnow().isoformat())
|
||||
|
||||
def to_dynamo_item(self) -> dict:
|
||||
return {k: v for k, v in asdict(self).items() if v is not None}
|
||||
|
||||
|
||||
@dataclass
|
||||
class WorkOrder:
|
||||
work_order_id: str
|
||||
description: Optional[str] = None
|
||||
status: str = WorkOrderStatus.UNKNOWN.value
|
||||
customer: str = "AMAZON"
|
||||
site_code: Optional[str] = None
|
||||
building: Optional[str] = None
|
||||
address: Optional[str] = None
|
||||
severity: Optional[str] = None
|
||||
priority: Optional[str] = None
|
||||
date_reported: Optional[str] = None
|
||||
scheduled_start: Optional[str] = None
|
||||
due_date: Optional[str] = None
|
||||
assigned_to: Optional[str] = None
|
||||
source_email_s3_key: Optional[str] = None
|
||||
created_at: str = field(default_factory=lambda: datetime.utcnow().isoformat())
|
||||
updated_at: str = field(default_factory=lambda: datetime.utcnow().isoformat())
|
||||
|
||||
def to_dynamo_item(self) -> dict:
|
||||
return {k: v for k, v in asdict(self).items() if v is not None}
|
||||
|
||||
|
||||
@dataclass
|
||||
class ParsedEmail:
|
||||
"""Result of AI parsing an inbound email."""
|
||||
email_type: str # EmailType value
|
||||
work_order_id: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
status: Optional[str] = None
|
||||
site_code: Optional[str] = None
|
||||
building: Optional[str] = None
|
||||
address: Optional[str] = None
|
||||
severity: Optional[str] = None
|
||||
priority: Optional[str] = None
|
||||
date_reported: Optional[str] = None
|
||||
scheduled_start: Optional[str] = None
|
||||
due_date: Optional[str] = None
|
||||
assigned_to: Optional[str] = None
|
||||
commenter: Optional[str] = None
|
||||
comment_text: Optional[str] = None
|
||||
comment_time: Optional[str] = None
|
||||
raw_subject: Optional[str] = None
|
||||
48
test_local.py
Normal file
48
test_local.py
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Local test script - parses sample emails through Claude without AWS.
|
||||
|
||||
Usage:
|
||||
export ANTHROPIC_API_KEY=sk-ant-...
|
||||
python test_local.py
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Add project root to path so we can import the handler's parsing logic
|
||||
sys.path.insert(0, str(Path(__file__).parent / "lambdas" / "email_processor"))
|
||||
|
||||
from handler import parse_raw_email, extract_with_claude
|
||||
|
||||
|
||||
def main():
|
||||
samples_dir = Path(__file__).parent / "samples"
|
||||
eml_files = list(samples_dir.glob("*.eml"))
|
||||
|
||||
if not eml_files:
|
||||
print("No .eml files found in samples/")
|
||||
return
|
||||
|
||||
for eml_path in eml_files:
|
||||
print(f"\n{'='*80}")
|
||||
print(f"FILE: {eml_path.name}")
|
||||
print(f"{'='*80}")
|
||||
|
||||
raw = eml_path.read_bytes()
|
||||
email_data = parse_raw_email(raw)
|
||||
|
||||
print(f"Subject: {email_data['subject']}")
|
||||
print(f"From: {email_data['sender']}")
|
||||
print(f"Date: {email_data['date']}")
|
||||
print(f"Body preview: {email_data['body'][:200]}...")
|
||||
print()
|
||||
|
||||
print("Sending to Claude for extraction...")
|
||||
parsed = extract_with_claude(email_data)
|
||||
print(json.dumps(parsed, indent=2))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Loading…
Add table
Reference in a new issue