2026-04-11 23:15:15 -04:00
import * as cdk from 'aws-cdk-lib' ;
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' ;
2026-04-30 16:38:54 -04:00
import * as logs from 'aws-cdk-lib/aws-logs' ;
2026-04-13 17:59:47 -04:00
import * as dynamodb from 'aws-cdk-lib/aws-dynamodb' ;
2026-06-09 12:33:06 -04:00
import * as kms from 'aws-cdk-lib/aws-kms' ;
import * as ssm from 'aws-cdk-lib/aws-ssm' ;
2026-04-13 19:51:00 -04:00
import * as ec2 from 'aws-cdk-lib/aws-ec2' ;
2026-04-11 23:15:15 -04:00
import * as secretsmanager from 'aws-cdk-lib/aws-secretsmanager' ;
import * as bedrock from 'aws-cdk-lib/aws-bedrock' ;
import * as path from 'path' ;
export interface BedrockAgentProps {
accountId : string ;
region : string ;
knowledgeBaseId : string ;
knowledgeBaseArn : string ;
2026-04-13 19:51:00 -04:00
vpc : ec2.IVpc ;
lambdaSecurityGroup : ec2.ISecurityGroup ;
2026-04-11 23:15:15 -04:00
}
export class BedrockAgentConstruct extends Construct {
public readonly agent : bedrock.CfnAgent ;
public readonly agentAlias : bedrock.CfnAgentAlias ;
public readonly qboLambda : lambdaNodejs.NodejsFunction ;
public readonly mapsLambda : lambdaNodejs.NodejsFunction ;
2026-04-13 17:59:47 -04:00
public readonly woPoLambda : lambdaNodejs.NodejsFunction ;
2026-04-11 23:15:15 -04:00
2026-04-11 23:52:44 -04:00
// 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' ;
2026-04-11 23:15:15 -04:00
constructor ( scope : Construct , id : string , props : BedrockAgentProps ) {
super ( scope , id ) ;
// Reference secrets created manually in Secrets Manager (see README for structure)
const qboSecret = secretsmanager . Secret . fromSecretNameV2 (
this , 'QBOSecret' , 'seahaven/qbo/oauth' ,
) ;
const mapsSecret = secretsmanager . Secret . fromSecretNameV2 (
this , 'MapsSecret' , 'seahaven/google/maps-api-key' ,
) ;
const bundling : lambdaNodejs.BundlingOptions = {
externalModules : [ '@aws-sdk/*' ] ,
minify : true ,
sourceMap : false ,
} ;
// ── QBO vendor lookup action group Lambda ─────────────────────────────────
this . qboLambda = new lambdaNodejs . NodejsFunction ( this , 'QBOLookupFn' , {
functionName : 'seahaven-qbo-lookup' ,
entry : path.join ( __dirname , '../../lambda/qbo-lookup/index.ts' ) ,
handler : 'handler' ,
runtime : lambda.Runtime.NODEJS_22_X ,
2026-04-30 16:38:54 -04:00
architecture : lambda.Architecture.ARM_64 ,
logRetention : logs.RetentionDays.TWO_MONTHS ,
2026-04-11 23:15:15 -04:00
timeout : cdk.Duration.seconds ( 30 ) ,
memorySize : 256 ,
environment : { QBO_SECRET_ARN : qboSecret.secretArn } ,
2026-04-13 19:51:00 -04:00
vpc : props.vpc ,
vpcSubnets : { subnetType : ec2.SubnetType.PRIVATE_WITH_EGRESS } ,
securityGroups : [ props . lambdaSecurityGroup ] ,
2026-04-11 23:15:15 -04:00
bundling ,
} ) ;
qboSecret . grantRead ( this . qboLambda ) ;
2026-04-13 19:31:13 -04:00
qboSecret . grantWrite ( this . qboLambda ) ;
2026-04-11 23:15:15 -04:00
// ── Google Maps lookup action group Lambda ────────────────────────────────
this . mapsLambda = new lambdaNodejs . NodejsFunction ( this , 'MapsLookupFn' , {
functionName : 'seahaven-maps-lookup' ,
entry : path.join ( __dirname , '../../lambda/maps-lookup/index.ts' ) ,
handler : 'handler' ,
runtime : lambda.Runtime.NODEJS_22_X ,
2026-04-30 16:38:54 -04:00
architecture : lambda.Architecture.ARM_64 ,
logRetention : logs.RetentionDays.TWO_MONTHS ,
2026-04-11 23:15:15 -04:00
timeout : cdk.Duration.seconds ( 30 ) ,
memorySize : 256 ,
environment : { MAPS_SECRET_ARN : mapsSecret.secretArn } ,
bundling ,
} ) ;
mapsSecret . grantRead ( this . mapsLambda ) ;
2026-04-13 17:59:47 -04:00
// ── 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' ,
) ;
2026-04-30 16:38:54 -04:00
const sitesTable = dynamodb . Table . fromTableName (
this , 'VerifiedSitesTable' , 'verified-sites' ,
) ;
const paymentsTable = dynamodb . Table . fromTableName (
this , 'PaymentsDashboardTable' , 'PaymentsDashboard' ,
) ;
2026-04-13 19:11:39 -04:00
2026-04-13 17:59:47 -04:00
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 ,
2026-04-30 16:38:54 -04:00
architecture : lambda.Architecture.ARM_64 ,
logRetention : logs.RetentionDays.TWO_MONTHS ,
2026-04-13 17:59:47 -04:00
timeout : cdk.Duration.seconds ( 30 ) ,
memorySize : 256 ,
environment : {
WORK_ORDERS_TABLE : workOrdersTable.tableName ,
COMMENTS_TABLE : commentsTable.tableName ,
PO_TABLE : poTable.tableName ,
2026-04-13 19:11:39 -04:00
SITES_TABLE : sitesTable.tableName ,
2026-04-30 16:38:54 -04:00
PAYMENTS_TABLE : paymentsTable.tableName ,
2026-04-13 17:59:47 -04:00
} ,
bundling ,
} ) ;
workOrdersTable . grantReadData ( this . woPoLambda ) ;
commentsTable . grantReadData ( this . woPoLambda ) ;
poTable . grantReadData ( this . woPoLambda ) ;
2026-04-13 19:11:39 -04:00
sitesTable . grantReadData ( this . woPoLambda ) ;
2026-04-30 16:38:54 -04:00
paymentsTable . grantReadData ( this . woPoLambda ) ;
2026-04-13 17:59:47 -04:00
2026-06-09 12:33:06 -04:00
// WorkOrders, WorkOrderComments, purchase-orders and PaymentsDashboard are
// SSE-encrypted with the shared customer-managed CMK (INFRA-95 / M-3). These
// tables are imported by name, so grantReadData does not add KMS perms — this
// cross-stack reader needs kms:Decrypt explicitly or every read on those four
// tables fails with AccessDenied. (verified-sites is NOT CMK-encrypted; it is
// unaffected.) The CMK key policy delegates to IAM via kms:ViaService.
const dynamodbCmk = kms . Key . fromKeyArn (
this ,
'DynamoDbCmk' ,
ssm . StringParameter . valueForStringParameter ( this , '/seahaven/dynamodb/cmk-arn' ) ,
) ;
dynamodbCmk . grantDecrypt ( this . woPoLambda ) ;
2026-04-11 23:15:15 -04:00
// ── Bedrock Agent execution role ──────────────────────────────────────────
const agentRole = new iam . Role ( this , 'AgentRole' , {
roleName : 'AmazonBedrockExecutionRoleForAgents_seahaven' ,
assumedBy : new iam . ServicePrincipal ( 'bedrock.amazonaws.com' , {
conditions : {
StringEquals : { 'aws:SourceAccount' : props . accountId } ,
ArnLike : {
'aws:SourceArn' : ` arn:aws:bedrock: ${ props . region } : ${ props . accountId } :agent/* ` ,
} ,
} ,
} ) ,
} ) ;
agentRole . addToPolicy ( new iam . PolicyStatement ( {
2026-04-11 23:52:44 -04:00
actions : [ 'bedrock:InvokeModel' , 'bedrock:InvokeModelWithResponseStream' ] ,
2026-04-11 23:15:15 -04:00
resources : [
2026-04-11 23:52:44 -04:00
// Cross-region inference profile (us.*) routes to multiple regions — wildcard region required
` arn:aws:bedrock:*::foundation-model/anthropic.claude-sonnet-4-5-20250929-v1:0 ` ,
` arn:aws:bedrock: ${ props . region } : ${ props . accountId } :inference-profile/ ${ BedrockAgentConstruct . MODEL_ID } ` ,
2026-04-11 23:15:15 -04:00
] ,
} ) ) ;
agentRole . addToPolicy ( new iam . PolicyStatement ( {
actions : [ 'bedrock:Retrieve' ] ,
resources : [ props . knowledgeBaseArn ] ,
} ) ) ;
// Allow Bedrock to invoke the action group Lambdas
this . qboLambda . addPermission ( 'BedrockInvokeQBO' , {
principal : new iam . ServicePrincipal ( 'bedrock.amazonaws.com' ) ,
sourceAccount : props.accountId ,
} ) ;
this . mapsLambda . addPermission ( 'BedrockInvokeMaps' , {
principal : new iam . ServicePrincipal ( 'bedrock.amazonaws.com' ) ,
sourceAccount : props.accountId ,
} ) ;
2026-04-13 17:59:47 -04:00
this . woPoLambda . addPermission ( 'BedrockInvokeWoPo' , {
principal : new iam . ServicePrincipal ( 'bedrock.amazonaws.com' ) ,
sourceAccount : props.accountId ,
} ) ;
2026-04-11 23:15:15 -04:00
// ── Agent instruction (system prompt) ────────────────────────────────────
2026-04-30 16:38:54 -04:00
const instruction = ` Your name is Alex. You are Sea Haven Industries' internal AI assistant, available to employees via Slack DM or @mention in any channel. You are friendly, concise, and professional — like a helpful coworker, not a robot. Sea Haven is a facility services company that provides maintenance, repair, and facility services to clients like Amazon.
2026-04-13 18:50:35 -04:00
IMPORTANT : You work FOR Sea Haven . Sea Haven is our company — we are the vendor / contractor that gets dispatched to job sites . When work orders or comments mention "Sea Haven" being dispatched or assigned , that means OUR team was sent . Never suggest Sea Haven as an external vendor to contact — we ARE Sea Haven . When users ask for a "local vendor," they mean a subcontractor or specialty trade vendor to handle work on our behalf .
2026-04-11 23:15:15 -04:00
You help employees with :
1 . Finding vendors and contractors for facility work
2 . Company policies , SOPs , and SA8000 social accountability compliance questions
3 . Employee handbook questions
2026-04-13 17:05:26 -04:00
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
2026-07-15 18:49:05 -04:00
6 . Amazon site lookups — address , location , and details for any Amazon facility by its site code ( e . g . , "ABE2" , "DFW6" )
2026-04-30 16:38:54 -04:00
7 . Payment and invoice status — whether a vendor has been paid , if an invoice is scheduled , check details
2026-04-11 23:15:15 -04:00
# # Vendor Query Rules — STRICTLY follow this priority order :
Step 1 : ALWAYS call QBO_Lookup . search_vendors first . This searches our QuickBooks Online account for existing , vetted vendors we already have a relationship with .
Step 2 : If QBO_Lookup returns no suitable match , search the knowledge base for approved vendor documentation or lists .
Step 3 : ONLY if both QBO and the knowledge base return nothing suitable should you call Google_Maps_Lookup . search_nearby_vendors to find new options .
When presenting vendor results :
- QBO vendors : include name , trade / specialty , phone , email , and last updated date
- Knowledge base vendors : cite the source document
- Google Maps vendors : clearly label these as NEW ( not yet vetted ) , include name , address , phone , and rating
2026-04-13 17:05:26 -04:00
# # Work Order & Purchase Order Queries :
2026-04-13 18:34:47 -04:00
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 .
2026-04-13 17:05:26 -04:00
2026-04-30 16:38:54 -04:00
# # Payment & Invoice Queries :
When a user asks about payments , invoices , or whether a vendor has been paid , use the WO_PO_Lookup payment functions . For vendor payment searches , use lookup_payment_by_vendor . For invoice number lookups , use lookup_payment_by_invoice . For check number lookups , use lookup_payment_by_check . Present results clearly : check number , payee , amount , status , method , and relevant dates . Payment records cover the last 90 days .
2026-04-13 19:11:39 -04:00
# # Site Lookups :
2026-07-15 18:49:05 -04:00
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 . Do NOT use the knowledge base for site code lookups — the action group queries the database directly and is more reliable . Site lookups require a site code ; listing sites by state is not supported .
2026-04-13 19:11:39 -04:00
2026-04-11 23:15:15 -04:00
# # 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 .
Keep responses concise , professional , and actionable . ` ;
2026-06-03 15:16:06 -04:00
// ── Guardrail (audit M-19) ────────────────────────────────────────────────
// Scope: prompt-attack + content filters + masking of credential/financial
// identifiers. Names, emails, and phone numbers are deliberately NOT masked —
// returning vendor contacts and payment/WO/PO details is Alex's core job.
const guardrail = new bedrock . CfnGuardrail ( this , 'Guardrail' , {
name : 'seahaven-alex-guardrail' ,
description : 'Prompt-attack, content, and sensitive-identifier guardrail for Alex' ,
blockedInputMessaging :
"Sorry, I can't help with that request. If you think this was blocked in error, contact IT." ,
blockedOutputsMessaging :
'Part of that response was withheld by policy. If you need this information, contact IT.' ,
contentPolicyConfig : {
filtersConfig : [
// outputStrength must be NONE for PROMPT_ATTACK per the Guardrails API
{ type : 'PROMPT_ATTACK' , inputStrength : 'HIGH' , outputStrength : 'NONE' } ,
{ type : 'HATE' , inputStrength : 'HIGH' , outputStrength : 'HIGH' } ,
{ type : 'INSULTS' , inputStrength : 'HIGH' , outputStrength : 'HIGH' } ,
{ type : 'SEXUAL' , inputStrength : 'HIGH' , outputStrength : 'HIGH' } ,
{ type : 'VIOLENCE' , inputStrength : 'HIGH' , outputStrength : 'HIGH' } ,
// Output at MEDIUM: Alex legitimately answers SA8000/HR questions about
// handling misconduct reports; HIGH output filtering suppresses those.
{ type : 'MISCONDUCT' , inputStrength : 'HIGH' , outputStrength : 'MEDIUM' } ,
] ,
} ,
sensitiveInformationPolicyConfig : {
piiEntitiesConfig : [
{ type : 'US_SOCIAL_SECURITY_NUMBER' , action : 'ANONYMIZE' } ,
{ type : 'CREDIT_DEBIT_CARD_NUMBER' , action : 'ANONYMIZE' } ,
{ type : 'US_BANK_ACCOUNT_NUMBER' , action : 'ANONYMIZE' } ,
{ type : 'US_BANK_ROUTING_NUMBER' , action : 'ANONYMIZE' } ,
{ type : 'PASSWORD' , action : 'ANONYMIZE' } ,
{ type : 'AWS_ACCESS_KEY' , action : 'ANONYMIZE' } ,
{ type : 'AWS_SECRET_KEY' , action : 'ANONYMIZE' } ,
] ,
} ,
} ) ;
const guardrailVersion = new bedrock . CfnGuardrailVersion ( this , 'GuardrailVersion' , {
guardrailIdentifier : guardrail.attrGuardrailId ,
description : 'Initial version — prompt-attack + content + credential/financial masking' ,
} ) ;
guardrailVersion . addDependency ( guardrail ) ;
agentRole . addToPolicy ( new iam . PolicyStatement ( {
2026-06-04 16:50:19 -04:00
// GetGuardrail: the Agents service fetches the guardrail config before applying it —
// without it every InvokeAgent logs an AccessDenied (trips CIS 4.1 alarm)
actions : [ 'bedrock:ApplyGuardrail' , 'bedrock:GetGuardrail' ] ,
2026-06-03 15:16:06 -04:00
// Base ARN plus version-suffixed children — runtime applies the versioned guardrail
resources : [ guardrail . attrGuardrailArn , ` ${ guardrail . attrGuardrailArn } /* ` ] ,
} ) ) ;
2026-04-11 23:15:15 -04:00
// ── CfnAgent ──────────────────────────────────────────────────────────────
this . agent = new bedrock . CfnAgent ( this , 'Agent' , {
2026-04-30 16:38:54 -04:00
agentName : 'seahaven-alex' ,
description : 'Alex — Sea Haven Industries internal Slack assistant' ,
2026-04-11 23:15:15 -04:00
agentResourceRoleArn : agentRole.roleArn ,
foundationModel : BedrockAgentConstruct.MODEL_ID ,
2026-06-03 15:16:06 -04:00
guardrailConfiguration : {
guardrailIdentifier : guardrail.attrGuardrailId ,
guardrailVersion : guardrailVersion.attrVersion ,
} ,
2026-04-11 23:15:15 -04:00
instruction ,
idleSessionTtlInSeconds : 1800 , // 30 min — matches the processor's session window
knowledgeBases : [
{
knowledgeBaseId : props.knowledgeBaseId ,
2026-04-13 17:05:26 -04:00
description : 'Sea Haven internal documents: SOPs, SA8000 compliance docs, employee handbook, approved vendor lists, work orders, and purchase orders' ,
2026-04-11 23:15:15 -04:00
knowledgeBaseState : 'ENABLED' ,
} ,
] ,
actionGroups : [
{
actionGroupName : 'QBO_Lookup' ,
description : 'Search QuickBooks Online for existing Sea Haven vendors by trade or name' ,
actionGroupState : 'ENABLED' ,
actionGroupExecutor : { lambda : this.qboLambda.functionArn } ,
functionSchema : {
functions : [
{
name : 'search_vendors' ,
description : 'Search QuickBooks Online for existing vendors by trade category or company name. Returns contact info and outstanding balance.' ,
parameters : {
trade : {
type : 'string' ,
description : 'Trade or service type to search for (e.g., "plumbing", "electrical", "HVAC", "janitorial", "landscaping")' ,
required : false ,
} ,
name : {
type : 'string' ,
description : 'Vendor company name or partial name to search for' ,
required : false ,
} ,
} ,
} ,
] ,
} ,
} ,
{
actionGroupName : 'Google_Maps_Lookup' ,
description : 'Search Google Maps Places for vendors near a location. Use ONLY when QBO and the knowledge base have no suitable vendor.' ,
actionGroupState : 'ENABLED' ,
actionGroupExecutor : { lambda : this.mapsLambda.functionArn } ,
functionSchema : {
functions : [
{
name : 'search_nearby_vendors' ,
description : 'Search Google Maps Places for local vendors and contractors by trade type and location.' ,
parameters : {
trade : {
type : 'string' ,
description : 'Trade or service type to search for (e.g., "plumbing contractor", "electrician")' ,
required : true ,
} ,
location : {
type : 'string' ,
2026-04-12 00:01:33 -04:00
description : 'Location to search near — can be a city, address, zip code, or facility/business name (e.g., "Seattle WA", "Amazon BFI9", "3230 International Pl DuPont WA"). Pass whatever location context the user provided; Google Maps will resolve it.' ,
2026-04-11 23:15:15 -04:00
required : true ,
} ,
} ,
} ,
] ,
} ,
} ,
2026-04-13 17:59:47 -04:00
{
actionGroupName : 'WO_PO_Lookup' ,
2026-04-30 16:38:54 -04:00
description : 'Look up work orders, purchase orders, Amazon site assignments, and payment/invoice status directly from the database' ,
2026-04-13 17:59:47 -04:00
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 ,
} ,
} ,
} ,
2026-04-13 19:11:39 -04:00
{
name : 'lookup_site' ,
2026-07-15 18:49:05 -04:00
description : 'Look up an Amazon facility site by its site code. Returns address, city, state, coordinates, and notes.' ,
2026-04-13 19:11:39 -04:00
parameters : {
site_code : {
type : 'string' ,
description : 'The Amazon site code (e.g., "ABE2", "DFW6", "WWY1")' ,
2026-07-15 18:49:05 -04:00
required : true ,
2026-04-13 19:11:39 -04:00
} ,
} ,
} ,
2026-04-30 16:38:54 -04:00
{
name : 'lookup_payment_by_vendor' ,
description : 'Search for payments made to a vendor/payee by name. Returns check numbers, amounts, status, payment method, and dates. Use when an employee asks "Has vendor X been paid?" or "What payments went to X?"' ,
parameters : {
vendor_name : {
type : 'string' ,
description : 'The vendor or payee name to search for (partial match supported)' ,
required : true ,
} ,
} ,
} ,
{
name : 'lookup_payment_by_invoice' ,
description : 'Search for a payment by invoice number. Returns the check number, payee, amount, status, and payment date. Use when an employee asks "Is invoice 12345 scheduled?" or "Has invoice 12345 been paid?"' ,
parameters : {
invoice_number : {
type : 'string' ,
description : 'The invoice number to search for' ,
required : true ,
} ,
} ,
} ,
{
name : 'lookup_payment_by_check' ,
description : 'Look up a specific payment by its check number. Returns payee, amount, status, method, invoices covered, and dates.' ,
parameters : {
check_number : {
type : 'string' ,
description : 'The check number to look up' ,
required : true ,
} ,
} ,
} ,
2026-04-13 17:59:47 -04:00
] ,
} ,
} ,
2026-04-11 23:15:15 -04:00
] ,
} ) ;
2026-06-03 15:16:06 -04:00
// Guardrail version must exist before the agent references it (review FIX)
this . agent . addDependency ( guardrailVersion ) ;
2026-04-11 23:15:15 -04:00
// ── Agent alias (stable ARN for invocations) ──────────────────────────────
// Creating the alias also triggers agent preparation in CloudFormation.
this . agentAlias = new bedrock . CfnAgentAlias ( this , 'AgentAlias' , {
agentId : this.agent.attrAgentId ,
agentAliasName : 'live' ,
2026-06-03 15:16:06 -04:00
// Description bump forces an alias update so a new agent version snapshots
// the guardrail config — CfnAgentAlias does not pick up agent changes otherwise.
description : 'Production alias — Alex v2 (guardrail)' ,
2026-04-11 23:15:15 -04:00
} ) ;
}
}