Add Amazon site assignments lookup via DynamoDB
- Create SiteAssignments DynamoDB table with state GSI for site code and state-based queries - Add lookup_site function to wo-po-lookup action group lambda - Add seed script (scripts/seed-sites.ts) to load site CSV into DynamoDB - Upload site list markdown to KB S3 bucket for semantic search - Update agent instruction to include site lookup capability - Update README with site assignment docs and maintenance notes
This commit is contained in:
parent
8cb762d055
commit
fb026dfd72
6 changed files with 1655 additions and 7 deletions
17
README.md
17
README.md
|
|
@ -1,6 +1,6 @@
|
|||
# Seahaven Slack Bot
|
||||
|
||||
Internal Slack DM assistant for Sea Haven Industries, powered by AWS Bedrock. Employees can ask questions about vendors, company policies, SOPs, and SA8000 compliance via direct message.
|
||||
Internal Slack DM assistant for Sea Haven Industries, powered by AWS Bedrock. Employees can ask questions about vendors, company policies, SOPs, SA8000 compliance, work orders, purchase orders, and Amazon site assignments via direct message.
|
||||
|
||||
## Architecture
|
||||
|
||||
|
|
@ -18,9 +18,10 @@ slack-processor Lambda ← calls Bedrock Agent, logs to DynamoDB, posts r
|
|||
│
|
||||
▼
|
||||
Bedrock Agent (Claude Sonnet 4.5)
|
||||
├── Knowledge Base (AOSS + S3) ← SA8000 docs, SOPs, employee handbook, Notion pages, POs, work orders
|
||||
├── Knowledge Base (AOSS + S3) ← SA8000 docs, SOPs, employee handbook, Notion pages, POs, work orders, site list
|
||||
├── QBO_Lookup action group ← QuickBooks vendor search
|
||||
└── Google_Maps_Lookup action group ← fallback vendor search
|
||||
├── Google_Maps_Lookup action group ← fallback vendor search
|
||||
└── WO_PO_Lookup action group ← work order, purchase order, and site lookups (DynamoDB direct)
|
||||
|
||||
EventBridge (daily 02:00 UTC)
|
||||
│
|
||||
|
|
@ -46,6 +47,7 @@ EventBridge (daily 02:00 UTC)
|
|||
| Conversation Log | DynamoDB `seahaven-conversations` (90-day TTL) |
|
||||
| PO Data Source | DynamoDB `purchase-orders` (via po-ingest) |
|
||||
| Work Order Data Source | DynamoDB `WorkOrders` + `WorkOrderComments` (via workorder-ingest) |
|
||||
| Site Assignments | DynamoDB `SiteAssignments` (seeded from CSV via `scripts/seed-sites.ts`) |
|
||||
| Webhook URL | `https://bot.seahaven.com/slack/events` |
|
||||
|
||||
## Prerequisites
|
||||
|
|
@ -142,7 +144,7 @@ lib/
|
|||
seahaven-slack-bot-stack.ts Main stack
|
||||
constructs/
|
||||
knowledge-base.ts Bedrock KB + AOSS + S3 (via @cdklabs L2 construct)
|
||||
bedrock-agent.ts Bedrock Agent + QBO/Maps action groups
|
||||
bedrock-agent.ts Bedrock Agent + QBO/Maps/WO-PO-Site action groups
|
||||
slack-handler.ts API Gateway + webhook/processor Lambdas
|
||||
conversation-log.ts DynamoDB table
|
||||
notion-sync.ts EventBridge daily cron + notion-sync Lambda + Secrets Manager
|
||||
|
|
@ -153,11 +155,13 @@ lambda/
|
|||
slack-processor/ Calls agent, writes DynamoDB, posts Slack reply
|
||||
qbo-lookup/ Bedrock action group — QuickBooks vendor search
|
||||
maps-lookup/ Bedrock action group — Google Maps Places search
|
||||
wo-po-lookup/ Bedrock action group — WO, PO, and site code lookups (DynamoDB direct)
|
||||
notion-sync/ Daily Notion → S3 export, triggers KB ingestion
|
||||
po-sync/ Daily purchase-orders DynamoDB → S3 export, triggers KB ingestion
|
||||
workorder-sync/ Daily WorkOrders DynamoDB → S3 export, triggers KB ingestion
|
||||
scripts/
|
||||
create-aoss-index.ts Manual fallback for AOSS index creation (not needed in normal deploy)
|
||||
seed-sites.ts Load Amazon site assignments CSV into DynamoDB
|
||||
```
|
||||
|
||||
## Known Maintenance Items
|
||||
|
|
@ -166,4 +170,9 @@ scripts/
|
|||
- **Notion sync** runs daily at 02:00 UTC automatically. To trigger an immediate sync, invoke `seahaven-notion-sync` manually via the Lambda console or CLI.
|
||||
- **PO sync** runs daily at 02:00 UTC. Scans the `purchase-orders` DynamoDB table (from [po-ingest](https://github.com/Sea-Haven-Industries/po-ingest)) and exports each PO as markdown to the KB. Manual trigger: invoke `seahaven-po-sync`.
|
||||
- **Work order sync** runs daily at 02:00 UTC. Scans `WorkOrders` and `WorkOrderComments` DynamoDB tables (from [workorder-ingest](https://github.com/Sea-Haven-Industries/workorder-ingest)) and exports each work order + comment history as markdown to the KB. Manual trigger: invoke `seahaven-workorder-sync`.
|
||||
- **Site assignments** are stored in the `SiteAssignments` DynamoDB table. To update, re-run the seed script with the latest CSV:
|
||||
```bash
|
||||
npx tsx scripts/seed-sites.ts path/to/updated-site-list.csv
|
||||
```
|
||||
The site markdown file in the KB bucket (`sites/amazon-site-assignments.md`) should also be regenerated and re-uploaded, then trigger a KB ingestion job.
|
||||
- **KB sync for manual S3 uploads** (non-Notion docs) must still be triggered manually after uploading new documents to the S3 bucket.
|
||||
|
|
|
|||
|
|
@ -190,6 +190,64 @@ async function lookupPurchaseOrder(poNumber: string): Promise<string> {
|
|||
return lines.join('\n');
|
||||
}
|
||||
|
||||
// ── Site lookup ─────────────────────────────────────────────────────────────
|
||||
|
||||
async function lookupSite(siteCode: string, state?: string): Promise<string> {
|
||||
// Exact site code lookup
|
||||
if (siteCode) {
|
||||
const res = await dynamo.send(
|
||||
new GetItemCommand({
|
||||
TableName: process.env.SITES_TABLE!,
|
||||
Key: { siteCode: { S: siteCode.toUpperCase() } },
|
||||
}),
|
||||
);
|
||||
|
||||
if (res.Item) {
|
||||
const s = unmarshall(res.Item) as Record<string, any>;
|
||||
const lines: string[] = [];
|
||||
lines.push(`SITE ${s.siteCode}`);
|
||||
lines.push(`Address: ${s.address === 'TBD' ? 'TBD (not yet established)' : s.fullAddress || s.address}`);
|
||||
if (s.city) lines.push(`City: ${s.city}`);
|
||||
if (s.state) lines.push(`State: ${s.state}`);
|
||||
if (s.zip) lines.push(`Zip: ${s.zip}`);
|
||||
if (s.latitude && s.longitude) lines.push(`Coordinates: ${s.latitude}, ${s.longitude}`);
|
||||
if (s.notes) lines.push(`Notes: ${s.notes}`);
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
return `No site found for code "${siteCode}". Please provide a valid Amazon site code (e.g., "ABE2", "DFW6").`;
|
||||
}
|
||||
|
||||
// State-based search via GSI
|
||||
if (state) {
|
||||
const res = await dynamo.send(
|
||||
new QueryCommand({
|
||||
TableName: process.env.SITES_TABLE!,
|
||||
IndexName: 'by-state',
|
||||
KeyConditionExpression: '#st = :state',
|
||||
ExpressionAttributeNames: { '#st': 'state' },
|
||||
ExpressionAttributeValues: { ':state': { S: state.toUpperCase() } },
|
||||
}),
|
||||
);
|
||||
|
||||
const sites = (res.Items ?? []).map((item) => unmarshall(item) as Record<string, any>);
|
||||
if (sites.length === 0) {
|
||||
return `No sites found in state "${state}".`;
|
||||
}
|
||||
|
||||
sites.sort((a, b) => (a.siteCode ?? '').localeCompare(b.siteCode ?? ''));
|
||||
const lines = [`Sites in ${state.toUpperCase()} (${sites.length} total):`];
|
||||
for (const s of sites) {
|
||||
const addr = s.address === 'TBD' ? 'TBD' : `${s.address}, ${s.city}`;
|
||||
const note = s.notes ? ` — ${s.notes}` : '';
|
||||
lines.push(`- ${s.siteCode}: ${addr}${note}`);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
return 'Please provide a site code (e.g., "ABE2") or a state abbreviation (e.g., "TX") to search.';
|
||||
}
|
||||
|
||||
// ── Handler ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export const handler = async (event: BedrockActionEvent): Promise<BedrockActionResponse> => {
|
||||
|
|
@ -208,6 +266,11 @@ export const handler = async (event: BedrockActionEvent): Promise<BedrockActionR
|
|||
const poNum = params.po_number?.trim();
|
||||
if (!poNum) return makeResponse(event, 'Please provide a purchase order number.');
|
||||
result = await lookupPurchaseOrder(poNum);
|
||||
} else if (event.function === 'lookup_site') {
|
||||
const code = params.site_code?.trim() ?? '';
|
||||
const state = params.state?.trim();
|
||||
if (!code && !state) return makeResponse(event, 'Please provide a site code or state abbreviation.');
|
||||
result = await lookupSite(code, state);
|
||||
} else {
|
||||
result = `Unknown function: ${event.function}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -79,6 +79,19 @@ export class BedrockAgentConstruct extends Construct {
|
|||
this, 'PurchaseOrdersTable', 'purchase-orders',
|
||||
);
|
||||
|
||||
// Site assignments table — stores Amazon facility site codes and addresses
|
||||
const sitesTable = new dynamodb.Table(this, 'SiteAssignmentsTable', {
|
||||
tableName: 'SiteAssignments',
|
||||
partitionKey: { name: 'siteCode', type: dynamodb.AttributeType.STRING },
|
||||
billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
|
||||
removalPolicy: cdk.RemovalPolicy.RETAIN,
|
||||
});
|
||||
sitesTable.addGlobalSecondaryIndex({
|
||||
indexName: 'by-state',
|
||||
partitionKey: { name: 'state', type: dynamodb.AttributeType.STRING },
|
||||
projectionType: dynamodb.ProjectionType.ALL,
|
||||
});
|
||||
|
||||
this.woPoLambda = new lambdaNodejs.NodejsFunction(this, 'WoPoLookupFn', {
|
||||
functionName: 'seahaven-wo-po-lookup',
|
||||
entry: path.join(__dirname, '../../lambda/wo-po-lookup/index.ts'),
|
||||
|
|
@ -90,12 +103,14 @@ export class BedrockAgentConstruct extends Construct {
|
|||
WORK_ORDERS_TABLE: workOrdersTable.tableName,
|
||||
COMMENTS_TABLE: commentsTable.tableName,
|
||||
PO_TABLE: poTable.tableName,
|
||||
SITES_TABLE: sitesTable.tableName,
|
||||
},
|
||||
bundling,
|
||||
});
|
||||
workOrdersTable.grantReadData(this.woPoLambda);
|
||||
commentsTable.grantReadData(this.woPoLambda);
|
||||
poTable.grantReadData(this.woPoLambda);
|
||||
sitesTable.grantReadData(this.woPoLambda);
|
||||
|
||||
// ── Bedrock Agent execution role ──────────────────────────────────────────
|
||||
const agentRole = new iam.Role(this, 'AgentRole', {
|
||||
|
|
@ -149,6 +164,7 @@ You help employees with:
|
|||
3. Employee handbook questions
|
||||
4. Work order lookups — status, history, assignments, comments, and details for any work order by its WO number
|
||||
5. Purchase order lookups — status, line items, suppliers, ship-to details, and dates for any PO by its PO number
|
||||
6. Amazon site lookups — address, location, and details for any Amazon facility by its site code (e.g., "ABE2", "DFW6"), or listing all sites in a given state
|
||||
|
||||
## Vendor Query Rules — STRICTLY follow this priority order:
|
||||
|
||||
|
|
@ -166,6 +182,9 @@ When presenting vendor results:
|
|||
## Work Order & Purchase Order Queries:
|
||||
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 the returned data clearly and concisely. The output is already well-structured — relay the key information without adding excessive formatting or repeating section headers verbatim. Summarize the current status and most recent updates first, then include the full comment history.
|
||||
|
||||
## Site Lookups:
|
||||
When a user asks about an Amazon site by its code (e.g., "ABE2", "DFW6", "WWY1"), ALWAYS use the WO_PO_Lookup.lookup_site function. You can also list all sites in a state by passing the state abbreviation. Do NOT use the knowledge base for site code lookups — the action group queries the database directly and is more reliable.
|
||||
|
||||
## 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.
|
||||
|
||||
|
|
@ -241,7 +260,7 @@ 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',
|
||||
description: 'Look up work orders, purchase orders, and Amazon site assignments directly from the database',
|
||||
actionGroupState: 'ENABLED',
|
||||
actionGroupExecutor: { lambda: this.woPoLambda.functionArn },
|
||||
functionSchema: {
|
||||
|
|
@ -268,6 +287,22 @@ Keep responses concise, professional, and actionable.`;
|
|||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'lookup_site',
|
||||
description: 'Look up an Amazon facility site by its site code or list all sites in a state. Returns address, city, state, coordinates, and notes.',
|
||||
parameters: {
|
||||
site_code: {
|
||||
type: 'string',
|
||||
description: 'The Amazon site code (e.g., "ABE2", "DFW6", "WWY1")',
|
||||
required: false,
|
||||
},
|
||||
state: {
|
||||
type: 'string',
|
||||
description: 'Two-letter state abbreviation to list all sites in that state (e.g., "TX", "CA", "GA")',
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
|
|
@ -279,7 +314,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 v7 (identity + formatting)',
|
||||
description: 'Production alias — seahaven-assistant v8 (site lookups)',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
1457
package-lock.json
generated
1457
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -11,6 +11,8 @@
|
|||
"synth": "cdk synth"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@aws-sdk/client-dynamodb": "^3.1030.0",
|
||||
"@aws-sdk/lib-dynamodb": "^3.1030.0",
|
||||
"@types/aws-lambda": "^8.10.149",
|
||||
"@types/node": "^22.0.0",
|
||||
"aws-cdk": "^2.180.0",
|
||||
|
|
|
|||
84
scripts/seed-sites.ts
Normal file
84
scripts/seed-sites.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
/**
|
||||
* Seed the SiteAssignments DynamoDB table from the Amazon site list CSV.
|
||||
*
|
||||
* Usage:
|
||||
* npx tsx scripts/seed-sites.ts path/to/amazon-sites.csv
|
||||
*/
|
||||
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
|
||||
import { DynamoDBDocumentClient, BatchWriteCommand } from '@aws-sdk/lib-dynamodb';
|
||||
import { readFileSync } from 'fs';
|
||||
|
||||
const TABLE_NAME = 'SiteAssignments';
|
||||
const REGION = 'us-east-1';
|
||||
|
||||
const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({ region: REGION }));
|
||||
|
||||
function parseCSV(filePath: string) {
|
||||
const content = readFileSync(filePath, 'utf-8');
|
||||
const lines = content.split('\n').filter((l) => l.trim());
|
||||
return lines.slice(1).map((line) => {
|
||||
const cols = line.split(',');
|
||||
return {
|
||||
siteCode: (cols[0] ?? '').trim(),
|
||||
address: (cols[1] ?? '').trim(),
|
||||
city: (cols[2] ?? '').trim(),
|
||||
state: (cols[3] ?? '').trim(),
|
||||
zip: (cols[4] ?? '').trim(),
|
||||
fullAddress: (cols[5] ?? '').trim(),
|
||||
latitude: (cols[6] ?? '').trim(),
|
||||
longitude: (cols[7] ?? '').trim(),
|
||||
notes: (cols[9] ?? '').trim(),
|
||||
};
|
||||
}).filter((r) => r.siteCode);
|
||||
}
|
||||
|
||||
async function seed(filePath: string) {
|
||||
const allRows = parseCSV(filePath);
|
||||
// Deduplicate by siteCode — last occurrence wins
|
||||
const deduped = new Map(allRows.map((r) => [r.siteCode, r]));
|
||||
const rows = [...deduped.values()];
|
||||
console.log(`Parsed ${allRows.length} rows, ${rows.length} unique sites`);
|
||||
|
||||
let written = 0;
|
||||
for (let i = 0; i < rows.length; i += 25) {
|
||||
const batch = rows.slice(i, i + 25);
|
||||
const requests = batch.map((row) => ({
|
||||
PutRequest: {
|
||||
Item: {
|
||||
siteCode: row.siteCode,
|
||||
address: row.address,
|
||||
city: row.city,
|
||||
state: row.state,
|
||||
...(row.zip && { zip: row.zip }),
|
||||
fullAddress: row.fullAddress,
|
||||
...(row.latitude && { latitude: row.latitude }),
|
||||
...(row.longitude && { longitude: row.longitude }),
|
||||
...(row.notes && { notes: row.notes }),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
await ddb.send(
|
||||
new BatchWriteCommand({
|
||||
RequestItems: { [TABLE_NAME]: requests },
|
||||
}),
|
||||
);
|
||||
written += batch.length;
|
||||
if (written % 250 === 0 || written === rows.length) {
|
||||
console.log(` Written ${written}/${rows.length}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Done — seeded ${written} sites into ${TABLE_NAME}`);
|
||||
}
|
||||
|
||||
const csvPath = process.argv[2];
|
||||
if (!csvPath) {
|
||||
console.error('Usage: npx tsx scripts/seed-sites.ts <path-to-csv>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
seed(csvPath).catch((err) => {
|
||||
console.error('Seed failed:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in a new issue