Add WO/PO direct lookup action group for reliable ID-based queries

Vector search couldn't match exact work order/PO numbers, so queries
always came back empty. This adds a dedicated lambda that queries
DynamoDB directly by ID, wired as a Bedrock Agent action group.
This commit is contained in:
Adam Moussa 2026-04-13 17:59:47 -04:00
parent 5943b775eb
commit 24ebe8bdcc
2 changed files with 259 additions and 2 deletions

View file

@ -0,0 +1,190 @@
import { DynamoDBClient, GetItemCommand, QueryCommand } from '@aws-sdk/client-dynamodb';
import { unmarshall } from '@aws-sdk/util-dynamodb';
const dynamo = new DynamoDBClient({});
// ── Bedrock action group event / response types ───────────────────────────────
interface ActionParameter {
name: string;
type: string;
value: string;
}
interface BedrockActionEvent {
messageVersion: string;
agent: { name: string; id: string; alias: string; version: string };
inputText: string;
sessionId: string;
actionGroup: string;
function: string;
parameters?: ActionParameter[];
sessionAttributes: Record<string, string>;
promptSessionAttributes: Record<string, string>;
}
interface BedrockActionResponse {
messageVersion: string;
response: {
actionGroup: string;
function: string;
functionResponse: {
responseBody: { TEXT: { body: string } };
};
};
}
function makeResponse(event: BedrockActionEvent, body: string): BedrockActionResponse {
return {
messageVersion: '1.0',
response: {
actionGroup: event.actionGroup,
function: event.function,
functionResponse: { responseBody: { TEXT: { body } } },
},
};
}
// ── Work Order lookup ────────────────────────────────────────────────────────
async function lookupWorkOrder(workOrderId: string): Promise<string> {
const woRes = await dynamo.send(
new GetItemCommand({
TableName: process.env.WORK_ORDERS_TABLE!,
Key: { work_order_id: { S: workOrderId } },
}),
);
if (!woRes.Item) {
return `Work order ${workOrderId} was not found.`;
}
const wo = unmarshall(woRes.Item) as Record<string, any>;
const lines: string[] = [`Work Order: ${wo.work_order_id}`];
if (wo.description) lines.push(`Description: ${wo.description}`);
if (wo.wo_status) lines.push(`Status: ${wo.wo_status}`);
if (wo.customer) lines.push(`Customer: ${wo.customer}`);
if (wo.site_code) lines.push(`Site Code: ${wo.site_code}`);
if (wo.building) lines.push(`Building: ${wo.building}`);
if (wo.address) lines.push(`Address: ${wo.address}`);
if (wo.severity) lines.push(`Severity: ${wo.severity}`);
if (wo.priority) lines.push(`Priority: ${wo.priority}`);
if (wo.assigned_to) lines.push(`Assigned To: ${wo.assigned_to}`);
if (wo.date_reported) lines.push(`Date Reported: ${wo.date_reported}`);
if (wo.scheduled_start) lines.push(`Scheduled Start: ${wo.scheduled_start}`);
if (wo.due_date) lines.push(`Due Date: ${wo.due_date}`);
if (wo.created_at) lines.push(`Created At: ${wo.created_at}`);
if (wo.updated_at) lines.push(`Updated At: ${wo.updated_at}`);
// Fetch comments
const commentsRes = await dynamo.send(
new QueryCommand({
TableName: process.env.COMMENTS_TABLE!,
KeyConditionExpression: 'work_order_id = :woid',
ExpressionAttributeValues: { ':woid': { S: workOrderId } },
}),
);
const comments = (commentsRes.Items ?? [])
.map((item) => unmarshall(item) as Record<string, any>)
.sort((a, b) => (a.created_at ?? '').localeCompare(b.created_at ?? ''));
if (comments.length > 0) {
lines.push('');
lines.push('Comment History:');
for (const c of comments) {
const ts = c.created_at ?? 'unknown date';
const author = c.commenter ?? 'unknown';
const type = c.record_type ? ` [${c.record_type}]` : '';
lines.push(`- ${ts} — ${author}${type}: ${c.text ?? '(no text)'}`);
}
}
return lines.join('\n');
}
// ── Purchase Order lookup ────────────────────────────────────────────────────
async function lookupPurchaseOrder(poNumber: string): Promise<string> {
const poRes = await dynamo.send(
new GetItemCommand({
TableName: process.env.PO_TABLE!,
Key: { po_number: { S: poNumber } },
}),
);
if (!poRes.Item) {
return `Purchase order ${poNumber} was not found.`;
}
const po = unmarshall(poRes.Item) as Record<string, any>;
const lines: string[] = [`Purchase Order: ${po.po_number}`];
if (po.po_status) lines.push(`Status: ${po.po_status}`);
if (po.email_type === 'cancellation') lines.push(`Cancelled: ${po.cancelled_at ?? 'yes'}`);
if (po.source_system) lines.push(`Source: ${po.source_system}`);
if (po.order_date) lines.push(`Order Date: ${po.order_date}`);
if (po.revision_date) lines.push(`Revision Date: ${po.revision_date}`);
if (po.payment_terms) lines.push(`Payment Terms: ${po.payment_terms}`);
if (po.requisition_number) lines.push(`Requisition #: ${po.requisition_number}`);
if (po.department) lines.push(`Department: ${po.department}`);
if (po.submitted_by) lines.push(`Submitted By: ${po.submitted_by}`);
if (po.on_behalf_of) lines.push(`On Behalf Of: ${po.on_behalf_of}`);
if (po.total_amount !== undefined) lines.push(`Total: ${po.currency ?? 'USD'} ${po.total_amount}`);
if (po.supplier?.name) lines.push(`Supplier: ${po.supplier.name}`);
const ship = po.ship_to;
if (ship) {
const parts: string[] = [];
if (ship.name) parts.push(ship.name);
if (ship.address) parts.push(ship.address);
if (ship.location_code) parts.push(`(${ship.location_code})`);
if (ship.attn) parts.push(`Attn: ${ship.attn}`);
if (parts.length > 0) lines.push(`Ship To: ${parts.join(', ')}`);
}
const items = po.line_items;
if (Array.isArray(items) && items.length > 0) {
lines.push('');
lines.push('Line Items:');
for (const li of items) {
const desc = li.description ?? 'No description';
const amt = li.amount ? ` — ${li.currency ?? 'USD'} ${li.amount}` : '';
const needBy = li.need_by ? ` (need by ${li.need_by})` : '';
lines.push(`- ${desc}${amt}${needBy}`);
}
}
return lines.join('\n');
}
// ── Handler ──────────────────────────────────────────────────────────────────
export const handler = async (event: BedrockActionEvent): Promise<BedrockActionResponse> => {
const params = Object.fromEntries(
(event.parameters ?? []).map((p) => [p.name, p.value]),
);
try {
let result: string;
if (event.function === 'lookup_work_order') {
const woId = params.work_order_id?.trim();
if (!woId) return makeResponse(event, 'Please provide a work order ID.');
result = await lookupWorkOrder(woId);
} else if (event.function === 'lookup_purchase_order') {
const poNum = params.po_number?.trim();
if (!poNum) return makeResponse(event, 'Please provide a purchase order number.');
result = await lookupPurchaseOrder(poNum);
} else {
result = `Unknown function: ${event.function}`;
}
return makeResponse(event, result);
} catch (err) {
console.error('Lookup error:', err);
return makeResponse(event, `Lookup failed: ${(err as Error).message}`);
}
};

