AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: >
  bedrock-analysis

  SAM Template for Bedrock Image Analysis Lambda

Parameters:
  ArtifactsBucketName:
    Type: String
    Default: datafy-bedrock-artifacts
    Description:  S3 bucket for saving image/prompt/response per run. Leave empty to disable.
  VpcId:
    Type: AWS::EC2::VPC::Id
    Default: vpc-0ada1e5366ad5c132
    Description: VPC where Lambdas will run
  PrivateSubnet1Id:
    Type: AWS::EC2::Subnet::Id
    Default: subnet-0aa14f7bb45db8b87
    Description: First private subnet ID (with NAT egress)
  PrivateSubnet2Id:
    Type: AWS::EC2::Subnet::Id
    Default: subnet-0b651a99800a9d99e
    Description: Second private subnet ID (with NAT egress)
  EnableBedrockRetrySchedule:
    Type: String
    Default: "true"
    AllowedValues:
      - "true"
      - "false"
    Description: When true, EventBridge runs the Bedrock retry state machine daily at 22:00 Africa/Johannesburg (20:00 UTC).
  BedrockRetryMapMaxConcurrency:
    Type: Number
    Default: 2
    MinValue: 1
    MaxValue: 100
    Description: >
      Step Functions Map parallel branches for Bedrock retry (each branch invokes the analysis Lambda once).
      Default 2 avoids ThrottlingException for typical InvokeModel quotas; increase only after Service Quotas review.
  BedrockFallbackInferenceProfileArn:
    Type: String
    Default: "eu.anthropic.claude-sonnet-4-6"
    Description: >
      Optional Bedrock inference profile ID if primary (INFERENCE_PROFILE_ARN) throttles — e.g. eu.anthropic.claude-sonnet-4-5 when primary is eu.anthropic.claude-sonnet-4-6. Leave empty to disable.
  BedrockBatchMapMaxConcurrency:
    Type: Number
    Default: 2
    MinValue: 1
    MaxValue: 100
    Description: Step Functions Map parallel branches for batched Bedrock (one product + N shelves per Lambda).
  BedrockBatchMaxShelfImages:
    Type: Number
    Default: 6
    MinValue: 1
    MaxValue: 20
    Description: Max shelf photos per Bedrock invocation (chunks larger product groups).
  BedrockBatchListPageSize:
    Type: Number
    Default: 80
    MinValue: 1
    MaxValue: 500
    Description: Max batch groups per list page (Step Functions state size).

Conditions:
  HasArtifactsBucket: !Not [!Equals [!Ref ArtifactsBucketName, '']]
  HasRetrySchedule: !Equals [!Ref EnableBedrockRetrySchedule, "true"]

Globals:
  Function:
    Timeout: 900
    MemorySize: 1024
    VpcConfig:
      SecurityGroupIds:
        - !Ref LambdaSecurityGroup
      SubnetIds:
        - !Ref PrivateSubnet1Id
        - !Ref PrivateSubnet2Id
    LoggingConfig:
      LogFormat: JSON

Resources:
  LambdaSecurityGroup:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupDescription: Egress-only security group for Lambda ENIs
      VpcId: !Ref VpcId
      SecurityGroupEgress:
        - IpProtocol: -1
          CidrIp: 0.0.0.0/0

  BedrockImageAnalysis:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: app/
      Handler: app.lambda_handler
      Runtime: python3.9
      Architectures:
        - x86_64
      Environment:
        Variables:
          INFERENCE_PROFILE_ARN: eu.anthropic.claude-sonnet-4-6
          BEDROCK_FALLBACK_INFERENCE_PROFILE_ARN: !Ref BedrockFallbackInferenceProfileArn
          GENAI_ARTIFACTS_S3_BUCKET: !Ref ArtifactsBucketName
      Policies:
        - AWSLambdaBasicExecutionRole
        - Version: '2012-10-17'
          Statement:
            - Effect: Allow
              Action:
                - ssm:GetParameter
                - bedrock:InvokeModel
                - bedrock:InvokeModelWithResponseStream
              Resource:
                - arn:aws:ssm:eu-west-1:587594388832:parameter/supabase/url
                - arn:aws:ssm:eu-west-1:587594388832:parameter/supabase/anon
                - arn:aws:bedrock:*::foundation-model/anthropic.claude-sonnet-4-6
                - arn:aws:bedrock:*::foundation-model/anthropic.claude-sonnet-4-5
                - arn:aws:bedrock:*:*:inference-profile/*
            - Effect: Allow
              Action:
                - s3:PutObject
                - s3:GetBucketLocation
              Resource:
                - !Sub 'arn:aws:s3:::datafy-bedrock-artifacts'
                - !Sub 'arn:aws:s3:::datafy-bedrock-artifacts/*'
      Events:
        BedrockAnalysisApi:
          Type: Api
          Properties:
            Path: /analyze-image
            Method: post
            RestApiId: !Ref BedrockAnalysisApi
        BedrockAnalysisOptions:
          Type: Api
          Properties:
            Path: /analyze-image
            Method: options
            RestApiId: !Ref BedrockAnalysisApi

  # test_app.invoke_bedrock_test_model only (not app.analyze_image_with_bedrock); no DB/S3 writes
  BedrockImageAnalysisTest:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: app/
      Handler: test_app.lambda_handler
      Runtime: python3.9
      Architectures:
        - x86_64
      Environment:
        Variables:
          INFERENCE_PROFILE_ARN: eu.anthropic.claude-sonnet-4-6
          BEDROCK_FALLBACK_INFERENCE_PROFILE_ARN: !Ref BedrockFallbackInferenceProfileArn
          # Optional: override Qwen default when JSON omits model_id (see test_app.DEFAULT_QWEN_TEST_MODEL_ID)
          BEDROCK_TEST_MODEL_ID: qwen.qwen3-vl-235b-a22b
      Policies:
        - AWSLambdaBasicExecutionRole
        - Version: '2012-10-17'
          Statement:
            - Effect: Allow
              Action:
                - ssm:GetParameter
                - bedrock:InvokeModel
                - bedrock:InvokeModelWithResponseStream
                - bedrock:Converse
              Resource:
                - arn:aws:ssm:eu-west-1:587594388832:parameter/supabase/url
                - arn:aws:ssm:eu-west-1:587594388832:parameter/supabase/anon
                - arn:aws:bedrock:*::foundation-model/anthropic.claude-sonnet-4-6
                - arn:aws:bedrock:*::foundation-model/anthropic.claude-sonnet-4-5
                - arn:aws:bedrock:*::foundation-model/qwen.*
                - arn:aws:bedrock:*:*:inference-profile/*
      Events:
        BedrockAnalysisTestApi:
          Type: Api
          Properties:
            Path: /analyze-image-test
            Method: post
            RestApiId: !Ref BedrockAnalysisApi
        BedrockAnalysisTestOptions:
          Type: Api
          Properties:
            Path: /analyze-image-test
            Method: options
            RestApiId: !Ref BedrockAnalysisApi

  ListBedrockRetryTasks:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: list_retry_tasks/
      Handler: app.lambda_handler
      Runtime: python3.9
      Architectures:
        - x86_64
      Timeout: 120
      MemorySize: 512
      Environment:
        Variables:
          RETRY_LIST_BATCH_SIZE: "500"
      Policies:
        - AWSLambdaBasicExecutionRole
        - Version: '2012-10-17'
          Statement:
            - Effect: Allow
              Action:
                - ssm:GetParameter
              Resource:
                - arn:aws:ssm:eu-west-1:587594388832:parameter/supabase/url
                - arn:aws:ssm:eu-west-1:587594388832:parameter/supabase/anon

  ListBedrockBatchGroups:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: list_bedrock_batch_groups/
      Handler: app.lambda_handler
      Runtime: python3.9
      Architectures:
        - x86_64
      Timeout: 120
      MemorySize: 512
      Environment:
        Variables:
          BATCH_MAX_SHELF_IMAGES: !Ref BedrockBatchMaxShelfImages
          BATCH_LIST_PAGE_SIZE: !Ref BedrockBatchListPageSize
      Policies:
        - AWSLambdaBasicExecutionRole
        - Version: '2012-10-17'
          Statement:
            - Effect: Allow
              Action:
                - ssm:GetParameter
              Resource:
                - arn:aws:ssm:eu-west-1:587594388832:parameter/supabase/url
                - arn:aws:ssm:eu-west-1:587594388832:parameter/supabase/anon

  BedrockBatchAnalyze:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: bedrock_batch_analyze/
      Handler: app.lambda_handler
      Runtime: python3.9
      Architectures:
        - x86_64
      MemorySize: 2048
      Environment:
        Variables:
          INFERENCE_PROFILE_ARN: eu.anthropic.claude-sonnet-4-6
          BEDROCK_FALLBACK_INFERENCE_PROFILE_ARN: !Ref BedrockFallbackInferenceProfileArn
      Policies:
        - AWSLambdaBasicExecutionRole
        - Version: '2012-10-17'
          Statement:
            - Effect: Allow
              Action:
                - ssm:GetParameter
                - bedrock:InvokeModel
                - bedrock:InvokeModelWithResponseStream
              Resource:
                - arn:aws:ssm:eu-west-1:587594388832:parameter/supabase/url
                - arn:aws:ssm:eu-west-1:587594388832:parameter/supabase/anon
                - arn:aws:bedrock:*::foundation-model/anthropic.claude-sonnet-4-6
                - arn:aws:bedrock:*::foundation-model/anthropic.claude-sonnet-4-5
                - arn:aws:bedrock:*:*:inference-profile/*

  BedrockRetryWorkflow:
    Type: AWS::StepFunctions::StateMachine
    Properties:
      StateMachineName: !Sub '${AWS::StackName}-BedrockRetry'
      RoleArn: !GetAtt BedrockRetryWorkflowRole.Arn
      DefinitionString: !Sub
        - |
          {
            "Comment": "Paginated list + Map (ResultPath null avoids 256KB limit); loops until has_more false. Pass run_date in execution input for a past SAST day.",
            "StartAt": "RouteRetryInput",
            "States": {
              "RouteRetryInput": {
                "Type": "Choice",
                "Choices": [
                  {
                    "Variable": "$$.Execution.Input.run_date",
                    "IsPresent": true,
                    "Next": "SeedRetryWithDate"
                  }
                ],
                "Default": "SeedRetryToday"
              },
              "SeedRetryWithDate": {
                "Type": "Pass",
                "Parameters": {
                  "offset": 0,
                  "run_date.$": "$$.Execution.Input.run_date"
                },
                "Next": "ListRetryFirst"
              },
              "SeedRetryToday": {
                "Type": "Pass",
                "Parameters": {
                  "offset": 0
                },
                "Next": "ListRetryFirst"
              },
              "ListRetryFirst": {
                "Type": "Task",
                "Resource": "arn:aws:states:::lambda:invoke",
                "Parameters": {
                  "FunctionName": "${ListBedrockRetryTasksArn}",
                  "Payload.$": "$"
                },
                "ResultSelector": {
                  "items.$": "$.Payload.items",
                  "run_date.$": "$.Payload.run_date",
                  "timezone.$": "$.Payload.timezone",
                  "count.$": "$.Payload.count",
                  "has_more.$": "$.Payload.has_more",
                  "next_offset.$": "$.Payload.next_offset",
                  "total_count.$": "$.Payload.total_count"
                },
                "ResultPath": "$",
                "Retry": [
                  {
                    "ErrorEquals": ["Lambda.ServiceException", "Lambda.AWSLambdaException", "Lambda.SdkClientException", "Lambda.TooManyRequestsException"],
                    "IntervalSeconds": 2,
                    "MaxAttempts": 3,
                    "BackoffRate": 2
                  }
                ],
                "Next": "AfterList"
              },
              "ListRetryNext": {
                "Type": "Task",
                "Resource": "arn:aws:states:::lambda:invoke",
                "Parameters": {
                  "FunctionName": "${ListBedrockRetryTasksArn}",
                  "Payload": {
                    "offset.$": "$.offset",
                    "run_date.$": "$.run_date"
                  }
                },
                "ResultSelector": {
                  "items.$": "$.Payload.items",
                  "run_date.$": "$.Payload.run_date",
                  "timezone.$": "$.Payload.timezone",
                  "count.$": "$.Payload.count",
                  "has_more.$": "$.Payload.has_more",
                  "next_offset.$": "$.Payload.next_offset",
                  "total_count.$": "$.Payload.total_count"
                },
                "ResultPath": "$",
                "Retry": [
                  {
                    "ErrorEquals": ["Lambda.ServiceException", "Lambda.AWSLambdaException", "Lambda.SdkClientException", "Lambda.TooManyRequestsException"],
                    "IntervalSeconds": 2,
                    "MaxAttempts": 3,
                    "BackoffRate": 2
                  }
                ],
                "Next": "AfterList"
              },
              "AfterList": {
                "Type": "Choice",
                "Choices": [
                  {
                    "Variable": "$.items[0]",
                    "IsPresent": true,
                    "Next": "ProcessAll"
                  }
                ],
                "Default": "ListHadNoRows"
              },
              "ListHadNoRows": {
                "Type": "Choice",
                "Choices": [
                  {
                    "Variable": "$.has_more",
                    "BooleanEquals": true,
                    "Next": "PrepareNextBatch"
                  }
                ],
                "Default": "NoWork"
              },
              "NoWork": {
                "Type": "Succeed"
              },
              "ProcessAll": {
                "Type": "Map",
                "ItemsPath": "$.items",
                "MaxConcurrency": ${MapConcurrency},
                "ResultPath": null,
                "Iterator": {
                  "StartAt": "InvokeBedrock",
                  "States": {
                    "InvokeBedrock": {
                      "Type": "Task",
                      "Resource": "arn:aws:states:::lambda:invoke",
                      "OutputPath": "$.Payload",
                      "Parameters": {
                        "FunctionName": "${BedrockImageAnalysisArn}",
                        "Payload.$": "$"
                      },
                      "Retry": [
                        {
                          "ErrorEquals": ["Lambda.ServiceException", "Lambda.AWSLambdaException", "Lambda.SdkClientException", "Lambda.TooManyRequestsException"],
                          "IntervalSeconds": 2,
                          "MaxAttempts": 3,
                          "BackoffRate": 2
                        }
                      ],
                      "End": true
                    }
                  }
                },
                "Next": "AfterBatch"
              },
              "AfterBatch": {
                "Type": "Choice",
                "Choices": [
                  {
                    "Variable": "$.has_more",
                    "BooleanEquals": true,
                    "Next": "PrepareNextBatch"
                  }
                ],
                "Default": "Done"
              },
              "PrepareNextBatch": {
                "Type": "Pass",
                "Parameters": {
                  "offset.$": "$.next_offset",
                  "run_date.$": "$.run_date"
                },
                "Next": "ListRetryNext"
              },
              "Done": {
                "Type": "Succeed"
              }
            }
          }
        - MapConcurrency: !Ref BedrockRetryMapMaxConcurrency
          ListBedrockRetryTasksArn: !GetAtt ListBedrockRetryTasks.Arn
          BedrockImageAnalysisArn: !GetAtt BedrockImageAnalysis.Arn

  BedrockBatchWorkflow:
    Type: AWS::StepFunctions::StateMachine
    Properties:
      StateMachineName: !Sub '${AWS::StackName}-BedrockBatch'
      RoleArn: !GetAtt BedrockBatchWorkflowRole.Arn
      DefinitionString: !Sub
        - |
          {
            "Comment": "Product-centric batch: list grouped chunks, Map invokes batch Lambda (ref once + N shelves). Pass run_date in execution input for a past SAST day.",
            "StartAt": "RouteBatchInput",
            "States": {
              "RouteBatchInput": {
                "Type": "Choice",
                "Choices": [
                  {
                    "Variable": "$$.Execution.Input.run_date",
                    "IsPresent": true,
                    "Next": "SeedBatchWithDate"
                  }
                ],
                "Default": "SeedBatchToday"
              },
              "SeedBatchWithDate": {
                "Type": "Pass",
                "Parameters": {
                  "offset": 0,
                  "run_date.$": "$$.Execution.Input.run_date"
                },
                "Next": "ListBatchFirst"
              },
              "SeedBatchToday": {
                "Type": "Pass",
                "Parameters": {
                  "offset": 0
                },
                "Next": "ListBatchFirst"
              },
              "ListBatchFirst": {
                "Type": "Task",
                "Resource": "arn:aws:states:::lambda:invoke",
                "Parameters": {
                  "FunctionName": "${ListBedrockBatchGroupsArn}",
                  "Payload.$": "$"
                },
                "ResultSelector": {
                  "groups.$": "$.Payload.groups",
                  "run_date.$": "$.Payload.run_date",
                  "timezone.$": "$.Payload.timezone",
                  "count.$": "$.Payload.count",
                  "has_more.$": "$.Payload.has_more",
                  "next_offset.$": "$.Payload.next_offset",
                  "total_count.$": "$.Payload.total_count"
                },
                "ResultPath": "$",
                "Retry": [
                  {
                    "ErrorEquals": ["Lambda.ServiceException", "Lambda.AWSLambdaException", "Lambda.SdkClientException", "Lambda.TooManyRequestsException"],
                    "IntervalSeconds": 2,
                    "MaxAttempts": 3,
                    "BackoffRate": 2
                  }
                ],
                "Next": "AfterBatchList"
              },
              "ListBatchNext": {
                "Type": "Task",
                "Resource": "arn:aws:states:::lambda:invoke",
                "Parameters": {
                  "FunctionName": "${ListBedrockBatchGroupsArn}",
                  "Payload": {
                    "offset.$": "$.offset",
                    "run_date.$": "$.run_date"
                  }
                },
                "ResultSelector": {
                  "groups.$": "$.Payload.groups",
                  "run_date.$": "$.Payload.run_date",
                  "timezone.$": "$.Payload.timezone",
                  "count.$": "$.Payload.count",
                  "has_more.$": "$.Payload.has_more",
                  "next_offset.$": "$.Payload.next_offset",
                  "total_count.$": "$.Payload.total_count"
                },
                "ResultPath": "$",
                "Retry": [
                  {
                    "ErrorEquals": ["Lambda.ServiceException", "Lambda.AWSLambdaException", "Lambda.SdkClientException", "Lambda.TooManyRequestsException"],
                    "IntervalSeconds": 2,
                    "MaxAttempts": 3,
                    "BackoffRate": 2
                  }
                ],
                "Next": "AfterBatchList"
              },
              "AfterBatchList": {
                "Type": "Choice",
                "Choices": [
                  {
                    "Variable": "$.groups[0]",
                    "IsPresent": true,
                    "Next": "ProcessBatchGroups"
                  }
                ],
                "Default": "BatchListHadNoRows"
              },
              "BatchListHadNoRows": {
                "Type": "Choice",
                "Choices": [
                  {
                    "Variable": "$.has_more",
                    "BooleanEquals": true,
                    "Next": "PrepareBatchNextPage"
                  }
                ],
                "Default": "BatchNoWork"
              },
              "BatchNoWork": {
                "Type": "Succeed"
              },
              "ProcessBatchGroups": {
                "Type": "Map",
                "ItemsPath": "$.groups",
                "MaxConcurrency": ${BatchMapConcurrency},
                "ResultPath": null,
                "Iterator": {
                  "StartAt": "InvokeBedrockBatch",
                  "States": {
                    "InvokeBedrockBatch": {
                      "Type": "Task",
                      "Resource": "arn:aws:states:::lambda:invoke",
                      "OutputPath": "$.Payload",
                      "Parameters": {
                        "FunctionName": "${BedrockBatchAnalyzeArn}",
                        "Payload.$": "$"
                      },
                      "End": true
                    }
                  }
                },
                "Next": "AfterBatchProcess"
              },
              "AfterBatchProcess": {
                "Type": "Choice",
                "Choices": [
                  {
                    "Variable": "$.has_more",
                    "BooleanEquals": true,
                    "Next": "PrepareBatchNextPage"
                  }
                ],
                "Default": "BatchDone"
              },
              "PrepareBatchNextPage": {
                "Type": "Pass",
                "Parameters": {
                  "offset.$": "$.next_offset",
                  "run_date.$": "$.run_date"
                },
                "Next": "ListBatchNext"
              },
              "BatchDone": {
                "Type": "Succeed"
              }
            }
          }
        - BatchMapConcurrency: !Ref BedrockBatchMapMaxConcurrency
          ListBedrockBatchGroupsArn: !GetAtt ListBedrockBatchGroups.Arn
          BedrockBatchAnalyzeArn: !GetAtt BedrockBatchAnalyze.Arn

  BedrockBatchWorkflowRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal:
              Service: states.amazonaws.com
            Action: sts:AssumeRole
      Policies:
        - PolicyName: InvokeBedrockBatchLambdas
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
              - Effect: Allow
                Action: lambda:InvokeFunction
                Resource:
                  - !GetAtt ListBedrockBatchGroups.Arn
                  - !GetAtt BedrockBatchAnalyze.Arn

  BedrockRetryWorkflowRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal:
              Service: states.amazonaws.com
            Action: sts:AssumeRole
      Policies:
        - PolicyName: InvokeRetryLambdas
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
              - Effect: Allow
                Action: lambda:InvokeFunction
                Resource:
                  - !GetAtt ListBedrockRetryTasks.Arn
                  - !GetAtt BedrockImageAnalysis.Arn

  EventBridgeBedrockRetryRole:
    Type: AWS::IAM::Role
    Condition: HasRetrySchedule
    Properties:
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal:
              Service: events.amazonaws.com
            Action: sts:AssumeRole
      Policies:
        - PolicyName: StartBedrockRetryWorkflow
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
              - Effect: Allow
                Action: states:StartExecution
                Resource: !Ref BedrockRetryWorkflow

  BedrockRetryScheduleRule:
    Type: AWS::Events::Rule
    Condition: HasRetrySchedule
    Properties:
      Description: Daily Bedrock GenAI retry at 22:00 SAST (Africa/Johannesburg, UTC+2) = 20:00 UTC. Input {} — list uses today SAST.
      ScheduleExpression: cron(0 20 * * ? *)
      State: ENABLED
      Targets:
        - Id: BedrockRetryWorkflow
          Arn: !Ref BedrockRetryWorkflow
          RoleArn: !GetAtt EventBridgeBedrockRetryRole.Arn
          Input: "{}"

  BedrockAnalysisApi:
    Type: AWS::Serverless::Api
    Properties:
      StageName: prod
      Cors:
        AllowMethods: "'POST,OPTIONS'"
        AllowHeaders: "'Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token'"
        AllowOrigin: "'*'"


Outputs:
  BedrockAnalysisApi:
    Description: "API Gateway endpoint URL for Prod stage"
    Value: !Sub "https://${BedrockAnalysisApi}.execute-api.${AWS::Region}.amazonaws.com/prod/analyze-image"
  BedrockAnalysisTestApiUrl:
    Description: Dry-run Bedrock test (POST) — same body as analyze-image; no DB writes
    Value: !Sub "https://${BedrockAnalysisApi}.execute-api.${AWS::Region}.amazonaws.com/prod/analyze-image-test"
  BedrockRetryWorkflowArn:
    Description: Step Functions ARN — list incomplete GenAI (fixed today SAST) then invoke Bedrock sequentially
    Value: !Ref BedrockRetryWorkflow
  BedrockBatchWorkflowArn:
    Description: Step Functions ARN — product-grouped batch Bedrock (reference image once + N shelf photos per call)
    Value: !Ref BedrockBatchWorkflow
  ListBedrockRetryTasksArn:
    Description: Lambda ARN for listing retry items (also used as first step of the workflow)
    Value: !GetAtt ListBedrockRetryTasks.Arn
  ListBedrockBatchGroupsArn:
    Description: Lambda ARN for listing batch groups (product_id + chunked items)
    Value: !GetAtt ListBedrockBatchGroups.Arn
  BedrockBatchAnalyzeArn:
    Description: Lambda ARN — one Bedrock call per group (shared product reference + multiple shelves)
    Value: !GetAtt BedrockBatchAnalyze.Arn