View file

@ -3,6 +3,7 @@ import { Construct } from 'constructs';
import * as iam from 'aws-cdk-lib/aws-iam';
import * as lambda from 'aws-cdk-lib/aws-lambda';
import * as lambdaNodejs from 'aws-cdk-lib/aws-lambda-nodejs';
import * as dynamodb from 'aws-cdk-lib/aws-dynamodb';
import * as secretsmanager from 'aws-cdk-lib/aws-secretsmanager';
import * as bedrock from 'aws-cdk-lib/aws-bedrock';
import * as path from 'path';
@ -19,6 +20,7 @@ export class BedrockAgentConstruct extends Construct {
public readonly agentAlias: bedrock.CfnAgentAlias;
public readonly qboLambda: lambdaNodejs.NodejsFunction;
public readonly mapsLambda: lambdaNodejs.NodejsFunction;
public readonly woPoLambda: lambdaNodejs.NodejsFunction;
// Cross-region inference profile — required for Claude 4.x on Bedrock Agents
private static readonly MODEL_ID = 'us.anthropic.claude-sonnet-4-5-20250929-v1:0';
@ -66,6 +68,35 @@ export class BedrockAgentConstruct extends Construct {
});
mapsSecret.grantRead(this.mapsLambda);
// ── WO/PO direct lookup action group Lambda ──────────────────────────────
const workOrdersTable = dynamodb.Table.fromTableName(
this, 'WorkOrdersTable', 'WorkOrders',
);
const commentsTable = dynamodb.Table.fromTableName(
this, 'WorkOrderCommentsTable', 'WorkOrderComments',
);
const poTable = dynamodb.Table.fromTableName(
this, 'PurchaseOrdersTable', 'purchase-orders',
);
this.woPoLambda = new lambdaNodejs.NodejsFunction(this, 'WoPoLookupFn', {
functionName: 'seahaven-wo-po-lookup',
entry: path.join(__dirname, '../../lambda/wo-po-lookup/index.ts'),
handler: 'handler',
runtime: lambda.Runtime.NODEJS_22_X,
timeout: cdk.Duration.seconds(30),
memorySize: 256,
environment: {
WORK_ORDERS_TABLE: workOrdersTable.tableName,
COMMENTS_TABLE: commentsTable.tableName,
PO_TABLE: poTable.tableName,
},
bundling,
});
workOrdersTable.grantReadData(this.woPoLambda);
commentsTable.grantReadData(this.woPoLambda);
poTable.grantReadData(this.woPoLambda);
// ── Bedrock Agent execution role ──────────────────────────────────────────
const agentRole = new iam.Role(this, 'AgentRole', {
roleName: 'AmazonBedrockExecutionRoleForAgents_seahaven',
@ -102,6 +133,10 @@ export class BedrockAgentConstruct extends Construct {
principal: new iam.ServicePrincipal('bedrock.amazonaws.com'),
sourceAccount: props.accountId,
});
this.woPoLambda.addPermission('BedrockInvokeWoPo', {
principal: new iam.ServicePrincipal('bedrock.amazonaws.com'),
sourceAccount: props.accountId,
});
// ── Agent instruction (system prompt) ────────────────────────────────────
const instruction = `You are the Sea Haven Industries internal assistant, accessible to employees via Slack direct message. Sea Haven is a facility services company.
@ -127,7 +162,7 @@ When presenting vendor results:
- Google Maps vendors: clearly label these as NEW (not yet vetted), include name, address, phone, and rating
## Work Order & Purchase Order Queries:
Use the knowledge base to look up work orders (WO) and purchase orders (PO). Work order documents include status, description, site/building, severity, priority, assignment, dates, and comment history. Purchase order documents include status, supplier, ship-to, line items, amounts, and dates. When a user asks about a WO or PO number, search the knowledge base and return the relevant details. If no matching record is found, say so clearly.
When a user asks about a work order (WO) or purchase order (PO) by number, ALWAYS use the WO_PO_Lookup action group to retrieve the record directly. Do NOT use the knowledge base for WO/PO lookups by number — the action group queries the database directly and is more reliable. Present all returned details including status, dates, assignments, and comment history.
## General Questions:
Use the knowledge base for policy, SOP, SA8000 compliance, and handbook questions. Cite the specific document or section when possible. If the information is not in the knowledge base, say so clearly — do not guess.
@ -202,6 +237,38 @@ Keep responses concise, professional, and actionable.`;
],
},
},
{
actionGroupName: 'WO_PO_Lookup',
description: 'Look up work orders and purchase orders by their ID/number directly from the database',
actionGroupState: 'ENABLED',
actionGroupExecutor: { lambda: this.woPoLambda.functionArn },
functionSchema: {
functions: [
{
name: 'lookup_work_order',
description: 'Look up a work order by its ID number. Returns status, description, site, assignment, dates, and full comment history.',
parameters: {
work_order_id: {
type: 'string',
description: 'The work order ID number (e.g., "10046966057")',
required: true,
},
},
},
{
name: 'lookup_purchase_order',
description: 'Look up a purchase order by its PO number. Returns status, supplier, ship-to, line items, amounts, and dates.',
parameters: {
po_number: {
type: 'string',
description: 'The purchase order number (e.g., "2D-20023475")',
required: true,
},
},
},
],
},
},
],
});
@ -210,7 +277,7 @@ Keep responses concise, professional, and actionable.`;
this.agentAlias = new bedrock.CfnAgentAlias(this, 'AgentAlias', {
agentId: this.agent.attrAgentId,
agentAliasName: 'live',
description: 'Production alias — seahaven-assistant v4 (WO + PO lookups)',
description: 'Production alias — seahaven-assistant v5 (WO + PO direct lookup)',
});
}
}