diff --git a/.github/workflows/CAdeploy.yml b/.github/workflows/CAdeploy.yml index 67752116c..e32db645e 100644 --- a/.github/workflows/CAdeploy.yml +++ b/.github/workflows/CAdeploy.yml @@ -8,75 +8,85 @@ on: - cron: '0 6,18 * * *' # Runs at 6:00 AM and 6:00 PM GMT env: - GPT_MIN_CAPACITY: 10 - TEXT_EMBEDDING_MIN_CAPACITY: 10 + GPT_MIN_CAPACITY: 250 + TEXT_EMBEDDING_MIN_CAPACITY: 40 + BRANCH_NAME: ${{ github.head_ref || github.ref_name }} jobs: deploy: - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 + outputs: + RESOURCE_GROUP_NAME: ${{ steps.check_create_rg.outputs.RESOURCE_GROUP_NAME }} + WEBAPP_URL: ${{ steps.get_output.outputs.WEBAPP_URL }} + DEPLOYMENT_SUCCESS: ${{ steps.deployment_status.outputs.SUCCESS }} + AI_SERVICES_NAME: ${{ steps.get_ai_services_name.outputs.AI_SERVICES_NAME }} + KEYVAULTS: ${{ steps.list_keyvaults.outputs.KEYVAULTS }} + AZURE_LOCATION: ${{ steps.set_region.outputs.AZURE_LOCATION }} + SOLUTION_PREFIX: ${{ steps.generate_solution_prefix.outputs.SOLUTION_PREFIX }} steps: - - name: Checkout Code - uses: actions/checkout@v3 + - name: Checkout + uses: actions/checkout@v4 + - name: Install ODBC Driver 18 for SQL Server + run: | + curl https://packages.microsoft.com/keys/microsoft.asc | sudo apt-key add - + sudo add-apt-repository "$(curl https://packages.microsoft.com/config/ubuntu/$(lsb_release -rs)/prod.list)" + sudo apt-get update + sudo ACCEPT_EULA=Y apt-get install -y msodbcsql18 + sudo apt-get install -y unixodbc-dev + + - name: Setup Azure CLI + run: curl -sL https://aka.ms/InstallAzureCLIDeb | sudo bash + + - name: Login to Azure + run: | + az login --service-principal -u ${{ secrets.AZURE_CLIENT_ID }} -p ${{ secrets.AZURE_CLIENT_SECRET }} --tenant ${{ secrets.AZURE_TENANT_ID }} + az account set --subscription ${{ secrets.AZURE_SUBSCRIPTION_ID }} + - name: Run Quota Check id: quota-check run: | - export AZURE_CLIENT_ID=${{ secrets.AZURE_CLIENT_ID }} - export AZURE_TENANT_ID=${{ secrets.AZURE_TENANT_ID }} - export AZURE_CLIENT_SECRET=${{ secrets.AZURE_CLIENT_SECRET }} + export AZURE_CLIENT_ID="${{ secrets.AZURE_CLIENT_ID }}" + export AZURE_TENANT_ID="${{ secrets.AZURE_TENANT_ID }}" + export AZURE_CLIENT_SECRET="${{ secrets.AZURE_CLIENT_SECRET }}" export AZURE_SUBSCRIPTION_ID="${{ secrets.AZURE_SUBSCRIPTION_ID }}" - export GPT_MIN_CAPACITY=${{ env.GPT_MIN_CAPACITY }} - export TEXT_EMBEDDING_MIN_CAPACITY=${{ env.TEXT_EMBEDDING_MIN_CAPACITY }} + export GPT_MIN_CAPACITY="150" + export TEXT_EMBEDDING_MIN_CAPACITY="80" export AZURE_REGIONS="${{ vars.AZURE_REGIONS_CA }}" - chmod +x infra/scripts/checkquota.sh if ! infra/scripts/checkquota.sh; then - # If quota check fails due to insufficient quota, set the flag - if grep -q "No region with sufficient quota found" infra/scripts/checkquota.sh; then + if grep -q "No region with sufficient quota found" infra/scripts/checkquota_ca.sh; then echo "QUOTA_FAILED=true" >> $GITHUB_ENV fi - exit 1 # Fail the pipeline if any other failure occurs + exit 1 fi - - - - name: Send Notification on Quota Failure + + - name: Notify on Quota Failure if: env.QUOTA_FAILED == 'true' run: | RUN_URL="https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}" - EMAIL_BODY=$(cat <Dear Team,

The quota check has failed, and the pipeline cannot proceed.

Build URL: ${RUN_URL}

Please take necessary action.

Best regards,
Your Automation Team

" - } - EOF - ) - curl -X POST "${{ secrets.LOGIC_APP_URL }}" \ -H "Content-Type: application/json" \ - -d "$EMAIL_BODY" || echo "Failed to send notification" + -d '{ + "subject": "CA Deployment - Quota Check Failed", + "body": "

The quota check failed for CA deployment.

View run

" + }' - - name: Fail Pipeline if Quota Check Fails + - name: Fail on Quota Check if: env.QUOTA_FAILED == 'true' run: exit 1 - - - name: Set Deployment Region - run: | - echo "Deployment Region: $VALID_REGION" - echo "AZURE_LOCATION=$VALID_REGION" >> $GITHUB_ENV - - - name: Setup Azure CLI - run: | - curl -sL https://aka.ms/InstallAzureCLIDeb | sudo bash - az --version # Verify installation - - - name: Login to Azure - run: | - az login --service-principal -u ${{ secrets.AZURE_CLIENT_ID }} -p ${{ secrets.AZURE_CLIENT_SECRET }} --tenant ${{ secrets.AZURE_TENANT_ID }} - name: Install Bicep CLI run: az bicep install + + - name: Set Deployment Region + id: set_region + run: | + echo "Selected Region: $VALID_REGION" + echo "AZURE_LOCATION=$VALID_REGION" >> $GITHUB_ENV + echo "AZURE_LOCATION=$VALID_REGION" >> $GITHUB_OUTPUT - - name: Generate Resource Group Name id: generate_rg_name run: | @@ -100,6 +110,8 @@ jobs: else echo "Resource group already exists." fi + # Set output for other jobs + echo "RESOURCE_GROUP_NAME=${{ env.RESOURCE_GROUP_NAME }}" >> $GITHUB_OUTPUT - name: Generate Unique Solution Prefix id: generate_solution_prefix @@ -110,16 +122,136 @@ jobs: UPDATED_TIMESTAMP=$(echo $TIMESTAMP | tail -c 3) UNIQUE_SOLUTION_PREFIX="${COMMON_PART}${UPDATED_TIMESTAMP}" echo "SOLUTION_PREFIX=${UNIQUE_SOLUTION_PREFIX}" >> $GITHUB_ENV + echo "SOLUTION_PREFIX=${UNIQUE_SOLUTION_PREFIX}" >> $GITHUB_OUTPUT echo "Generated SOLUTION_PREFIX: ${UNIQUE_SOLUTION_PREFIX}" - - - name: Deploy Bicep Template - id: deploy + + - name: Determine Tag + id: determine_tag + run: | + BRANCH=${{ github.ref_name }} + if [[ "$BRANCH" == "main" ]]; then TAG="latest" + elif [[ "$BRANCH" == "dev" ]]; then TAG="dev" + elif [[ "$BRANCH" == "demo" ]]; then TAG="demo" + else TAG="default"; fi + echo "tagname=$TAG" >> $GITHUB_OUTPUT + + - name: Get Deployment Output and extract Values + id: get_output run: | set -e - az deployment group create \ + echo "Fetching deployment output..." + # Install azd (Azure Developer CLI) - required by process_sample_data.sh + curl -fsSL https://aka.ms/install-azd.sh | bash + + DEPLOY_OUTPUT=$(az deployment group create \ --resource-group ${{ env.RESOURCE_GROUP_NAME }} \ --template-file infra/main.bicep \ - --parameters AzureOpenAILocation=${{ env.AZURE_LOCATION }} environmentName=${{ env.SOLUTION_PREFIX }} cosmosLocation=eastus2 gptDeploymentCapacity=${{ env.GPT_MIN_CAPACITY }} embeddingDeploymentCapacity=${{ env.TEXT_EMBEDDING_MIN_CAPACITY }} \ + --parameters AzureOpenAILocation=${{ env.AZURE_LOCATION }} environmentName=${{ env.SOLUTION_PREFIX }} cosmosLocation=westus gptDeploymentCapacity=${{ env.GPT_MIN_CAPACITY }} embeddingDeploymentCapacity=${{ env.TEXT_EMBEDDING_MIN_CAPACITY }} \ + --query "properties.outputs" -o json) + + + + echo "Deployment output: $DEPLOY_OUTPUT" + if [[ -z "$DEPLOY_OUTPUT" ]]; then + echo "Error: Deployment output is empty. Please check the deployment logs." + exit 1 + fi + + export AI_FOUNDARY_NAME=$(echo "$DEPLOY_OUTPUT" | jq -r '.aI_FOUNDRY_NAME.value') + echo "AI_FOUNDARY_NAME=$AI_FOUNDARY_NAME" >> $GITHUB_ENV + export SEARCH_SERVICE_NAME=$(echo "$DEPLOY_OUTPUT" | jq -r '.aI_SEARCH_SERVICE_NAME.value') + echo "SEARCH_SERVICE_NAME=$SEARCH_SERVICE_NAME" >> $GITHUB_ENV + export COSMOS_DB_ACCOUNT_NAME=$(echo "$DEPLOY_OUTPUT" | jq -r '.cosmosdB_ACCOUNT_NAME.value') + echo "COSMOS_DB_ACCOUNT_NAME=$COSMOS_DB_ACCOUNT_NAME" >> $GITHUB_ENV + export STORAGE_ACCOUNT=$(echo "$DEPLOY_OUTPUT" | jq -r '.storagE_ACCOUNT_NAME.value') + echo "STORAGE_ACCOUNT=$STORAGE_ACCOUNT" >> $GITHUB_ENV + export STORAGE_CONTAINER=$(echo "$DEPLOY_OUTPUT" | jq -r '.storagE_CONTAINER_NAME.value') + echo "STORAGE_CONTAINER=$STORAGE_CONTAINER" >> $GITHUB_ENV + export KEYVAULT_NAME=$(echo "$DEPLOY_OUTPUT" | jq -r '.keY_VAULT_NAME.value') + echo "KEYVAULT_NAME=$KEYVAULT_NAME" >> $GITHUB_ENV + export SQL_SERVER=$(echo "$DEPLOY_OUTPUT" | jq -r '.sqldB_SERVER.value') + echo "SQL_SERVER=$SQL_SERVER" >> $GITHUB_ENV + export SQL_DATABASE=$(echo "$DEPLOY_OUTPUT" | jq -r '.sqldB_DATABASE.value') + echo "SQL_DATABASE=$SQL_DATABASE" >> $GITHUB_ENV + export CLIENT_ID=$(echo "$DEPLOY_OUTPUT" | jq -r '.managedidentitY_WEBAPP_CLIENTID.value') + echo "CLIENT_ID=$CLIENT_ID" >> $GITHUB_ENV + export CLIENT_NAME=$(echo "$DEPLOY_OUTPUT" | jq -r '.managedidentitY_WEBAPP_NAME.value') + echo "CLIENT_NAME=$CLIENT_NAME" >> $GITHUB_ENV + export RG_NAME=$(echo "$DEPLOY_OUTPUT" | jq -r '.resourcE_GROUP_NAME.value') + echo "RG_NAME=$RG_NAME" >> $GITHUB_ENV + WEBAPP_URL=$(echo $DEPLOY_OUTPUT | jq -r '.weB_APP_URL.value') + echo "WEBAPP_URL=$WEBAPP_URL" >> $GITHUB_OUTPUT + WEB_APP_NAME=$(echo $DEPLOY_OUTPUT | jq -r '.weB_APP_NAME.value') + echo "WEB_APP_NAME=$WEB_APP_NAME" >> $GITHUB_ENV + echo "Deployment output: $DEPLOY_OUTPUT" + + + echo "🔧 Disabling AUTH_ENABLED for the web app..." + az webapp config appsettings set -g "$RG_NAME" -n "$WEB_APP_NAME" --settings AUTH_ENABLED=false + + sleep 30 + + export CLIENT_OBJECT_ID=$(az identity show \ + --name "$CLIENT_NAME" \ + --resource-group "$RG_NAME" \ + --query 'principalId' -o tsv) + echo "CLIENT_OBJECT_ID=$CLIENT_OBJECT_ID" >> $GITHUB_ENV + + + + - name: Deploy Infra and Import Sample Data + run: | + set -e + az account set --subscription "${{ secrets.AZURE_SUBSCRIPTION_ID }}" + + export AZURE_CLIENT_OBJECT_ID=$(az ad sp show --id ${{ secrets.AZURE_CLIENT_ID }} --query id -o tsv) + echo "AZURE_CLIENT_OBJECT_ID=$AZURE_CLIENT_OBJECT_ID" >> $GITHUB_ENV + + az role assignment create \ + --assignee-object-id $AZURE_CLIENT_OBJECT_ID \ + --assignee-principal-type ServicePrincipal \ + --role "Cognitive Services OpenAI User" \ + --scope /subscriptions/${{ secrets.AZURE_SUBSCRIPTION_ID }}/resourceGroups/${{ env.RG_NAME }}/providers/Microsoft.CognitiveServices/accounts/${{ env.AI_FOUNDARY_NAME }} + + sleep 30 + + az role assignment create \ + --assignee-object-id $AZURE_CLIENT_OBJECT_ID \ + --assignee-principal-type ServicePrincipal \ + --role "Search Index Data Contributor" \ + --scope /subscriptions/${{ secrets.AZURE_SUBSCRIPTION_ID }}/resourceGroups/${{ env.RG_NAME }}/providers/Microsoft.Search/searchServices/${{ env.SEARCH_SERVICE_NAME }} + + echo "Running post-deployment script..." + bash ./infra/scripts/add_cosmosdb_access.sh \ + "${{ env.RG_NAME }}" \ + "${{ env.COSMOS_DB_ACCOUNT_NAME }}" \ + "${{ secrets.AZURE_CLIENT_ID }}" + bash ./infra/scripts/copy_kb_files.sh \ + "${{ env.STORAGE_ACCOUNT }}" \ + "${{ env.STORAGE_CONTAINER }}" \ + "" \ + "${{ secrets.AZURE_CLIENT_ID }}" + bash ./infra/scripts/run_create_index_scripts.sh \ + "${{ env.KEYVAULT_NAME }}" \ + "" \ + "${{ secrets.AZURE_CLIENT_ID }}" \ + "${{ env.RG_NAME }}" \ + "${{ env.SQL_SERVER }}" + + + user_roles_json='[ + {"clientId":"${{ env.CLIENT_ID }}","displayName":"${{ env.CLIENT_NAME }}","role":"db_datareader"}, + {"clientId":"${{ env.CLIENT_ID }}","displayName":"${{ env.CLIENT_NAME }}","role":"db_owner"} + ]' + + bash ./infra/scripts/add_user_scripts/create_sql_user_and_role.sh \ + "${{ env.SQL_SERVER }}.database.windows.net" \ + "${{ env.SQL_DATABASE }}" \ + "$user_roles_json" \ + "${{ secrets.AZURE_CLIENT_ID }}" + + echo "=== Post-Deployment Script Completed Successfully ===" + - name: Get AI Services name and store in variable if: always() && steps.check_create_rg.outcome == 'success' @@ -131,9 +263,9 @@ jobs: ai_services_name=$(az cognitiveservices account list -g ${{ env.RESOURCE_GROUP_NAME }} --query "[0].name" -o tsv) if [ -z "$ai_services_name" ]; then echo "No AI Services resource found in the resource group." - echo "AI_SERVICES_NAME=" >> $GITHUB_ENV + echo "AI_SERVICES_NAME=" >> $GITHUB_OUTPUT else - echo "AI_SERVICES_NAME=${ai_services_name}" >> $GITHUB_ENV + echo "AI_SERVICES_NAME=${ai_services_name}" >> $GITHUB_OUTPUT echo "Found AI Services resource: $ai_services_name" fi @@ -141,16 +273,15 @@ jobs: if: always() && steps.check_create_rg.outcome == 'success' id: list_keyvaults run: | - set -e - echo "Listing all KeyVaults in the resource group ${RESOURCE_GROUP_NAME}..." + echo "Listing all KeyVaults in the resource group ${{ env.RESOURCE_GROUP_NAME }}..." # Get the list of KeyVaults in the specified resource group keyvaults=$(az resource list --resource-group ${{ env.RESOURCE_GROUP_NAME }} --query "[?type=='Microsoft.KeyVault/vaults'].name" -o tsv) if [ -z "$keyvaults" ]; then - echo "No KeyVaults found in resource group ${RESOURCE_GROUP_NAME}." - echo "KEYVAULTS=[]" >> $GITHUB_ENV # If no KeyVaults found, set an empty array + echo "No KeyVaults found in resource group ${{ env.RESOURCE_GROUP_NAME }}." + echo "KEYVAULTS=[]" >> $GITHUB_OUTPUT # If no KeyVaults found, set an empty array else echo "KeyVaults found: $keyvaults" @@ -167,67 +298,104 @@ jobs: done keyvault_array="$keyvault_array]" - # Output the formatted array and save it to the environment variable - echo "KEYVAULTS=$keyvault_array" >> $GITHUB_ENV + # Output the formatted array and save it to the job output + echo "KEYVAULTS=$keyvault_array" >> $GITHUB_OUTPUT fi - # - name: Update PowerBI URL - # if: success() - # run: | - # set -e - - # COMMON_PART="-app-service" - # application_name="${{ env.SOLUTION_PREFIX }}${COMMON_PART}" - # echo "Updating application: $application_name" - - # # Log the Power BI URL being set - # echo "Setting Power BI URL: ${{ vars.VITE_POWERBI_EMBED_URL }}" - - # # Update the application settings - # az webapp config appsettings set --name "$application_name" --resource-group "${{ env.RESOURCE_GROUP_NAME }}" --settings VITE_POWERBI_EMBED_URL="${{ vars.VITE_POWERBI_EMBED_URL }}" + - name: Set Deployment Status + id: deployment_status + if: always() + run: | + if [ "${{ job.status }}" == "success" ]; then + echo "SUCCESS=true" >> $GITHUB_OUTPUT + else + echo "SUCCESS=false" >> $GITHUB_OUTPUT + fi - # # Restart the web app - # az webapp restart --resource-group "${{ env.RESOURCE_GROUP_NAME }}" --name "$application_name" + - name: Logout + if: always() + run: az logout + + e2e-test: + needs: deploy + if: needs.deploy.outputs.DEPLOYMENT_SUCCESS == 'true' + uses: ./.github/workflows/test_automation.yml + with: + CA_WEB_URL: ${{ needs.deploy.outputs.WEBAPP_URL }} + secrets: inherit + + cleanup: + if: always() + needs: [deploy, e2e-test] + runs-on: ubuntu-latest + env: + RESOURCE_GROUP_NAME: ${{ needs.deploy.outputs.RESOURCE_GROUP_NAME }} + AI_SERVICES_NAME: ${{ needs.deploy.outputs.AI_SERVICES_NAME }} + KEYVAULTS: ${{ needs.deploy.outputs.KEYVAULTS }} + AZURE_LOCATION: ${{ needs.deploy.outputs.AZURE_LOCATION }} + SOLUTION_PREFIX: ${{ needs.deploy.outputs.SOLUTION_PREFIX }} + steps: + - name: Setup Azure CLI + run: curl -sL https://aka.ms/InstallAzureCLIDeb | sudo bash - # echo "Power BI URL updated successfully for application: $application_name." + - name: Login to Azure + run: | + az login --service-principal -u ${{ secrets.AZURE_CLIENT_ID }} -p ${{ secrets.AZURE_CLIENT_SECRET }} --tenant ${{ secrets.AZURE_TENANT_ID }} + az account set --subscription ${{ secrets.AZURE_SUBSCRIPTION_ID }} - name: Delete Bicep Deployment if: always() run: | set -e echo "Checking if resource group exists..." - rg_exists=$(az group exists --name ${{ env.RESOURCE_GROUP_NAME }}) + echo "Resource group name: ${{ env.RESOURCE_GROUP_NAME }}" + + if [ -z "${{ env.RESOURCE_GROUP_NAME }}" ]; then + echo "Resource group name is empty. Skipping deletion." + exit 0 + fi + + rg_exists=$(az group exists --name "${{ env.RESOURCE_GROUP_NAME }}") if [ "$rg_exists" = "true" ]; then - echo "Resource group exist. Cleaning..." + echo "Resource group exists. Cleaning..." az group delete \ - --name ${{ env.RESOURCE_GROUP_NAME }} \ + --name "${{ env.RESOURCE_GROUP_NAME }}" \ --yes \ --no-wait - echo "Resource group deleted... ${{ env.RESOURCE_GROUP_NAME }}" + echo "Resource group deletion initiated: ${{ env.RESOURCE_GROUP_NAME }}" else - echo "Resource group does not exists." + echo "Resource group does not exist." fi - name: Wait for resource deletion to complete - if: always() && steps.check_create_rg.outcome == 'success' + if: always() run: | + # Check if resource group name is available + if [ -z "${{ env.RESOURCE_GROUP_NAME }}" ]; then + echo "Resource group name is empty. Skipping resource check." + exit 0 + fi # List of keyvaults KEYVAULTS="${{ env.KEYVAULTS }}" - # Remove the surrounding square brackets, if they exist - stripped_keyvaults=$(echo "$KEYVAULTS" | sed 's/\[\|\]//g') + # Remove the surrounding square brackets and quotes, if they exist + stripped_keyvaults=$(echo "$KEYVAULTS" | sed 's/\[\|\]//g' | sed 's/"//g') # Convert the comma-separated string into an array IFS=',' read -r -a resources_to_check <<< "$stripped_keyvaults" - # Append new resources to the array - # resources_to_check+=("${{ env.SOLUTION_PREFIX }}-openai" "${{ env.SOLUTION_PREFIX }}-cogser") - echo "List of resources to check: ${resources_to_check[@]}" + # Check if resource group still exists before listing resources + rg_exists=$(az group exists --name "${{ env.RESOURCE_GROUP_NAME }}") + if [ "$rg_exists" = "false" ]; then + echo "Resource group no longer exists. Skipping resource check." + exit 0 + fi + # Get the list of resources in YAML format - resource_list=$(az resource list --resource-group ${{ env.RESOURCE_GROUP_NAME }} --output yaml) + resource_list=$(az resource list --resource-group "${{ env.RESOURCE_GROUP_NAME }}" --output yaml || echo "") # Maximum number of retries max_retries=3 @@ -240,8 +408,20 @@ jobs: while true; do resource_found=false + # Check if resource group still exists + rg_exists=$(az group exists --name "${{ env.RESOURCE_GROUP_NAME }}") + if [ "$rg_exists" = "false" ]; then + echo "Resource group no longer exists. Exiting resource check." + break + fi + # Iterate through the resources to check for resource in "${resources_to_check[@]}"; do + # Skip empty resource names + if [ -z "$resource" ]; then + continue + fi + echo "Checking resource: $resource" if echo "$resource_list" | grep -q "name: $resource"; then echo "Resource '$resource' exists in the resource group." @@ -261,6 +441,8 @@ jobs: # Wait for the appropriate interval for the current retry echo "Waiting for ${retry_intervals[$retries-1]} seconds before retrying..." sleep ${retry_intervals[$retries-1]} + # Refresh resource list + resource_list=$(az resource list --resource-group "${{ env.RESOURCE_GROUP_NAME }}" --output yaml || echo "") fi else echo "No resources found. Exiting." @@ -269,60 +451,51 @@ jobs: done - name: Purging the Resources - if: always() && steps.check_create_rg.outcome == 'success' + if: always() run: | - set -e - # Define variables - # OPENAI_COMMON_PART="-openai" - # openai_name="${{ env.SOLUTION_PREFIX }}${OPENAI_COMMON_PART}" - # echo "Azure OpenAI: $openai_name" - - # MULTISERVICE_COMMON_PART="-cogser" - # multiservice_account_name="${{ env.SOLUTION_PREFIX }}${MULTISERVICE_COMMON_PART}" - # echo "Azure MultiService Account: $multiservice_account_name" - - # # Purge OpenAI Resource - # echo "Purging the OpenAI Resource..." - # if ! az resource delete --ids /subscriptions/${{ secrets.AZURE_SUBSCRIPTION_ID }}/providers/Microsoft.CognitiveServices/locations/uksouth/resourceGroups/${{ env.RESOURCE_GROUP_NAME }}/deletedAccounts/$openai_name --verbose; then - # echo "Failed to purge openai resource: $openai_name" - # else - # echo "Purged the openai resource: $openai_name" - # fi - - # # Purge MultiService Account Resource - # echo "Purging the MultiService Account Resource..." - # if ! az resource delete --ids /subscriptions/${{ secrets.AZURE_SUBSCRIPTION_ID }}/providers/Microsoft.CognitiveServices/locations/uksouth/resourceGroups/${{ env.RESOURCE_GROUP_NAME }}/deletedAccounts/$multiservice_account_name --verbose; then - # echo "Failed to purge multiService account resource: $multiservice_account_name" - # else - # echo "Purged the multiService account resource: $multiservice_account_name" - # fi + + # Check if resource group name is available + if [ -z "${{ env.RESOURCE_GROUP_NAME }}" ]; then + echo "Resource group name is empty. Skipping resource purging." + exit 0 + fi # Purge AI Services if [ -z "${{ env.AI_SERVICES_NAME }}" ]; then - echo "AI_SERVICES_NAME is not set. Skipping purge." + echo "AI_SERVICES_NAME is not set. Skipping AI Services purge." else echo "Purging AI Services..." - if [ -n "$(az cognitiveservices account list-deleted --query "[?name=='${{env.AI_SERVICES_NAME}}']" -o tsv)" ]; then - echo "AI Services '${{env.AI_SERVICES_NAME}}' is soft-deleted. Proceeding to purge..." - az cognitiveservices account purge --location ${{ env.AZURE_LOCATION }} --resource-group ${{env.RESOURCE_GROUP_NAME}} --name ${{ env.AI_SERVICES_NAME }} + if [ -n "$(az cognitiveservices account list-deleted --query "[?name=='${{ env.AI_SERVICES_NAME }}']" -o tsv)" ]; then + echo "AI Services '${{ env.AI_SERVICES_NAME }}' is soft-deleted. Proceeding to purge..." + az cognitiveservices account purge --location "${{ env.AZURE_LOCATION }}" --resource-group "${{ env.RESOURCE_GROUP_NAME }}" --name "${{ env.AI_SERVICES_NAME }}" else - echo "AI Services '${{env.AI_SERVICES_NAME}}' is not soft-deleted. No action taken." + echo "AI Services '${{ env.AI_SERVICES_NAME }}' is not soft-deleted. No action taken." fi fi - # Ensure KEYVAULTS is properly formatted as a comma-separated string KEYVAULTS="${{ env.KEYVAULTS }}" - # Remove the surrounding square brackets, if they exist - stripped_keyvaults=$(echo "$KEYVAULTS" | sed 's/\[\|\]//g') + # Check if KEYVAULTS is empty or null + if [ -z "$KEYVAULTS" ] || [ "$KEYVAULTS" = "[]" ]; then + echo "No KeyVaults to purge." + exit 0 + fi + + # Remove the surrounding square brackets and quotes, if they exist + stripped_keyvaults=$(echo "$KEYVAULTS" | sed 's/\[\|\]//g' | sed 's/"//g') # Convert the comma-separated string into an array IFS=',' read -r -a keyvault_array <<< "$stripped_keyvaults" echo "Using KeyVaults Array..." for keyvault_name in "${keyvault_array[@]}"; do + # Skip empty keyvault names + if [ -z "$keyvault_name" ]; then + continue + fi + echo "Processing KeyVault: $keyvault_name" # Check if the KeyVault is soft-deleted deleted_vaults=$(az keyvault list-deleted --query "[?name=='$keyvault_name']" -o json --subscription ${{ secrets.AZURE_SUBSCRIPTION_ID }}) @@ -337,22 +510,18 @@ jobs: done echo "Resource purging completed successfully" - - - name: Send Notification on Failure - if: failure() - run: | + - name: Logout + if: always() + run: az logout + + - name: Notify on Failure + if: failure() || needs.deploy.result == 'failure' || needs.e2e-test.result == 'failure' + run: | RUN_URL="https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}" - - # Construct the email body - EMAIL_BODY=$(cat <Dear Team,

We would like to inform you that the Client Advisor Automation process has encountered an issue and has failed to complete successfully.

Build URL: ${RUN_URL}
${OUTPUT}

Please investigate the matter at your earliest convenience.

Best regards,
Your Automation Team

" - } - EOF - ) - - # Send the notification curl -X POST "${{ secrets.LOGIC_APP_URL }}" \ -H "Content-Type: application/json" \ - -d "$EMAIL_BODY" || echo "Failed to send notification" + -d '{ + "subject": "CA Deployment Failed", + "body": "

The CA Deployment pipeline failed.

View Run

" + }' \ No newline at end of file diff --git a/.github/workflows/test_automation.yml b/.github/workflows/test_automation.yml index 64be66e1d..1a401bb43 100644 --- a/.github/workflows/test_automation.yml +++ b/.github/workflows/test_automation.yml @@ -11,9 +11,14 @@ on: schedule: - cron: '0 13 * * *' # Runs at 1 PM UTC workflow_dispatch: + workflow_call: + inputs: + CA_WEB_URL: + required: true + type: string env: - url: ${{ vars.CLIENT_ADVISOR_URL }} + url: ${{ inputs.CA_WEB_URL }} accelerator_name: "Client Advisor" jobs: @@ -36,6 +41,42 @@ jobs: - name: Ensure browsers are installed run: python -m playwright install --with-deps chromium + + - name: Validate URL + run: | + if [ -z "${{ env.url }}" ]; then + echo "ERROR: No URL provided for testing" + exit 1 + + fi + + echo "Testing URL: ${{ env.url }}" + + + - name: Wait for Application to be Ready + run: | + echo "Waiting for application to be ready at ${{ env.url }} " + max_attempts=10 + attempt=1 + + while [ $attempt -le $max_attempts ]; do + echo "Attempt $attempt: Checking if application is ready..." + if curl -f -s "${{ env.url }}" > /dev/null; then + echo "Application is ready!" + break + + fi + + if [ $attempt -eq $max_attempts ]; then + echo "Application is not ready after $max_attempts attempts" + exit 1 + fi + + echo "Application not ready, waiting 30 seconds..." + sleep 30 + attempt=$((attempt + 1)) + done + - name: Run tests(1) id: test1 run: | @@ -108,4 +149,4 @@ jobs: # Send the notification curl -X POST "${{ secrets.EMAILNOTIFICATION_LOGICAPP_URL_TA}}" \ -H "Content-Type: application/json" \ - -d "$EMAIL_BODY" || echo "Failed to send notification" + -d "$EMAIL_BODY" || echo "Failed to send notification" \ No newline at end of file diff --git a/docs/CustomizingAzdParameters.md b/docs/CustomizingAzdParameters.md index 49d98701a..fb0aa4947 100644 --- a/docs/CustomizingAzdParameters.md +++ b/docs/CustomizingAzdParameters.md @@ -21,6 +21,7 @@ By default this template will use the environment name as the prefix to prevent | `AZURE_ENV_OPENAI_LOCATION` | string | `eastus2` | Location of the Azure OpenAI resource. Choose from (allowed values: `swedencentral`, `australiaeast`). | | `AZURE_LOCATION` | string | `japaneast` | Sets the Azure region for resource deployment. | | `AZURE_ENV_LOG_ANALYTICS_WORKSPACE_ID` | string | `` | Reuses an existing Log Analytics Workspace instead of provisioning a new one. | +| `RESOURCE_GROUP_NAME_FOUNDRY` | string | `` | Reuses an existing AI Foundry Project instead of provisioning a new one. | ## How to Set a Parameter To customize any of the above values, run the following command **before** `azd up`: diff --git a/docs/DeploymentGuide.md b/docs/DeploymentGuide.md index 823452049..865612fd8 100644 --- a/docs/DeploymentGuide.md +++ b/docs/DeploymentGuide.md @@ -119,6 +119,7 @@ When you start the deployment, most parameters will have **default values**, but | **Azure OpenAI API Version** | Set the API version for OpenAI model deployments. | `2025-04-01-preview` | | **AZURE\_LOCATION** | Sets the Azure region for resource deployment. | `japaneast` | | **Existing Log Analytics Workspace** | To reuse an existing Log Analytics Workspace ID instead of creating a new one. | *(empty)* | +| **Existing AI Foundry Project Resource ID** | To reuse an existing AI Foundry Project Resource ID instead of creating a new one. | *(empty)* | diff --git a/infra/deploy_ai_foundry.bicep b/infra/deploy_ai_foundry.bicep index 43a713c71..677f09d15 100644 --- a/infra/deploy_ai_foundry.bicep +++ b/infra/deploy_ai_foundry.bicep @@ -9,6 +9,7 @@ param gptDeploymentCapacity int param embeddingModel string param embeddingDeploymentCapacity int param existingLogAnalyticsWorkspaceId string = '' +param azureExistingAIProjectResourceId string = '' // Load the abbrevations file required to name the azure resources. var abbrs = loadJsonContent('./abbreviations.json') @@ -52,6 +53,31 @@ var existingLawSubscription = useExisting ? split(existingLogAnalyticsWorkspaceI var existingLawResourceGroup = useExisting ? split(existingLogAnalyticsWorkspaceId, '/')[4] : '' var existingLawName = useExisting ? split(existingLogAnalyticsWorkspaceId, '/')[8] : '' +var existingOpenAIEndpoint = !empty(azureExistingAIProjectResourceId) + ? format('https://{0}.openai.azure.com/', split(azureExistingAIProjectResourceId, '/')[8]) + : '' +var existingProjEndpoint = !empty(azureExistingAIProjectResourceId) + ? format( + 'https://{0}.services.ai.azure.com/api/projects/{1}', + split(azureExistingAIProjectResourceId, '/')[8], + split(azureExistingAIProjectResourceId, '/')[10] + ) + : '' +var existingAIFoundryName = !empty(azureExistingAIProjectResourceId) + ? split(azureExistingAIProjectResourceId, '/')[8] + : '' +var existingAIProjectName = !empty(azureExistingAIProjectResourceId) + ? split(azureExistingAIProjectResourceId, '/')[10] + : '' +var existingAIServiceSubscription = !empty(azureExistingAIProjectResourceId) + ? split(azureExistingAIProjectResourceId, '/')[2] + : '' +var existingAIServiceResourceGroup = !empty(azureExistingAIProjectResourceId) + ? split(azureExistingAIProjectResourceId, '/')[4] + : '' +var aiSearchConnectionName = 'foundry-search-connection-${solutionName}' +var aiAppInsightConnectionName = 'foundry-app-insights-connection-${solutionName}' + resource existingLogAnalyticsWorkspace 'Microsoft.OperationalInsights/workspaces@2023-09-01' existing = if (useExisting) { name: existingLawName scope: resourceGroup(existingLawSubscription, existingLawResourceGroup) @@ -69,25 +95,6 @@ resource logAnalytics 'Microsoft.OperationalInsights/workspaces@2023-09-01' = if } } -// resource applicationInsights 'Microsoft.Insights/components@2020-02-02' = { -// name: applicationInsightsName -// location: location -// kind: 'web' -// properties: { -// Application_Type: 'web' -// DisableIpMasking: false -// DisableLocalAuth: false -// Flow_Type: 'Bluefield' -// ForceCustomerStorageForProfiler: false -// ImmediatePurgeDataOn30Days: true -// IngestionMode: 'ApplicationInsights' -// publicNetworkAccessForIngestion: 'Enabled' -// publicNetworkAccessForQuery: 'Disabled' -// Request_Source: 'rest' -// WorkspaceResourceId: logAnalytics.id -// } -// } - resource applicationInsights 'Microsoft.Insights/components@2020-02-02' = { name: applicationInsightsName location: location @@ -100,7 +107,7 @@ resource applicationInsights 'Microsoft.Insights/components@2020-02-02' = { } } -resource aiFoundry 'Microsoft.CognitiveServices/accounts@2025-04-01-preview' = { +resource aiFoundry 'Microsoft.CognitiveServices/accounts@2025-04-01-preview' = if (empty(azureExistingAIProjectResourceId)) { name: aiFoundryName location: location sku: { @@ -123,7 +130,7 @@ resource aiFoundry 'Microsoft.CognitiveServices/accounts@2025-04-01-preview' = { } } -resource aiFoundryProject 'Microsoft.CognitiveServices/accounts/projects@2025-04-01-preview' = { +resource aiFoundryProject 'Microsoft.CognitiveServices/accounts/projects@2025-04-01-preview' = if (empty(azureExistingAIProjectResourceId)) { parent: aiFoundry name: aiProjectName location: location @@ -138,7 +145,7 @@ resource aiFoundryProject 'Microsoft.CognitiveServices/accounts/projects@2025-04 @batchSize(1) resource aiFModelDeployments 'Microsoft.CognitiveServices/accounts/deployments@2023-05-01' = [ - for aiModeldeployment in aiModelDeployments: { + for aiModeldeployment in aiModelDeployments: if (empty(azureExistingAIProjectResourceId)) { parent: aiFoundry name: aiModeldeployment.name properties: { @@ -185,8 +192,8 @@ resource aiSearch 'Microsoft.Search/searchServices@2025-02-01-preview' = { } } -resource aiSearchFoundryConnection 'Microsoft.CognitiveServices/accounts/connections@2025-04-01-preview' ={ - name: 'foundry-search-connection' +resource aiSearchFoundryConnection 'Microsoft.CognitiveServices/accounts/connections@2025-04-01-preview' = if (empty(azureExistingAIProjectResourceId)) { + name: aiSearchConnectionName parent: aiFoundry properties: { category: 'CognitiveSearch' @@ -201,18 +208,56 @@ resource aiSearchFoundryConnection 'Microsoft.CognitiveServices/accounts/connect } } +module existing_AIProject_SearchConnectionModule 'deploy_aifp_aisearch_connection.bicep' = if (!empty(azureExistingAIProjectResourceId)) { + name: 'aiProjectSearchConnectionDeployment' + scope: resourceGroup(existingAIServiceSubscription, existingAIServiceResourceGroup) + params: { + existingAIProjectName: existingAIProjectName + existingAIFoundryName: existingAIFoundryName + aiSearchName: aiSearchName + aiSearchResourceId: aiSearch.id + aiSearchLocation: aiSearch.location + aiSearchConnectionName: aiSearchConnectionName + } +} + +resource cognitiveServicesOpenAIUser 'Microsoft.Authorization/roleDefinitions@2022-04-01' existing = { + name: '5e0bd9bd-7b93-4f28-af87-19fc36ad61bd' +} + +module assignOpenAIRoleToAISearch 'deploy_foundry_role_assignment.bicep' = { + name: 'assignOpenAIRoleToAISearch' + scope: resourceGroup(existingAIServiceSubscription, existingAIServiceResourceGroup) + params: { + roleDefinitionId: cognitiveServicesOpenAIUser.id + roleAssignmentName: guid(resourceGroup().id, aiSearch.id, cognitiveServicesOpenAIUser.id, 'openai-foundry') + aiFoundryName: !empty(azureExistingAIProjectResourceId) ? existingAIFoundryName : aiFoundryName + aiProjectName: !empty(azureExistingAIProjectResourceId) ? existingAIProjectName : aiProjectName + principalId: aiSearch.identity.principalId + } +} + @description('This is the built-in Search Index Data Reader role.') resource searchIndexDataReaderRoleDefinition 'Microsoft.Authorization/roleDefinitions@2022-04-01' existing = { scope: aiSearch name: '1407120a-92aa-4202-b7e9-c0e197c71c8f' } -resource searchIndexDataReaderRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { - name: guid(aiSearch.id, aiFoundry.id, searchIndexDataReaderRoleDefinition.id) +resource searchIndexDataReaderRoleAssignmentToAIFP 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (empty(azureExistingAIProjectResourceId)) { + name: guid(aiSearch.id, aiFoundryProject.id, searchIndexDataReaderRoleDefinition.id) scope: aiSearch properties: { roleDefinitionId: searchIndexDataReaderRoleDefinition.id - principalId: aiFoundry.identity.principalId + principalId: aiFoundryProject.identity.principalId + principalType: 'ServicePrincipal' + } +} +resource assignSearchIndexDataReaderToExistingAiProject 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (!empty(azureExistingAIProjectResourceId)) { + name: guid(resourceGroup().id, existingAIProjectName, searchIndexDataReaderRoleDefinition.id, 'Existing') + scope: aiSearch + properties: { + roleDefinitionId: searchIndexDataReaderRoleDefinition.id + principalId: assignOpenAIRoleToAISearch.outputs.aiProjectPrincipalId principalType: 'ServicePrincipal' } } @@ -223,18 +268,28 @@ resource searchServiceContributorRoleDefinition 'Microsoft.Authorization/roleDef name: '7ca78c08-252a-4471-8644-bb5ff32d4ba0' } -resource searchServiceContributorRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { - name: guid(aiSearch.id, aiFoundry.id, searchServiceContributorRoleDefinition.id) +resource searchServiceContributorRoleAssignmentToAIFP 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (empty(azureExistingAIProjectResourceId)) { + name: guid(aiSearch.id, aiFoundryProject.id, searchServiceContributorRoleDefinition.id) scope: aiSearch properties: { roleDefinitionId: searchServiceContributorRoleDefinition.id - principalId: aiFoundry.identity.principalId + principalId: aiFoundryProject.identity.principalId principalType: 'ServicePrincipal' } } -resource appInsightsFoundryConnection 'Microsoft.CognitiveServices/accounts/connections@2025-04-01-preview' = { - name: 'foundry-app-insights-connection' +resource searchServiceContributorRoleAssignmentExisting 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (!empty(azureExistingAIProjectResourceId)) { + name: guid(resourceGroup().id, existingAIProjectName, searchServiceContributorRoleDefinition.id, 'Existing') + scope: aiSearch + properties: { + roleDefinitionId: searchServiceContributorRoleDefinition.id + principalId: assignOpenAIRoleToAISearch.outputs.aiProjectPrincipalId + principalType: 'ServicePrincipal' + } +} + +resource appInsightsFoundryConnection 'Microsoft.CognitiveServices/accounts/connections@2025-04-01-preview' = if (empty(azureExistingAIProjectResourceId)) { + name: aiAppInsightConnectionName parent: aiFoundry properties: { category: 'AppInsights' @@ -251,14 +306,6 @@ resource appInsightsFoundryConnection 'Microsoft.CognitiveServices/accounts/conn } } -// resource azureOpenAIApiKeyEntry 'Microsoft.KeyVault/vaults/secrets@2021-11-01-preview' = { -// parent: keyVault -// name: 'AZURE-OPENAI-KEY' -// properties: { -// value: aiFoundry.listKeys().key1 //aiServices_m.listKeys().key1 -// } -// } - resource azureOpenAIApiVersionEntry 'Microsoft.KeyVault/vaults/secrets@2021-11-01-preview' = { parent: keyVault name: 'AZURE-OPENAI-PREVIEW-API-VERSION' @@ -271,7 +318,10 @@ resource azureOpenAIEndpointEntry 'Microsoft.KeyVault/vaults/secrets@2021-11-01- parent: keyVault name: 'AZURE-OPENAI-ENDPOINT' properties: { - value: aiFoundry.properties.endpoints['OpenAI Language Model Instance API'] //aiServices_m.properties.endpoint + // value: aiFoundry.properties.endpoints['OpenAI Language Model Instance API'] //aiServices_m.properties.endpoint + value: !empty(existingOpenAIEndpoint) + ? existingOpenAIEndpoint + : aiFoundry.properties.endpoints['OpenAI Language Model Instance API'] } } @@ -283,14 +333,6 @@ resource azureOpenAIEmbeddingModelEntry 'Microsoft.KeyVault/vaults/secrets@2021- } } -// resource azureSearchAdminKeyEntry 'Microsoft.KeyVault/vaults/secrets@2021-11-01-preview' = { -// parent: keyVault -// name: 'AZURE-SEARCH-KEY' -// properties: { -// value: aiSearch.listAdminKeys().primaryKey -// } -// } - resource azureSearchServiceEndpointEntry 'Microsoft.KeyVault/vaults/secrets@2021-11-01-preview' = { parent: keyVault name: 'AZURE-SEARCH-ENDPOINT' @@ -310,21 +352,27 @@ resource azureSearchIndexEntry 'Microsoft.KeyVault/vaults/secrets@2021-11-01-pre output keyvaultName string = keyvaultName output keyvaultId string = keyVault.id -output aiFoundryProjectEndpoint string = aiFoundryProject.properties.endpoints['AI Foundry API'] -output aiServicesTarget string = aiFoundry.properties.endpoint //aiServices_m.properties.endpoint -output aoaiEndpoint string = aiFoundry.properties.endpoints['OpenAI Language Model Instance API'] //aiServices_m.properties.endpoint -output aiFoundryName string = aiFoundryName //aiServicesName_m -output aiFoundryId string = aiFoundry.id //aiServices_m.id +output resourceGroupNameFoundry string = !empty(existingAIServiceResourceGroup) + ? existingAIServiceResourceGroup + : resourceGroup().name +output aiFoundryProjectEndpoint string = !empty(existingProjEndpoint) + ? existingProjEndpoint + : aiFoundryProject.properties.endpoints['AI Foundry API'] +output aoaiEndpoint string = !empty(existingOpenAIEndpoint) + ? existingOpenAIEndpoint + : aiFoundry.properties.endpoints['OpenAI Language Model Instance API'] //aiServices_m.properties.endpoint +output aiFoundryName string = !empty(existingAIFoundryName) ? existingAIFoundryName : aiFoundryName //aiServicesName_m output aiSearchName string = aiSearchName output aiSearchId string = aiSearch.id output aiSearchTarget string = 'https://${aiSearch.name}.search.windows.net' output aiSearchService string = aiSearch.name -output aiFoundryProjectName string = aiFoundryProject.name +output aiFoundryProjectName string = !empty(existingAIProjectName) ? existingAIProjectName : aiFoundryProject.name output applicationInsightsId string = applicationInsights.id output logAnalyticsWorkspaceResourceName string = useExisting ? existingLogAnalyticsWorkspace.name : logAnalytics.name output logAnalyticsWorkspaceResourceGroup string = useExisting ? existingLawResourceGroup : resourceGroup().name - output applicationInsightsConnectionString string = applicationInsights.properties.ConnectionString + +output aiSearchFoundryConnectionName string = aiSearchConnectionName diff --git a/infra/deploy_aifp_aisearch_connection.bicep b/infra/deploy_aifp_aisearch_connection.bicep new file mode 100644 index 000000000..0dec1b9bb --- /dev/null +++ b/infra/deploy_aifp_aisearch_connection.bicep @@ -0,0 +1,21 @@ +param existingAIProjectName string +param existingAIFoundryName string +param aiSearchName string +param aiSearchResourceId string +param aiSearchLocation string +param aiSearchConnectionName string + +resource projectAISearchConnection 'Microsoft.CognitiveServices/accounts/projects/connections@2025-04-01-preview' = { + name: '${existingAIFoundryName}/${existingAIProjectName}/${aiSearchConnectionName}' + properties: { + category: 'CognitiveSearch' + target: 'https://${aiSearchName}.search.windows.net' + authType: 'AAD' + isSharedToAll: true + metadata: { + ApiType: 'Azure' + ResourceId: aiSearchResourceId + location: aiSearchLocation + } + } +} diff --git a/infra/deploy_app_service.bicep b/infra/deploy_app_service.bicep index 3ad3b0ff2..648fbf16f 100644 --- a/infra/deploy_app_service.bicep +++ b/infra/deploy_app_service.bicep @@ -2,12 +2,10 @@ targetScope = 'resourceGroup' @description('Solution Location') - param solutionLocation string +param solutionLocation string @description('The pricing tier for the App Service plan') -@allowed( - ['F1', 'D1', 'B1', 'B2', 'B3', 'S1', 'S2', 'S3', 'P1', 'P2', 'P3', 'P4','P0v3'] -) +@allowed(['F1', 'D1', 'B1', 'B2', 'B3', 'S1', 'S2', 'S3', 'P1', 'P2', 'P3', 'P4', 'P0v3']) param HostingPlanSku string = 'B2' param HostingPlanName string @@ -77,9 +75,7 @@ param AzureOpenAIApiVersion string = '2024-02-15-preview' param AzureOpenAIStream string = 'True' @description('Azure Search Query Type') -@allowed( - ['simple', 'semantic', 'vector', 'vectorSimpleHybrid', 'vectorSemanticHybrid'] -) +@allowed(['simple', 'semantic', 'vector', 'vectorSimpleHybrid', 'vectorSemanticHybrid']) param AzureSearchQueryType string = 'simple' @description('Azure Search Vector Fields') @@ -139,9 +135,10 @@ param streamTextSystemPrompt string param aiFoundryProjectEndpoint string param useAIProjectClientFlag string = 'false' -param aiFoundryProjectName string + param aiFoundryName string param applicationInsightsConnectionString string +param aiSearchProjectConnectionName string // var WebAppImageName = 'DOCKER|byoaiacontainer.azurecr.io/byoaia-app:latest' @@ -149,6 +146,18 @@ param applicationInsightsConnectionString string var WebAppImageName = 'DOCKER|bycwacontainerreg.azurecr.io/byc-wa-app:${imageTag}' +param azureExistingAIProjectResourceId string = '' + +var existingAIServiceSubscription = !empty(azureExistingAIProjectResourceId) + ? split(azureExistingAIProjectResourceId, '/')[2] + : subscription().subscriptionId +var existingAIServiceResourceGroup = !empty(azureExistingAIProjectResourceId) + ? split(azureExistingAIProjectResourceId, '/')[4] + : resourceGroup().name +var existingAIServicesName = !empty(azureExistingAIProjectResourceId) + ? split(azureExistingAIProjectResourceId, '/')[8] + : '' + resource HostingPlan 'Microsoft.Web/serverfarms@2020-06-01' = { name: HostingPlanName location: solutionLocation @@ -354,6 +363,10 @@ resource Website 'Microsoft.Web/sites@2020-06-01' = { name: 'AZURE_AI_AGENT_API_VERSION' value: AzureOpenAIApiVersion } + { + name: 'AZURE_SEARCH_CONNECTION_NAME' + value: aiSearchProjectConnectionName + } ] linuxFxVersion: WebAppImageName } @@ -377,7 +390,6 @@ resource contributorRoleDefinition 'Microsoft.DocumentDB/databaseAccounts/sqlRol name: '${AZURE_COSMOSDB_ACCOUNT}/00000000-0000-0000-0000-000000000002' } - module cosmosUserRole 'core/database/cosmos/cosmos-role-assign.bicep' = { name: 'cosmos-sql-user-role-${WebsiteName}' params: { @@ -392,11 +404,7 @@ module cosmosUserRole 'core/database/cosmos/cosmos-role-assign.bicep' = { resource aiFoundry 'Microsoft.CognitiveServices/accounts@2025-04-01-preview' existing = { name: aiFoundryName -} - -resource aiFoundryProject 'Microsoft.CognitiveServices/accounts/projects@2025-04-01-preview' existing = { - parent: aiFoundry - name: aiFoundryProjectName + scope: resourceGroup(existingAIServiceSubscription, existingAIServiceResourceGroup) } @description('This is the built-in Azure AI User role.') @@ -405,30 +413,16 @@ resource aiUserRoleDefinitionFoundry 'Microsoft.Authorization/roleDefinitions@20 name: '53ca6127-db72-4b80-b1b0-d745d6d5456d' } -resource aiUserRoleAssignmentFoundry 'Microsoft.Authorization/roleAssignments@2022-04-01' = { - name: guid(Website.id, aiFoundry.id, aiUserRoleDefinitionFoundry.id) - scope: aiFoundry - properties: { - roleDefinitionId: aiUserRoleDefinitionFoundry.id - principalId: Website.identity.principalId - principalType: 'ServicePrincipal' - } -} - -@description('This is the built-in Azure AI User role.') -resource aiUserRoleDefinitionFoundryProject 'Microsoft.Authorization/roleDefinitions@2022-04-01' existing = { - scope: aiFoundryProject - name: '53ca6127-db72-4b80-b1b0-d745d6d5456d' -} - -resource aiUserRoleAssignmentFoundryProject 'Microsoft.Authorization/roleAssignments@2022-04-01' = { - name: guid(Website.id, aiFoundryProject.id, aiUserRoleDefinitionFoundryProject.id) - scope: aiFoundryProject - properties: { - roleDefinitionId: aiUserRoleDefinitionFoundryProject.id +module assignAiUserRoleToAiProject 'deploy_foundry_role_assignment.bicep' = { + name: 'assignAiUserRoleToAiProject' + scope: resourceGroup(existingAIServiceSubscription, existingAIServiceResourceGroup) + params: { principalId: Website.identity.principalId - principalType: 'ServicePrincipal' + roleDefinitionId: aiUserRoleDefinitionFoundry.id + roleAssignmentName: guid(Website.name, aiFoundry.id, aiUserRoleDefinitionFoundry.id) + aiFoundryName: !empty(azureExistingAIProjectResourceId) ? existingAIServicesName : aiFoundryName } } output webAppUrl string = 'https://${WebsiteName}.azurewebsites.net' +output webAppName string = WebsiteName diff --git a/infra/deploy_foundry_role_assignment.bicep b/infra/deploy_foundry_role_assignment.bicep new file mode 100644 index 000000000..a2a6b246f --- /dev/null +++ b/infra/deploy_foundry_role_assignment.bicep @@ -0,0 +1,26 @@ +param principalId string = '' +param roleDefinitionId string +param roleAssignmentName string = '' +param aiFoundryName string +param aiProjectName string = '' + +resource aiServices 'Microsoft.CognitiveServices/accounts@2025-04-01-preview' existing = { + name: aiFoundryName +} + +resource aiProject 'Microsoft.CognitiveServices/accounts/projects@2025-04-01-preview' existing = if (!empty(aiProjectName)) { + name: aiProjectName + parent: aiServices +} + +resource roleAssignmentToFoundry 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: roleAssignmentName + scope: aiServices + properties: { + roleDefinitionId: roleDefinitionId + principalId: principalId + } +} + +output aiServicesPrincipalId string = aiServices.identity.principalId +output aiProjectPrincipalId string = !empty(aiProjectName) ? aiProject.identity.principalId : '' diff --git a/infra/main.bicep b/infra/main.bicep index 3a3e65a30..868410526 100644 --- a/infra/main.bicep +++ b/infra/main.bicep @@ -9,6 +9,9 @@ param environmentName string @description('Optional: Existing Log Analytics Workspace Resource ID') param existingLogAnalyticsWorkspaceId string = '' +@description('Use this parameter to use an existing AI project resource ID') +param azureExistingAIProjectResourceId string = '' + @description('CosmosDB Location') param cosmosLocation string = 'eastus2' @@ -42,7 +45,6 @@ param gptDeploymentCapacity int = 200 ]) param embeddingModel string = 'text-embedding-ada-002' - @minValue(10) @description('Capacity of the Embedding Model deployment') param embeddingDeploymentCapacity int = 80 @@ -69,7 +71,7 @@ param imageTag string = 'latest' param aiDeploymentsLocation string @description('Set this if you want to deploy to a different region than the resource group. Otherwise, it will use the resource group location by default.') -param AZURE_LOCATION string='' +param AZURE_LOCATION string = '' var solutionLocation = empty(AZURE_LOCATION) ? resourceGroup().location : AZURE_LOCATION var uniqueId = toLower(uniqueString(environmentName, subscription().id, solutionLocation)) @@ -83,7 +85,7 @@ var abbrs = loadJsonContent('./abbreviations.json') //var solutionLocation = resourceGroupLocation // var baseUrl = 'https://raw.githubusercontent.com/microsoft/Build-your-own-copilot-Solution-Accelerator/main/' -var functionAppSqlPrompt ='''Generate a valid T-SQL query to find {query} for tables and columns provided below: +var functionAppSqlPrompt = '''Generate a valid T-SQL query to find {query} for tables and columns provided below: 1. Table: Clients Columns: ClientId, Client, Email, Occupation, MaritalStatus, Dependents 2. Table: InvestmentGoals @@ -106,7 +108,7 @@ var functionAppSqlPrompt ='''Generate a valid T-SQL query to find {query} for ta ALWAYS select Client Name (Column: Client) in the query. Query filters are IMPORTANT. Add filters like AssetType, AssetDate, etc. if needed. Only return the generated SQL query. Do not return anything else.''' - + var functionAppCallTranscriptSystemPrompt = '''You are an assistant who supports wealth advisors in preparing for client meetings. You have access to the client’s past meeting call transcripts. When answering questions, especially summary requests, provide a detailed and structured response that includes key topics, concerns, decisions, and trends. @@ -114,7 +116,7 @@ var functionAppCallTranscriptSystemPrompt = '''You are an assistant who supports var functionAppStreamTextSystemPrompt = '''The currently selected client's name is '{SelectedClientName}'. Treat any case-insensitive or partial mention as referring to this client. If the user mentions no name, assume they are asking about '{SelectedClientName}'. - If the user references a name that clearly differs from '{SelectedClientName}', respond only with: 'Please only ask questions about the selected client or select another client.' Otherwise, provide thorough answers for every question using only data from SQL or call transcripts.' + If the user references a name that clearly differs from '{SelectedClientName}' or comparing with other clients, respond only with: 'Please only ask questions about the selected client or select another client.' Otherwise, provide thorough answers for every question using only data from SQL or call transcripts.' If no data is found, respond with 'No data found for that client.' Remove any client identifiers from the final response. Always send clientId as '{client_id}'.''' @@ -135,7 +137,7 @@ module keyvaultModule 'deploy_keyvault.bicep' = { params: { solutionName: solutionPrefix solutionLocation: solutionLocation - managedIdentityObjectId:managedIdentityModule.outputs.managedIdentityOutput.objectId + managedIdentityObjectId: managedIdentityModule.outputs.managedIdentityOutput.objectId kvName: '${abbrs.security.keyVault}${solutionPrefix}' } scope: resourceGroup(resourceGroup().name) @@ -155,6 +157,7 @@ module aifoundry 'deploy_ai_foundry.bicep' = { embeddingModel: embeddingModel embeddingDeploymentCapacity: embeddingDeploymentCapacity existingLogAnalyticsWorkspaceId: existingLogAnalyticsWorkspaceId + azureExistingAIProjectResourceId: azureExistingAIProjectResourceId } scope: resourceGroup(resourceGroup().name) } @@ -164,20 +167,19 @@ module cosmosDBModule 'deploy_cosmos_db.bicep' = { name: 'deploy_cosmos_db' params: { solutionLocation: cosmosLocation - cosmosDBName:'${abbrs.databases.cosmosDBDatabase}${solutionPrefix}' + cosmosDBName: '${abbrs.databases.cosmosDBDatabase}${solutionPrefix}' } scope: resourceGroup(resourceGroup().name) } - // ========== Storage Account Module ========== // module storageAccountModule 'deploy_storage_account.bicep' = { name: 'deploy_storage_account' params: { solutionLocation: solutionLocation - managedIdentityObjectId:managedIdentityModule.outputs.managedIdentityOutput.objectId + managedIdentityObjectId: managedIdentityModule.outputs.managedIdentityOutput.objectId saName: '${abbrs.storage.storageAccount}${solutionPrefix}' - keyVaultName:keyvaultModule.outputs.keyvaultName + keyVaultName: keyvaultModule.outputs.keyvaultName } scope: resourceGroup(resourceGroup().name) } @@ -187,9 +189,9 @@ module sqlDBModule 'deploy_sql_db.bicep' = { name: 'deploy_sql_db' params: { solutionLocation: solutionLocation - keyVaultName:keyvaultModule.outputs.keyvaultName - managedIdentityObjectId:managedIdentityModule.outputs.managedIdentityOutput.objectId - managedIdentityName:managedIdentityModule.outputs.managedIdentityOutput.name + keyVaultName: keyvaultModule.outputs.keyvaultName + managedIdentityObjectId: managedIdentityModule.outputs.managedIdentityOutput.objectId + managedIdentityName: managedIdentityModule.outputs.managedIdentityOutput.name serverName: '${abbrs.databases.sqlDatabaseServer}${solutionPrefix}' sqlDBName: '${abbrs.databases.sqlDatabase}${solutionPrefix}' } @@ -209,51 +211,53 @@ module appserviceModule 'deploy_app_service.bicep' = { solutionLocation: solutionLocation HostingPlanName: '${abbrs.compute.appServicePlan}${solutionPrefix}' WebsiteName: '${abbrs.compute.webApp}${solutionPrefix}' - AzureSearchService:aifoundry.outputs.aiSearchService - AzureSearchIndex:'transcripts_index' - AzureSearchUseSemanticSearch:'True' - AzureSearchSemanticSearchConfig:'my-semantic-config' - AzureSearchTopK:'5' - AzureSearchContentColumns:'content' - AzureSearchFilenameColumn:'chunk_id' - AzureSearchTitleColumn:'client_id' - AzureSearchUrlColumn:'sourceurl' - AzureOpenAIResource:aifoundry.outputs.aiFoundryName - AzureOpenAIEndpoint:aifoundry.outputs.aoaiEndpoint - AzureOpenAIModel:gptModelName - AzureOpenAITemperature:'0' - AzureOpenAITopP:'1' - AzureOpenAIMaxTokens:'1000' - AzureOpenAIStopSequence:'' - AzureOpenAISystemMessage:'''You are a helpful Wealth Advisor assistant''' - AzureOpenAIApiVersion:azureOpenaiAPIVersion - AzureOpenAIStream:'True' - AzureSearchQueryType:'simple' - AzureSearchVectorFields:'contentVector' - AzureSearchPermittedGroupsField:'' - AzureSearchStrictness:'3' - AzureOpenAIEmbeddingName:embeddingModel - AzureOpenAIEmbeddingEndpoint:aifoundry.outputs.aoaiEndpoint - USE_INTERNAL_STREAM:'True' - SQLDB_SERVER:'${sqlDBModule.outputs.sqlServerName}.database.windows.net' - SQLDB_DATABASE:sqlDBModule.outputs.sqlDbName + AzureSearchService: aifoundry.outputs.aiSearchService + AzureSearchIndex: 'transcripts_index' + AzureSearchUseSemanticSearch: 'True' + AzureSearchSemanticSearchConfig: 'my-semantic-config' + AzureSearchTopK: '5' + AzureSearchContentColumns: 'content' + AzureSearchFilenameColumn: 'chunk_id' + AzureSearchTitleColumn: 'client_id' + AzureSearchUrlColumn: 'sourceurl' + AzureOpenAIResource: aifoundry.outputs.aiFoundryName + AzureOpenAIEndpoint: aifoundry.outputs.aoaiEndpoint + AzureOpenAIModel: gptModelName + AzureOpenAITemperature: '0' + AzureOpenAITopP: '1' + AzureOpenAIMaxTokens: '1000' + AzureOpenAIStopSequence: '' + AzureOpenAISystemMessage: '''You are a helpful Wealth Advisor assistant''' + AzureOpenAIApiVersion: azureOpenaiAPIVersion + AzureOpenAIStream: 'True' + AzureSearchQueryType: 'simple' + AzureSearchVectorFields: 'contentVector' + AzureSearchPermittedGroupsField: '' + AzureSearchStrictness: '3' + AzureOpenAIEmbeddingName: embeddingModel + AzureOpenAIEmbeddingEndpoint: aifoundry.outputs.aoaiEndpoint + USE_INTERNAL_STREAM: 'True' + SQLDB_SERVER: '${sqlDBModule.outputs.sqlServerName}.database.windows.net' + SQLDB_DATABASE: sqlDBModule.outputs.sqlDbName AZURE_COSMOSDB_ACCOUNT: cosmosDBModule.outputs.cosmosAccountName AZURE_COSMOSDB_CONVERSATIONS_CONTAINER: cosmosDBModule.outputs.cosmosContainerName AZURE_COSMOSDB_DATABASE: cosmosDBModule.outputs.cosmosDatabaseName AZURE_COSMOSDB_ENABLE_FEEDBACK: 'True' //VITE_POWERBI_EMBED_URL: 'TBD' imageTag: imageTag - userassignedIdentityClientId:managedIdentityModule.outputs.managedIdentityWebAppOutput.clientId - userassignedIdentityId:managedIdentityModule.outputs.managedIdentityWebAppOutput.id + userassignedIdentityClientId: managedIdentityModule.outputs.managedIdentityWebAppOutput.clientId + userassignedIdentityId: managedIdentityModule.outputs.managedIdentityWebAppOutput.id applicationInsightsId: aifoundry.outputs.applicationInsightsId - azureSearchServiceEndpoint:aifoundry.outputs.aiSearchTarget + azureSearchServiceEndpoint: aifoundry.outputs.aiSearchTarget sqlSystemPrompt: functionAppSqlPrompt callTranscriptSystemPrompt: functionAppCallTranscriptSystemPrompt streamTextSystemPrompt: functionAppStreamTextSystemPrompt - aiFoundryProjectName:aifoundry.outputs.aiFoundryProjectName + //aiFoundryProjectName:aifoundry.outputs.aiFoundryProjectName aiFoundryProjectEndpoint: aifoundry.outputs.aiFoundryProjectEndpoint aiFoundryName: aifoundry.outputs.aiFoundryName - applicationInsightsConnectionString:aifoundry.outputs.applicationInsightsConnectionString + applicationInsightsConnectionString: aifoundry.outputs.applicationInsightsConnectionString + azureExistingAIProjectResourceId: azureExistingAIProjectResourceId + aiSearchProjectConnectionName: aifoundry.outputs.aiSearchFoundryConnectionName } scope: resourceGroup(resourceGroup().name) } @@ -264,9 +268,11 @@ output STORAGE_CONTAINER_NAME string = storageAccountModule.outputs.storageConta output KEY_VAULT_NAME string = keyvaultModule.outputs.keyvaultName output COSMOSDB_ACCOUNT_NAME string = cosmosDBModule.outputs.cosmosAccountName output RESOURCE_GROUP_NAME string = resourceGroup().name +output RESOURCE_GROUP_NAME_FOUNDRY string = aifoundry.outputs.resourceGroupNameFoundry output SQLDB_SERVER string = sqlDBModule.outputs.sqlServerName output SQLDB_DATABASE string = sqlDBModule.outputs.sqlDbName output MANAGEDIDENTITY_WEBAPP_NAME string = managedIdentityModule.outputs.managedIdentityWebAppOutput.name output MANAGEDIDENTITY_WEBAPP_CLIENTID string = managedIdentityModule.outputs.managedIdentityWebAppOutput.clientId output AI_FOUNDRY_NAME string = aifoundry.outputs.aiFoundryName output AI_SEARCH_SERVICE_NAME string = aifoundry.outputs.aiSearchService +output WEB_APP_NAME string = appserviceModule.outputs.webAppName diff --git a/infra/main.json b/infra/main.json index b1483eb4e..6dcdaec21 100644 --- a/infra/main.json +++ b/infra/main.json @@ -5,7 +5,7 @@ "_generator": { "name": "bicep", "version": "0.36.1.42791", - "templateHash": "461277054460209703" + "templateHash": "539400033229136375" } }, "parameters": { @@ -24,8 +24,16 @@ "description": "Optional: Existing Log Analytics Workspace Resource ID" } }, + "azureExistingAIProjectResourceId": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "Use this parameter to use an existing AI project resource ID" + } + }, "cosmosLocation": { "type": "string", + "defaultValue": "eastus2", "metadata": { "description": "CosmosDB Location" } @@ -59,7 +67,7 @@ }, "gptDeploymentCapacity": { "type": "int", - "defaultValue": 30, + "defaultValue": 200, "minValue": 10, "metadata": { "description": "Capacity of the GPT deployment:" @@ -88,9 +96,8 @@ "type": "string", "defaultValue": "latest" }, - "AzureOpenAILocation": { + "aiDeploymentsLocation": { "type": "string", - "defaultValue": "eastus2", "allowedValues": [ "australiaeast", "eastus", @@ -103,7 +110,14 @@ "westus3" ], "metadata": { - "description": "Azure OpenAI Location" + "azd": { + "type": "location", + "usageName": [ + "OpenAI.GlobalStandard.gpt-4o-mini,200", + "OpenAI.Standard.text-embedding-ada-002,80" + ] + }, + "description": "Location for AI Foundry deployment. This is the location where the AI Foundry resources will be deployed." } }, "AZURE_LOCATION": { @@ -350,7 +364,7 @@ "abbrs": "[variables('$fxv#0')]", "functionAppSqlPrompt": "Generate a valid T-SQL query to find {query} for tables and columns provided below:\n 1. Table: Clients\n Columns: ClientId, Client, Email, Occupation, MaritalStatus, Dependents\n 2. Table: InvestmentGoals\n Columns: ClientId, InvestmentGoal\n 3. Table: Assets\n Columns: ClientId, AssetDate, Investment, ROI, Revenue, AssetType\n 4. Table: ClientSummaries\n Columns: ClientId, ClientSummary\n 5. Table: InvestmentGoalsDetails\n Columns: ClientId, InvestmentGoal, TargetAmount, Contribution\n 6. Table: Retirement\n Columns: ClientId, StatusDate, RetirementGoalProgress, EducationGoalProgress\n 7. Table: ClientMeetings\n Columns: ClientId, ConversationId, Title, StartTime, EndTime, Advisor, ClientEmail\n Always use the Investment column from the Assets table as the value.\n Assets table has snapshots of values by date. Do not add numbers across different dates for total values.\n Do not use client name in filters.\n Do not include assets values unless asked for.\n ALWAYS use ClientId = {clientid} in the query filter.\n ALWAYS select Client Name (Column: Client) in the query.\n Query filters are IMPORTANT. Add filters like AssetType, AssetDate, etc. if needed.\n Only return the generated SQL query. Do not return anything else.", "functionAppCallTranscriptSystemPrompt": "You are an assistant who supports wealth advisors in preparing for client meetings. \n You have access to the client’s past meeting call transcripts. \n When answering questions, especially summary requests, provide a detailed and structured response that includes key topics, concerns, decisions, and trends. \n If no data is available, state 'No relevant data found for previous meetings.", - "functionAppStreamTextSystemPrompt": "The currently selected client's name is '{SelectedClientName}'. Treat any case-insensitive or partial mention as referring to this client.\n If the user mentions no name, assume they are asking about '{SelectedClientName}'.\n If the user references a name that clearly differs from '{SelectedClientName}', respond only with: 'Please only ask questions about the selected client or select another client.' Otherwise, provide thorough answers for every question using only data from SQL or call transcripts.'\n If no data is found, respond with 'No data found for that client.' Remove any client identifiers from the final response.\n Always send clientId as '{client_id}'." + "functionAppStreamTextSystemPrompt": "The currently selected client's name is '{SelectedClientName}'. Treat any case-insensitive or partial mention as referring to this client.\n If the user mentions no name, assume they are asking about '{SelectedClientName}'.\n If the user references a name that clearly differs from '{SelectedClientName}' or comparing with other clients, respond only with: 'Please only ask questions about the selected client or select another client.' Otherwise, provide thorough answers for every question using only data from SQL or call transcripts.'\n If no data is found, respond with 'No data found for that client.' Remove any client identifiers from the final response.\n Always send clientId as '{client_id}'." }, "resources": [ { @@ -683,7 +697,7 @@ "value": "[variables('solutionPrefix')]" }, "solutionLocation": { - "value": "[parameters('AzureOpenAILocation')]" + "value": "[parameters('aiDeploymentsLocation')]" }, "keyVaultName": { "value": "[reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, resourceGroup().name), 'Microsoft.Resources/deployments', 'deploy_keyvault'), '2022-09-01').outputs.keyvaultName.value]" @@ -708,6 +722,9 @@ }, "existingLogAnalyticsWorkspaceId": { "value": "[parameters('existingLogAnalyticsWorkspaceId')]" + }, + "azureExistingAIProjectResourceId": { + "value": "[parameters('azureExistingAIProjectResourceId')]" } }, "template": { @@ -717,7 +734,7 @@ "_generator": { "name": "bicep", "version": "0.36.1.42791", - "templateHash": "15647067587936233417" + "templateHash": "13634339460279357495" } }, "parameters": { @@ -751,6 +768,10 @@ "existingLogAnalyticsWorkspaceId": { "type": "string", "defaultValue": "" + }, + "azureExistingAIProjectResourceId": { + "type": "string", + "defaultValue": "" } }, "variables": { @@ -1016,7 +1037,15 @@ "useExisting": "[not(empty(parameters('existingLogAnalyticsWorkspaceId')))]", "existingLawSubscription": "[if(variables('useExisting'), split(parameters('existingLogAnalyticsWorkspaceId'), '/')[2], '')]", "existingLawResourceGroup": "[if(variables('useExisting'), split(parameters('existingLogAnalyticsWorkspaceId'), '/')[4], '')]", - "existingLawName": "[if(variables('useExisting'), split(parameters('existingLogAnalyticsWorkspaceId'), '/')[8], '')]" + "existingLawName": "[if(variables('useExisting'), split(parameters('existingLogAnalyticsWorkspaceId'), '/')[8], '')]", + "existingOpenAIEndpoint": "[if(not(empty(parameters('azureExistingAIProjectResourceId'))), format('https://{0}.openai.azure.com/', split(parameters('azureExistingAIProjectResourceId'), '/')[8]), '')]", + "existingProjEndpoint": "[if(not(empty(parameters('azureExistingAIProjectResourceId'))), format('https://{0}.services.ai.azure.com/api/projects/{1}', split(parameters('azureExistingAIProjectResourceId'), '/')[8], split(parameters('azureExistingAIProjectResourceId'), '/')[10]), '')]", + "existingAIFoundryName": "[if(not(empty(parameters('azureExistingAIProjectResourceId'))), split(parameters('azureExistingAIProjectResourceId'), '/')[8], '')]", + "existingAIProjectName": "[if(not(empty(parameters('azureExistingAIProjectResourceId'))), split(parameters('azureExistingAIProjectResourceId'), '/')[10], '')]", + "existingAIServiceSubscription": "[if(not(empty(parameters('azureExistingAIProjectResourceId'))), split(parameters('azureExistingAIProjectResourceId'), '/')[2], '')]", + "existingAIServiceResourceGroup": "[if(not(empty(parameters('azureExistingAIProjectResourceId'))), split(parameters('azureExistingAIProjectResourceId'), '/')[4], '')]", + "aiSearchConnectionName": "[format('foundry-search-connection-{0}', parameters('solutionName'))]", + "aiAppInsightConnectionName": "[format('foundry-app-insights-connection-{0}', parameters('solutionName'))]" }, "resources": [ { @@ -1050,6 +1079,7 @@ ] }, { + "condition": "[empty(parameters('azureExistingAIProjectResourceId'))]", "type": "Microsoft.CognitiveServices/accounts", "apiVersion": "2025-04-01-preview", "name": "[variables('aiFoundryName')]", @@ -1074,6 +1104,7 @@ } }, { + "condition": "[empty(parameters('azureExistingAIProjectResourceId'))]", "type": "Microsoft.CognitiveServices/accounts/projects", "apiVersion": "2025-04-01-preview", "name": "[format('{0}/{1}', variables('aiFoundryName'), variables('aiProjectName'))]", @@ -1096,6 +1127,7 @@ "mode": "serial", "batchSize": 1 }, + "condition": "[empty(parameters('azureExistingAIProjectResourceId'))]", "type": "Microsoft.CognitiveServices/accounts/deployments", "apiVersion": "2023-05-01", "name": "[format('{0}/{1}', variables('aiFoundryName'), variables('aiModelDeployments')[copyIndex()].name)]", @@ -1146,9 +1178,10 @@ } }, { + "condition": "[empty(parameters('azureExistingAIProjectResourceId'))]", "type": "Microsoft.CognitiveServices/accounts/connections", "apiVersion": "2025-04-01-preview", - "name": "[format('{0}/{1}', variables('aiFoundryName'), 'foundry-search-connection')]", + "name": "[format('{0}/{1}', variables('aiFoundryName'), variables('aiSearchConnectionName'))]", "properties": { "category": "CognitiveSearch", "target": "[reference(resourceId('Microsoft.Search/searchServices', variables('aiSearchName')), '2025-02-01-preview').endpoint]", @@ -1166,39 +1199,74 @@ ] }, { + "condition": "[empty(parameters('azureExistingAIProjectResourceId'))]", "type": "Microsoft.Authorization/roleAssignments", "apiVersion": "2022-04-01", "scope": "[format('Microsoft.Search/searchServices/{0}', variables('aiSearchName'))]", - "name": "[guid(resourceId('Microsoft.Search/searchServices', variables('aiSearchName')), resourceId('Microsoft.CognitiveServices/accounts', variables('aiFoundryName')), extensionResourceId(resourceId('Microsoft.Search/searchServices', variables('aiSearchName')), 'Microsoft.Authorization/roleDefinitions', '1407120a-92aa-4202-b7e9-c0e197c71c8f'))]", + "name": "[guid(resourceId('Microsoft.Search/searchServices', variables('aiSearchName')), resourceId('Microsoft.CognitiveServices/accounts/projects', variables('aiFoundryName'), variables('aiProjectName')), extensionResourceId(resourceId('Microsoft.Search/searchServices', variables('aiSearchName')), 'Microsoft.Authorization/roleDefinitions', '1407120a-92aa-4202-b7e9-c0e197c71c8f'))]", "properties": { "roleDefinitionId": "[extensionResourceId(resourceId('Microsoft.Search/searchServices', variables('aiSearchName')), 'Microsoft.Authorization/roleDefinitions', '1407120a-92aa-4202-b7e9-c0e197c71c8f')]", - "principalId": "[reference(resourceId('Microsoft.CognitiveServices/accounts', variables('aiFoundryName')), '2025-04-01-preview', 'full').identity.principalId]", + "principalId": "[reference(resourceId('Microsoft.CognitiveServices/accounts/projects', variables('aiFoundryName'), variables('aiProjectName')), '2025-04-01-preview', 'full').identity.principalId]", "principalType": "ServicePrincipal" }, "dependsOn": [ - "[resourceId('Microsoft.CognitiveServices/accounts', variables('aiFoundryName'))]", + "[resourceId('Microsoft.CognitiveServices/accounts/projects', variables('aiFoundryName'), variables('aiProjectName'))]", "[resourceId('Microsoft.Search/searchServices', variables('aiSearchName'))]" ] }, { + "condition": "[not(empty(parameters('azureExistingAIProjectResourceId')))]", + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "scope": "[format('Microsoft.Search/searchServices/{0}', variables('aiSearchName'))]", + "name": "[guid(resourceGroup().id, variables('existingAIProjectName'), extensionResourceId(resourceId('Microsoft.Search/searchServices', variables('aiSearchName')), 'Microsoft.Authorization/roleDefinitions', '1407120a-92aa-4202-b7e9-c0e197c71c8f'), 'Existing')]", + "properties": { + "roleDefinitionId": "[extensionResourceId(resourceId('Microsoft.Search/searchServices', variables('aiSearchName')), 'Microsoft.Authorization/roleDefinitions', '1407120a-92aa-4202-b7e9-c0e197c71c8f')]", + "principalId": "[reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', variables('existingAIServiceSubscription'), variables('existingAIServiceResourceGroup')), 'Microsoft.Resources/deployments', 'assignOpenAIRoleToAISearch'), '2022-09-01').outputs.aiProjectPrincipalId.value]", + "principalType": "ServicePrincipal" + }, + "dependsOn": [ + "[resourceId('Microsoft.Search/searchServices', variables('aiSearchName'))]", + "[extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', variables('existingAIServiceSubscription'), variables('existingAIServiceResourceGroup')), 'Microsoft.Resources/deployments', 'assignOpenAIRoleToAISearch')]" + ] + }, + { + "condition": "[empty(parameters('azureExistingAIProjectResourceId'))]", "type": "Microsoft.Authorization/roleAssignments", "apiVersion": "2022-04-01", "scope": "[format('Microsoft.Search/searchServices/{0}', variables('aiSearchName'))]", - "name": "[guid(resourceId('Microsoft.Search/searchServices', variables('aiSearchName')), resourceId('Microsoft.CognitiveServices/accounts', variables('aiFoundryName')), extensionResourceId(resourceId('Microsoft.Search/searchServices', variables('aiSearchName')), 'Microsoft.Authorization/roleDefinitions', '7ca78c08-252a-4471-8644-bb5ff32d4ba0'))]", + "name": "[guid(resourceId('Microsoft.Search/searchServices', variables('aiSearchName')), resourceId('Microsoft.CognitiveServices/accounts/projects', variables('aiFoundryName'), variables('aiProjectName')), extensionResourceId(resourceId('Microsoft.Search/searchServices', variables('aiSearchName')), 'Microsoft.Authorization/roleDefinitions', '7ca78c08-252a-4471-8644-bb5ff32d4ba0'))]", "properties": { "roleDefinitionId": "[extensionResourceId(resourceId('Microsoft.Search/searchServices', variables('aiSearchName')), 'Microsoft.Authorization/roleDefinitions', '7ca78c08-252a-4471-8644-bb5ff32d4ba0')]", - "principalId": "[reference(resourceId('Microsoft.CognitiveServices/accounts', variables('aiFoundryName')), '2025-04-01-preview', 'full').identity.principalId]", + "principalId": "[reference(resourceId('Microsoft.CognitiveServices/accounts/projects', variables('aiFoundryName'), variables('aiProjectName')), '2025-04-01-preview', 'full').identity.principalId]", "principalType": "ServicePrincipal" }, "dependsOn": [ - "[resourceId('Microsoft.CognitiveServices/accounts', variables('aiFoundryName'))]", + "[resourceId('Microsoft.CognitiveServices/accounts/projects', variables('aiFoundryName'), variables('aiProjectName'))]", "[resourceId('Microsoft.Search/searchServices', variables('aiSearchName'))]" ] }, { + "condition": "[not(empty(parameters('azureExistingAIProjectResourceId')))]", + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "scope": "[format('Microsoft.Search/searchServices/{0}', variables('aiSearchName'))]", + "name": "[guid(resourceGroup().id, variables('existingAIProjectName'), extensionResourceId(resourceId('Microsoft.Search/searchServices', variables('aiSearchName')), 'Microsoft.Authorization/roleDefinitions', '7ca78c08-252a-4471-8644-bb5ff32d4ba0'), 'Existing')]", + "properties": { + "roleDefinitionId": "[extensionResourceId(resourceId('Microsoft.Search/searchServices', variables('aiSearchName')), 'Microsoft.Authorization/roleDefinitions', '7ca78c08-252a-4471-8644-bb5ff32d4ba0')]", + "principalId": "[reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', variables('existingAIServiceSubscription'), variables('existingAIServiceResourceGroup')), 'Microsoft.Resources/deployments', 'assignOpenAIRoleToAISearch'), '2022-09-01').outputs.aiProjectPrincipalId.value]", + "principalType": "ServicePrincipal" + }, + "dependsOn": [ + "[resourceId('Microsoft.Search/searchServices', variables('aiSearchName'))]", + "[extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', variables('existingAIServiceSubscription'), variables('existingAIServiceResourceGroup')), 'Microsoft.Resources/deployments', 'assignOpenAIRoleToAISearch')]" + ] + }, + { + "condition": "[empty(parameters('azureExistingAIProjectResourceId'))]", "type": "Microsoft.CognitiveServices/accounts/connections", "apiVersion": "2025-04-01-preview", - "name": "[format('{0}/{1}', variables('aiFoundryName'), 'foundry-app-insights-connection')]", + "name": "[format('{0}/{1}', variables('aiFoundryName'), variables('aiAppInsightConnectionName'))]", "properties": { "category": "AppInsights", "target": "[resourceId('Microsoft.Insights/components', variables('applicationInsightsName'))]", @@ -1230,7 +1298,7 @@ "apiVersion": "2021-11-01-preview", "name": "[format('{0}/{1}', parameters('keyVaultName'), 'AZURE-OPENAI-ENDPOINT')]", "properties": { - "value": "[reference(resourceId('Microsoft.CognitiveServices/accounts', variables('aiFoundryName')), '2025-04-01-preview').endpoints['OpenAI Language Model Instance API']]" + "value": "[if(not(empty(variables('existingOpenAIEndpoint'))), variables('existingOpenAIEndpoint'), reference(resourceId('Microsoft.CognitiveServices/accounts', variables('aiFoundryName')), '2025-04-01-preview').endpoints['OpenAI Language Model Instance API'])]" }, "dependsOn": [ "[resourceId('Microsoft.CognitiveServices/accounts', variables('aiFoundryName'))]" @@ -1262,6 +1330,174 @@ "properties": { "value": "transcripts_index" } + }, + { + "condition": "[not(empty(parameters('azureExistingAIProjectResourceId')))]", + "type": "Microsoft.Resources/deployments", + "apiVersion": "2022-09-01", + "name": "aiProjectSearchConnectionDeployment", + "subscriptionId": "[variables('existingAIServiceSubscription')]", + "resourceGroup": "[variables('existingAIServiceResourceGroup')]", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "existingAIProjectName": { + "value": "[variables('existingAIProjectName')]" + }, + "existingAIFoundryName": { + "value": "[variables('existingAIFoundryName')]" + }, + "aiSearchName": { + "value": "[variables('aiSearchName')]" + }, + "aiSearchResourceId": { + "value": "[resourceId('Microsoft.Search/searchServices', variables('aiSearchName'))]" + }, + "aiSearchLocation": { + "value": "[reference(resourceId('Microsoft.Search/searchServices', variables('aiSearchName')), '2025-02-01-preview', 'full').location]" + }, + "aiSearchConnectionName": { + "value": "[variables('aiSearchConnectionName')]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.36.1.42791", + "templateHash": "4784003223337407725" + } + }, + "parameters": { + "existingAIProjectName": { + "type": "string" + }, + "existingAIFoundryName": { + "type": "string" + }, + "aiSearchName": { + "type": "string" + }, + "aiSearchResourceId": { + "type": "string" + }, + "aiSearchLocation": { + "type": "string" + }, + "aiSearchConnectionName": { + "type": "string" + } + }, + "resources": [ + { + "type": "Microsoft.CognitiveServices/accounts/projects/connections", + "apiVersion": "2025-04-01-preview", + "name": "[format('{0}/{1}/{2}', parameters('existingAIFoundryName'), parameters('existingAIProjectName'), parameters('aiSearchConnectionName'))]", + "properties": { + "category": "CognitiveSearch", + "target": "[format('https://{0}.search.windows.net', parameters('aiSearchName'))]", + "authType": "AAD", + "isSharedToAll": true, + "metadata": { + "ApiType": "Azure", + "ResourceId": "[parameters('aiSearchResourceId')]", + "location": "[parameters('aiSearchLocation')]" + } + } + } + ] + } + }, + "dependsOn": [ + "[resourceId('Microsoft.Search/searchServices', variables('aiSearchName'))]" + ] + }, + { + "type": "Microsoft.Resources/deployments", + "apiVersion": "2022-09-01", + "name": "assignOpenAIRoleToAISearch", + "subscriptionId": "[variables('existingAIServiceSubscription')]", + "resourceGroup": "[variables('existingAIServiceResourceGroup')]", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "roleDefinitionId": { + "value": "[resourceId('Microsoft.Authorization/roleDefinitions', '5e0bd9bd-7b93-4f28-af87-19fc36ad61bd')]" + }, + "roleAssignmentName": { + "value": "[guid(resourceGroup().id, resourceId('Microsoft.Search/searchServices', variables('aiSearchName')), resourceId('Microsoft.Authorization/roleDefinitions', '5e0bd9bd-7b93-4f28-af87-19fc36ad61bd'), 'openai-foundry')]" + }, + "aiFoundryName": "[if(not(empty(parameters('azureExistingAIProjectResourceId'))), createObject('value', variables('existingAIFoundryName')), createObject('value', variables('aiFoundryName')))]", + "aiProjectName": "[if(not(empty(parameters('azureExistingAIProjectResourceId'))), createObject('value', variables('existingAIProjectName')), createObject('value', variables('aiProjectName')))]", + "principalId": { + "value": "[reference(resourceId('Microsoft.Search/searchServices', variables('aiSearchName')), '2025-02-01-preview', 'full').identity.principalId]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.36.1.42791", + "templateHash": "1709475957170755318" + } + }, + "parameters": { + "principalId": { + "type": "string", + "defaultValue": "" + }, + "roleDefinitionId": { + "type": "string" + }, + "roleAssignmentName": { + "type": "string", + "defaultValue": "" + }, + "aiFoundryName": { + "type": "string" + }, + "aiProjectName": { + "type": "string", + "defaultValue": "" + } + }, + "resources": [ + { + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "scope": "[format('Microsoft.CognitiveServices/accounts/{0}', parameters('aiFoundryName'))]", + "name": "[parameters('roleAssignmentName')]", + "properties": { + "roleDefinitionId": "[parameters('roleDefinitionId')]", + "principalId": "[parameters('principalId')]" + } + } + ], + "outputs": { + "aiServicesPrincipalId": { + "type": "string", + "value": "[reference(resourceId('Microsoft.CognitiveServices/accounts', parameters('aiFoundryName')), '2025-04-01-preview', 'full').identity.principalId]" + }, + "aiProjectPrincipalId": { + "type": "string", + "value": "[if(not(empty(parameters('aiProjectName'))), reference(resourceId('Microsoft.CognitiveServices/accounts/projects', parameters('aiFoundryName'), parameters('aiProjectName')), '2025-04-01-preview', 'full').identity.principalId, '')]" + } + } + } + }, + "dependsOn": [ + "[resourceId('Microsoft.Search/searchServices', variables('aiSearchName'))]" + ] } ], "outputs": { @@ -1273,25 +1509,21 @@ "type": "string", "value": "[resourceId('Microsoft.KeyVault/vaults', parameters('keyVaultName'))]" }, - "aiFoundryProjectEndpoint": { + "resourceGroupNameFoundry": { "type": "string", - "value": "[reference(resourceId('Microsoft.CognitiveServices/accounts/projects', variables('aiFoundryName'), variables('aiProjectName')), '2025-04-01-preview').endpoints['AI Foundry API']]" + "value": "[if(not(empty(variables('existingAIServiceResourceGroup'))), variables('existingAIServiceResourceGroup'), resourceGroup().name)]" }, - "aiServicesTarget": { + "aiFoundryProjectEndpoint": { "type": "string", - "value": "[reference(resourceId('Microsoft.CognitiveServices/accounts', variables('aiFoundryName')), '2025-04-01-preview').endpoint]" + "value": "[if(not(empty(variables('existingProjEndpoint'))), variables('existingProjEndpoint'), reference(resourceId('Microsoft.CognitiveServices/accounts/projects', variables('aiFoundryName'), variables('aiProjectName')), '2025-04-01-preview').endpoints['AI Foundry API'])]" }, "aoaiEndpoint": { "type": "string", - "value": "[reference(resourceId('Microsoft.CognitiveServices/accounts', variables('aiFoundryName')), '2025-04-01-preview').endpoints['OpenAI Language Model Instance API']]" + "value": "[if(not(empty(variables('existingOpenAIEndpoint'))), variables('existingOpenAIEndpoint'), reference(resourceId('Microsoft.CognitiveServices/accounts', variables('aiFoundryName')), '2025-04-01-preview').endpoints['OpenAI Language Model Instance API'])]" }, "aiFoundryName": { "type": "string", - "value": "[variables('aiFoundryName')]" - }, - "aiFoundryId": { - "type": "string", - "value": "[resourceId('Microsoft.CognitiveServices/accounts', variables('aiFoundryName'))]" + "value": "[if(not(empty(variables('existingAIFoundryName'))), variables('existingAIFoundryName'), variables('aiFoundryName'))]" }, "aiSearchName": { "type": "string", @@ -1311,7 +1543,7 @@ }, "aiFoundryProjectName": { "type": "string", - "value": "[variables('aiProjectName')]" + "value": "[if(not(empty(variables('existingAIProjectName'))), variables('existingAIProjectName'), variables('aiProjectName'))]" }, "applicationInsightsId": { "type": "string", @@ -1328,6 +1560,10 @@ "applicationInsightsConnectionString": { "type": "string", "value": "[reference(resourceId('Microsoft.Insights/components', variables('applicationInsightsName')), '2020-02-02').ConnectionString]" + }, + "aiSearchFoundryConnectionName": { + "type": "string", + "value": "[variables('aiSearchConnectionName')]" } } } @@ -1974,9 +2210,6 @@ "streamTextSystemPrompt": { "value": "[variables('functionAppStreamTextSystemPrompt')]" }, - "aiFoundryProjectName": { - "value": "[reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, resourceGroup().name), 'Microsoft.Resources/deployments', 'deploy_ai_foundry'), '2022-09-01').outputs.aiFoundryProjectName.value]" - }, "aiFoundryProjectEndpoint": { "value": "[reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, resourceGroup().name), 'Microsoft.Resources/deployments', 'deploy_ai_foundry'), '2022-09-01').outputs.aiFoundryProjectEndpoint.value]" }, @@ -1985,6 +2218,12 @@ }, "applicationInsightsConnectionString": { "value": "[reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, resourceGroup().name), 'Microsoft.Resources/deployments', 'deploy_ai_foundry'), '2022-09-01').outputs.applicationInsightsConnectionString.value]" + }, + "azureExistingAIProjectResourceId": { + "value": "[parameters('azureExistingAIProjectResourceId')]" + }, + "aiSearchProjectConnectionName": { + "value": "[reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, resourceGroup().name), 'Microsoft.Resources/deployments', 'deploy_ai_foundry'), '2022-09-01').outputs.aiSearchFoundryConnectionName.value]" } }, "template": { @@ -1994,7 +2233,7 @@ "_generator": { "name": "bicep", "version": "0.36.1.42791", - "templateHash": "6657678385477724168" + "templateHash": "4144537398413637557" } }, "parameters": { @@ -2315,18 +2554,25 @@ "type": "string", "defaultValue": "false" }, - "aiFoundryProjectName": { - "type": "string" - }, "aiFoundryName": { "type": "string" }, "applicationInsightsConnectionString": { "type": "string" + }, + "aiSearchProjectConnectionName": { + "type": "string" + }, + "azureExistingAIProjectResourceId": { + "type": "string", + "defaultValue": "" } }, "variables": { - "WebAppImageName": "[format('DOCKER|bycwacontainerreg.azurecr.io/byc-wa-app:{0}', parameters('imageTag'))]" + "WebAppImageName": "[format('DOCKER|bycwacontainerreg.azurecr.io/byc-wa-app:{0}', parameters('imageTag'))]", + "existingAIServiceSubscription": "[if(not(empty(parameters('azureExistingAIProjectResourceId'))), split(parameters('azureExistingAIProjectResourceId'), '/')[2], subscription().subscriptionId)]", + "existingAIServiceResourceGroup": "[if(not(empty(parameters('azureExistingAIProjectResourceId'))), split(parameters('azureExistingAIProjectResourceId'), '/')[4], resourceGroup().name)]", + "existingAIServicesName": "[if(not(empty(parameters('azureExistingAIProjectResourceId'))), split(parameters('azureExistingAIProjectResourceId'), '/')[8], '')]" }, "resources": [ { @@ -2533,6 +2779,10 @@ { "name": "AZURE_AI_AGENT_API_VERSION", "value": "[parameters('AzureOpenAIApiVersion')]" + }, + { + "name": "AZURE_SEARCH_CONNECTION_NAME", + "value": "[parameters('aiSearchProjectConnectionName')]" } ], "linuxFxVersion": "[variables('WebAppImageName')]" @@ -2542,34 +2792,6 @@ "[resourceId('Microsoft.Web/serverfarms', parameters('HostingPlanName'))]" ] }, - { - "type": "Microsoft.Authorization/roleAssignments", - "apiVersion": "2022-04-01", - "scope": "[format('Microsoft.CognitiveServices/accounts/{0}', parameters('aiFoundryName'))]", - "name": "[guid(resourceId('Microsoft.Web/sites', parameters('WebsiteName')), resourceId('Microsoft.CognitiveServices/accounts', parameters('aiFoundryName')), extensionResourceId(resourceId('Microsoft.CognitiveServices/accounts', parameters('aiFoundryName')), 'Microsoft.Authorization/roleDefinitions', '53ca6127-db72-4b80-b1b0-d745d6d5456d'))]", - "properties": { - "roleDefinitionId": "[extensionResourceId(resourceId('Microsoft.CognitiveServices/accounts', parameters('aiFoundryName')), 'Microsoft.Authorization/roleDefinitions', '53ca6127-db72-4b80-b1b0-d745d6d5456d')]", - "principalId": "[reference(resourceId('Microsoft.Web/sites', parameters('WebsiteName')), '2020-06-01', 'full').identity.principalId]", - "principalType": "ServicePrincipal" - }, - "dependsOn": [ - "[resourceId('Microsoft.Web/sites', parameters('WebsiteName'))]" - ] - }, - { - "type": "Microsoft.Authorization/roleAssignments", - "apiVersion": "2022-04-01", - "scope": "[format('Microsoft.CognitiveServices/accounts/{0}/projects/{1}', parameters('aiFoundryName'), parameters('aiFoundryProjectName'))]", - "name": "[guid(resourceId('Microsoft.Web/sites', parameters('WebsiteName')), resourceId('Microsoft.CognitiveServices/accounts/projects', parameters('aiFoundryName'), parameters('aiFoundryProjectName')), extensionResourceId(resourceId('Microsoft.CognitiveServices/accounts/projects', parameters('aiFoundryName'), parameters('aiFoundryProjectName')), 'Microsoft.Authorization/roleDefinitions', '53ca6127-db72-4b80-b1b0-d745d6d5456d'))]", - "properties": { - "roleDefinitionId": "[extensionResourceId(resourceId('Microsoft.CognitiveServices/accounts/projects', parameters('aiFoundryName'), parameters('aiFoundryProjectName')), 'Microsoft.Authorization/roleDefinitions', '53ca6127-db72-4b80-b1b0-d745d6d5456d')]", - "principalId": "[reference(resourceId('Microsoft.Web/sites', parameters('WebsiteName')), '2020-06-01', 'full').identity.principalId]", - "principalType": "ServicePrincipal" - }, - "dependsOn": [ - "[resourceId('Microsoft.Web/sites', parameters('WebsiteName'))]" - ] - }, { "type": "Microsoft.Resources/deployments", "apiVersion": "2022-09-01", @@ -2630,12 +2852,97 @@ "dependsOn": [ "[resourceId('Microsoft.Web/sites', parameters('WebsiteName'))]" ] + }, + { + "type": "Microsoft.Resources/deployments", + "apiVersion": "2022-09-01", + "name": "assignAiUserRoleToAiProject", + "subscriptionId": "[variables('existingAIServiceSubscription')]", + "resourceGroup": "[variables('existingAIServiceResourceGroup')]", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "principalId": { + "value": "[reference(resourceId('Microsoft.Web/sites', parameters('WebsiteName')), '2020-06-01', 'full').identity.principalId]" + }, + "roleDefinitionId": { + "value": "[extensionResourceId(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', variables('existingAIServiceSubscription'), variables('existingAIServiceResourceGroup')), 'Microsoft.CognitiveServices/accounts', parameters('aiFoundryName')), 'Microsoft.Authorization/roleDefinitions', '53ca6127-db72-4b80-b1b0-d745d6d5456d')]" + }, + "roleAssignmentName": { + "value": "[guid(parameters('WebsiteName'), extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', variables('existingAIServiceSubscription'), variables('existingAIServiceResourceGroup')), 'Microsoft.CognitiveServices/accounts', parameters('aiFoundryName')), extensionResourceId(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', variables('existingAIServiceSubscription'), variables('existingAIServiceResourceGroup')), 'Microsoft.CognitiveServices/accounts', parameters('aiFoundryName')), 'Microsoft.Authorization/roleDefinitions', '53ca6127-db72-4b80-b1b0-d745d6d5456d'))]" + }, + "aiFoundryName": "[if(not(empty(parameters('azureExistingAIProjectResourceId'))), createObject('value', variables('existingAIServicesName')), createObject('value', parameters('aiFoundryName')))]" + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.36.1.42791", + "templateHash": "1709475957170755318" + } + }, + "parameters": { + "principalId": { + "type": "string", + "defaultValue": "" + }, + "roleDefinitionId": { + "type": "string" + }, + "roleAssignmentName": { + "type": "string", + "defaultValue": "" + }, + "aiFoundryName": { + "type": "string" + }, + "aiProjectName": { + "type": "string", + "defaultValue": "" + } + }, + "resources": [ + { + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "scope": "[format('Microsoft.CognitiveServices/accounts/{0}', parameters('aiFoundryName'))]", + "name": "[parameters('roleAssignmentName')]", + "properties": { + "roleDefinitionId": "[parameters('roleDefinitionId')]", + "principalId": "[parameters('principalId')]" + } + } + ], + "outputs": { + "aiServicesPrincipalId": { + "type": "string", + "value": "[reference(resourceId('Microsoft.CognitiveServices/accounts', parameters('aiFoundryName')), '2025-04-01-preview', 'full').identity.principalId]" + }, + "aiProjectPrincipalId": { + "type": "string", + "value": "[if(not(empty(parameters('aiProjectName'))), reference(resourceId('Microsoft.CognitiveServices/accounts/projects', parameters('aiFoundryName'), parameters('aiProjectName')), '2025-04-01-preview', 'full').identity.principalId, '')]" + } + } + } + }, + "dependsOn": [ + "[resourceId('Microsoft.Web/sites', parameters('WebsiteName'))]" + ] } ], "outputs": { "webAppUrl": { "type": "string", "value": "[format('https://{0}.azurewebsites.net', parameters('WebsiteName'))]" + }, + "webAppName": { + "type": "string", + "value": "[parameters('WebsiteName')]" } } } @@ -2673,6 +2980,10 @@ "type": "string", "value": "[resourceGroup().name]" }, + "RESOURCE_GROUP_NAME_FOUNDRY": { + "type": "string", + "value": "[reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, resourceGroup().name), 'Microsoft.Resources/deployments', 'deploy_ai_foundry'), '2022-09-01').outputs.resourceGroupNameFoundry.value]" + }, "SQLDB_SERVER": { "type": "string", "value": "[reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, resourceGroup().name), 'Microsoft.Resources/deployments', 'deploy_sql_db'), '2022-09-01').outputs.sqlServerName.value]" @@ -2696,6 +3007,10 @@ "AI_SEARCH_SERVICE_NAME": { "type": "string", "value": "[reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, resourceGroup().name), 'Microsoft.Resources/deployments', 'deploy_ai_foundry'), '2022-09-01').outputs.aiSearchService.value]" + }, + "WEB_APP_NAME": { + "type": "string", + "value": "[reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, resourceGroup().name), 'Microsoft.Resources/deployments', 'deploy_app_service'), '2022-09-01').outputs.webAppName.value]" } } } \ No newline at end of file diff --git a/infra/main.parameters.json b/infra/main.parameters.json index a9a35d824..10822f1f7 100644 --- a/infra/main.parameters.json +++ b/infra/main.parameters.json @@ -34,6 +34,9 @@ }, "existingLogAnalyticsWorkspaceId": { "value": "${AZURE_ENV_LOG_ANALYTICS_WORKSPACE_ID}" + }, + "azureExistingAIProjectResourceId": { + "value": "${AZURE_EXISTING_AI_PROJECT_RESOURCE_ID}" } } } \ No newline at end of file diff --git a/infra/scripts/add_cosmosdb_access.sh b/infra/scripts/add_cosmosdb_access.sh index 957e49e61..b75801067 100644 --- a/infra/scripts/add_cosmosdb_access.sh +++ b/infra/scripts/add_cosmosdb_access.sh @@ -23,6 +23,16 @@ fi echo "Getting signed in user id" signed_user_id=$(az ad signed-in-user show --query id -o tsv) +if [ $? -ne 0 ]; then + if [ -z "$managedIdentityClientId" ]; then + echo "Error: Failed to get signed in user id." + exit 1 + else + signed_user_id=$managedIdentityClientId + signed_user_id=$(az ad sp show --id $managedIdentityClientId --query id -o tsv) + + fi +fi # Check if the user has the Cosmos DB Built-in Data Contributor role echo "Checking if user has the Cosmos DB Built-in Data Contributor role" diff --git a/infra/scripts/add_user_scripts/create_sql_user_and_role.sh b/infra/scripts/add_user_scripts/create_sql_user_and_role.sh index 65526819c..db4f561bd 100644 --- a/infra/scripts/add_user_scripts/create_sql_user_and_role.sh +++ b/infra/scripts/add_user_scripts/create_sql_user_and_role.sh @@ -4,7 +4,9 @@ SqlServerName="$1" SqlDatabaseName="$2" UserRoleJSONArray="$3" -ManagedIdentityClientId="$6" +ManagedIdentityClientId="$4" + +echo "Script Started" # Function to check if a command exists or runs successfully function check_command() { @@ -22,6 +24,7 @@ check_command "sqlcmd '-?'" if az account show &> /dev/null; then echo "Already authenticated with Azure." else + echo "Not authenticated with Azure. Attempting to authenticate..." if [ -n "$ManagedIdentityClientId" ]; then # Use managed identity if running in Azure echo "Authenticating with Managed Identity..." @@ -31,13 +34,28 @@ else echo "Authenticating with Azure CLI..." az login fi - echo "Not authenticated with Azure. Attempting to authenticate..." +fi + +echo "Getting signed in user id" +signed_user_id=$(az ad signed-in-user show --query id -o tsv) +if [ $? -ne 0 ]; then + if [ -z "$ManagedIdentityClientId" ]; then + echo "Error: Failed to get signed in user id." + exit 1 + else + signed_user_id=$ManagedIdentityClientId + # signed_user_id=$(az ad sp show --id $ManagedIdentityClientId --query id -o tsv) + + fi fi SQL_QUERY="" #loop through the JSON array and create users and assign roles using grep and sed count=1 +echo "Processing JSON object" while read -r json_object; do + + # echo "Processing JSON object: $json_object" # Extract fields from the JSON object using grep and sed clientId=$(echo "$json_object" | grep -o '"clientId": *"[^"]*"' | sed 's/"clientId": *"\([^"]*\)"/\1/') displayName=$(echo "$json_object" | grep -o '"displayName": *"[^"]*"' | sed 's/"displayName": *"\([^"]*\)"/\1/') diff --git a/infra/scripts/checkquota.sh b/infra/scripts/checkquota.sh old mode 100644 new mode 100755 diff --git a/infra/scripts/copy_kb_files.sh b/infra/scripts/copy_kb_files.sh index 09b8148a8..1d94772b9 100644 --- a/infra/scripts/copy_kb_files.sh +++ b/infra/scripts/copy_kb_files.sh @@ -12,6 +12,7 @@ echo "Script Started" if az account show &> /dev/null; then echo "Already authenticated with Azure." else + echo "Not authenticated with Azure. Attempting to authenticate..." if [ -n "$managedIdentityClientId" ]; then # Use managed identity if running in Azure echo "Authenticating with Managed Identity..." @@ -21,51 +22,59 @@ else echo "Authenticating with Azure CLI..." az login fi - echo "Not authenticated with Azure. Attempting to authenticate..." +fi + +echo "Getting signed in user id" +signed_user_id=$(az ad signed-in-user show --query id -o tsv) +if [ $? -ne 0 ]; then + if [ -z "$managedIdentityClientId" ]; then + echo "Error: Failed to get signed in user id." + exit 1 + else + signed_user_id=$managedIdentityClientId + fi fi # if using managed identity, skip role assignments as its already provided via bicep -if [ -n "$managedIdentityClientId" ]; then - echo "Skipping role assignments as managed identity is used" -else - echo "Getting signed in user id" - signed_user_id=$(az ad signed-in-user show --query id -o tsv) - - echo "Getting storage account resource id" - storage_account_resource_id=$(az storage account show --name $storageAccount --query id --output tsv) - - #check if user has the Storage Blob Data Contributor role, add it if not - echo "Checking if user has the Storage Blob Data Contributor role" - role_assignment=$(MSYS_NO_PATHCONV=1 az role assignment list --assignee $signed_user_id --role "Storage Blob Data Contributor" --scope $storage_account_resource_id --query "[].roleDefinitionId" -o tsv) - if [ -z "$role_assignment" ]; then - echo "User does not have the Storage Blob Data Contributor role. Assigning the role." - MSYS_NO_PATHCONV=1 az role assignment create --assignee $signed_user_id --role "Storage Blob Data Contributor" --scope $storage_account_resource_id --output none - if [ $? -eq 0 ]; then - echo "Role assignment completed successfully." - retries=3 - while [ $retries -gt 0 ]; do - # Check if the role assignment was successful - role_assignment_check=$(MSYS_NO_PATHCONV=1 az role assignment list --assignee $signed_user_id --role "Storage Blob Data Contributor" --scope $storage_account_resource_id --query "[].roleDefinitionId" -o tsv) - if [ -n "$role_assignment_check" ]; then - echo "Role assignment verified successfully." - break - else - echo "Role assignment not found, retrying..." - ((retries--)) - sleep 10 - fi - done - if [ $retries -eq 0 ]; then - echo "Error: Role assignment verification failed after multiple attempts. Try rerunning the script." - exit 1 + +# echo "Getting signed in user id" +# signed_user_id=$(az ad signed-in-user show --query id -o tsv) + +echo "Getting storage account resource id" +storage_account_resource_id=$(az storage account show --name $storageAccount --query id --output tsv) + +#check if user has the Storage Blob Data Contributor role, add it if not +echo "Checking if user has the Storage Blob Data Contributor role" +role_assignment=$(MSYS_NO_PATHCONV=1 az role assignment list --assignee $signed_user_id --role "Storage Blob Data Contributor" --scope $storage_account_resource_id --query "[].roleDefinitionId" -o tsv) +if [ -z "$role_assignment" ]; then + echo "User does not have the Storage Blob Data Contributor role. Assigning the role." + MSYS_NO_PATHCONV=1 az role assignment create --assignee $signed_user_id --role "Storage Blob Data Contributor" --scope $storage_account_resource_id --output none + if [ $? -eq 0 ]; then + echo "Role assignment completed successfully." + retries=3 + while [ $retries -gt 0 ]; do + # Check if the role assignment was successful + role_assignment_check=$(MSYS_NO_PATHCONV=1 az role assignment list --assignee $signed_user_id --role "Storage Blob Data Contributor" --scope $storage_account_resource_id --query "[].roleDefinitionId" -o tsv) + if [ -n "$role_assignment_check" ]; then + echo "Role assignment verified successfully." + sleep 60 + break + else + echo "Role assignment not found, retrying..." + ((retries--)) + sleep 10 fi - else - echo "Error: Role assignment failed." + done + if [ $retries -eq 0 ]; then + echo "Error: Role assignment verification failed after multiple attempts. Try rerunning the script." exit 1 fi else - echo "User already has the Storage Blob Data Contributor role." + echo "Error: Role assignment failed." + exit 1 fi +else + echo "User already has the Storage Blob Data Contributor role." fi zipFileName1="clientdata.zip" @@ -86,7 +95,7 @@ extractionPath1="" extractionPath2="" # Check if running in Azure Container App -if !([ -z "$baseUrl" ] && [ -z "$managedIdentityClientId" ]); then +if [ -n "$baseUrl" ] && [ -n "$managedIdentityClientId" ]; then extractionPath1="/mnt/azscripts/azscriptinput/$extractedFolder1" extractionPath2="/mnt/azscripts/azscriptinput/$extractedFolder2" @@ -110,8 +119,52 @@ else unzip -o $zipUrl2 -d $extractionPath2 fi +echo "Uploading files to Azure Blob Storage" # Using az storage blob upload-batch to upload files with managed identity authentication, as the az storage fs directory upload command is not working with managed identity authentication. az storage blob upload-batch --account-name "$storageAccount" --destination data/"$extractedFolder1" --source $extractionPath1 --auth-mode login --pattern '*' --overwrite --output none +if [ $? -ne 0 ]; then + retries=3 + sleepTime=10 + echo "Error: Failed to upload files to Azure Blob Storage. Retrying upload...($((4 - retries)) of 3)" + while [ $retries -gt 0 ]; do + sleep $sleepTime + az storage blob upload-batch --account-name "$storageAccount" --destination data/"$extractedFolder1" --source $extractionPath1 --auth-mode login --pattern '*' --overwrite --output none + if [ $? -eq 0 ]; then + echo "Files uploaded successfully to Azure Blob Storage." + break + else + ((retries--)) + echo "Retrying upload... ($((4 - retries)) of 3)" + sleepTime=$((sleepTime * 2)) + sleep $sleepTime + fi + done + exit 1 +else + echo "Files uploaded successfully to Azure Blob Storage." +fi + az storage blob upload-batch --account-name "$storageAccount" --destination data/"$extractedFolder2" --source $extractionPath2 --auth-mode login --pattern '*' --overwrite --output none +if [ $? -ne 0 ]; then + retries=3 + sleepTime=10 + echo "Error: Failed to upload files to Azure Blob Storage. Retrying upload...($((4 - retries)) of 3)" + while [ $retries -gt 0 ]; do + sleep $sleepTime + az storage blob upload-batch --account-name "$storageAccount" --destination data/"$extractedFolder2" --source $extractionPath2 --auth-mode login --pattern '*' --overwrite --output none + if [ $? -eq 0 ]; then + echo "Files uploaded successfully to Azure Blob Storage." + break + else + ((retries--)) + echo "Retrying upload... ($((4 - retries)) of 3)" + sleepTime=$((sleepTime * 2)) + sleep $sleepTime + fi + done + exit 1 +else + echo "Files uploaded successfully to Azure Blob Storage." +fi # az storage fs directory upload -f "$fileSystem" --account-name "$storageAccount" -s "$extractedFolder1" --account-key "$accountKey" --recursive # az storage fs directory upload -f "$fileSystem" --account-name "$storageAccount" -s "$extractedFolder2" --account-key "$accountKey" --recursive diff --git a/infra/scripts/index_scripts/create_search_index.py b/infra/scripts/index_scripts/create_search_index.py index 42316feff..b429a6456 100644 --- a/infra/scripts/index_scripts/create_search_index.py +++ b/infra/scripts/index_scripts/create_search_index.py @@ -22,6 +22,8 @@ SimpleField, VectorSearch, VectorSearchProfile, + AzureOpenAIVectorizer, + AzureOpenAIVectorizerParameters ) from azure.storage.filedatalake import ( DataLakeDirectoryClient, @@ -93,8 +95,19 @@ VectorSearchProfile( name="myHnswProfile", algorithm_configuration_name="myHnsw", + vectorizer_name="aoai-ada-002-vectorizer", ) ], + vectorizers= [ + AzureOpenAIVectorizer( + vectorizer_name="aoai-ada-002-vectorizer", + parameters=AzureOpenAIVectorizerParameters( + resource_url=openai_api_base, + deployment_name=openai_embedding_model or "text-embedding-ada-002", + model_name=openai_embedding_model or "text-embedding-ada-002", + ) + ) + ] ) semantic_config = SemanticConfiguration( @@ -121,9 +134,7 @@ # Function: Get Embeddings def get_embeddings(text: str, openai_api_base, openai_api_version, azure_token_provider): - model_id = ( - openai_embedding_model if openai_embedding_model else "text-embedding-ada-002" - ) + model_id = openai_embedding_model or "text-embedding-ada-002" client = AzureOpenAI( api_version=openai_api_version, azure_endpoint=openai_api_base, @@ -201,7 +212,7 @@ def chunk_data(text): print(paths) search_client = SearchClient(search_endpoint, index_name, credential) -index_client = SearchIndexClient(endpoint=search_endpoint, credential=credential) +# index_client = SearchIndexClient(endpoint=search_endpoint, credential=credential) # metadata_filepath = f'Data/{foldername}/meeting_transcripts_metadata/transcripts_metadata.csv' # # df_metadata = spark.read.format("csv").option("header","true").option("multiLine", "true").option("quote", "\"").option("escape", "\"").load(metadata_filepath).toPandas() diff --git a/infra/scripts/process_sample_data.sh b/infra/scripts/process_sample_data.sh index 62f260f0c..7b6213eee 100644 --- a/infra/scripts/process_sample_data.sh +++ b/infra/scripts/process_sample_data.sh @@ -12,12 +12,17 @@ webAppManagedIdentityClientId="$8" webAppManagedIdentityDisplayName="$9" aiFoundryName="${10}" aiSearchName="${11}" +resourceGroupNameFoundry="${12}" # get parameters from azd env, if not provided if [ -z "$resourceGroupName" ]; then resourceGroupName=$(azd env get-value RESOURCE_GROUP_NAME) fi +if [ -z "$resourceGroupNameFoundry" ]; then + resourceGroupNameFoundry=$(azd env get-value RESOURCE_GROUP_NAME_FOUNDRY) +fi + if [ -z "$cosmosDbAccountName" ]; then cosmosDbAccountName=$(azd env get-value COSMOSDB_ACCOUNT_NAME) fi @@ -58,12 +63,72 @@ if [ -z "$aiSearchName" ]; then aiSearchName=$(azd env get-value AI_SEARCH_SERVICE_NAME) fi +azSubscriptionId=$(azd env get-value AZURE_SUBSCRIPTION_ID) + # Check if all required arguments are provided -if [ -z "$resourceGroupName" ] || [ -z "$cosmosDbAccountName" ] || [ -z "$storageAccount" ] || [ -z "$fileSystem" ] || [ -z "$keyvaultName" ] || [ -z "$sqlServerName" ] || [ -z "$SqlDatabaseName" ] || [ -z "$webAppManagedIdentityClientId" ] || [ -z "$webAppManagedIdentityDisplayName" ] || [ -z "$aiFoundryName" ] || [ -z "$aiSearchName" ]; then - echo "Usage: $0 " +if [ -z "$resourceGroupName" ] || [ -z "$cosmosDbAccountName" ] || [ -z "$storageAccount" ] || [ -z "$fileSystem" ] || [ -z "$keyvaultName" ] || [ -z "$sqlServerName" ] || [ -z "$SqlDatabaseName" ] || [ -z "$webAppManagedIdentityClientId" ] || [ -z "$webAppManagedIdentityDisplayName" ] || [ -z "$aiFoundryName" ] || [ -z "$aiSearchName" ] || [ -z "$resourceGroupNameFoundry" ]; then + echo "Usage: $0 " exit 1 fi +# Authenticate with Azure +if az account show &> /dev/null; then + echo "Already authenticated with Azure." +else + echo "Not authenticated with Azure. Attempting to authenticate..." + if [ -n "$managedIdentityClientId" ]; then + # Use managed identity if running in Azure + echo "Authenticating with Managed Identity..." + az login --identity --client-id ${managedIdentityClientId} + else + # Use Azure CLI login if running locally + echo "Authenticating with Azure CLI..." + az login + fi +fi + +#check if user has selected the correct subscription +currentSubscriptionId=$(az account show --query id -o tsv) +currentSubscriptionName=$(az account show --query name -o tsv) +if [ "$currentSubscriptionId" != "$azSubscriptionId" ]; then + echo "Current selected subscription is $currentSubscriptionName ( $currentSubscriptionId )." + read -rp "Do you want to continue with this subscription?(y/n): " confirmation + if [[ "$confirmation" != "y" && "$confirmation" != "Y" ]]; then + echo "Fetching available subscriptions..." + availableSubscriptions=$(az account list --query "[?state=='Enabled'].[name,id]" --output tsv) + while true; do + echo "" + echo "Available Subscriptions:" + echo "========================" + echo "$availableSubscriptions" | awk '{printf "%d. %s ( %s )\n", NR, $1, $2}' + echo "========================" + echo "" + read -rp "Enter the number of the subscription (1-$(echo "$availableSubscriptions" | wc -l)) to use: " subscriptionIndex + if [[ "$subscriptionIndex" =~ ^[0-9]+$ ]] && [ "$subscriptionIndex" -ge 1 ] && [ "$subscriptionIndex" -le $(echo "$availableSubscriptions" | wc -l) ]; then + selectedSubscription=$(echo "$availableSubscriptions" | sed -n "${subscriptionIndex}p") + selectedSubscriptionName=$(echo "$selectedSubscription" | cut -f1) + selectedSubscriptionId=$(echo "$selectedSubscription" | cut -f2) + + # Set the selected subscription + if az account set --subscription "$selectedSubscriptionId"; then + echo "Switched to subscription: $selectedSubscriptionName ( $selectedSubscriptionId )" + break + else + echo "Failed to switch to subscription: $selectedSubscriptionName ( $selectedSubscriptionId )." + fi + else + echo "Invalid selection. Please try again." + fi + done + else + echo "Proceeding with the current subscription: $currentSubscriptionName ( $currentSubscriptionId )" + az account set --subscription "$currentSubscriptionId" + fi +else + echo "Proceeding with the subscription: $currentSubscriptionName ( $currentSubscriptionId )" + az account set --subscription "$currentSubscriptionId" +fi + # Call add_cosmosdb_access.sh echo "Running add_cosmosdb_access.sh" bash infra/scripts/add_cosmosdb_access.sh "$resourceGroupName" "$cosmosDbAccountName" @@ -84,7 +149,7 @@ echo "copy_kb_files.sh completed successfully." # Call run_create_index_scripts.sh echo "Running run_create_index_scripts.sh" -bash infra/scripts/run_create_index_scripts.sh "$keyvaultName" "" "" "$resourceGroupName" "$sqlServerName" "$aiFoundryName" "$aiSearchName" +bash infra/scripts/run_create_index_scripts.sh "$keyvaultName" "" "" "$resourceGroupName" "$sqlServerName" "$aiFoundryName" "$aiSearchName" "$resourceGroupNameFoundry" if [ $? -ne 0 ]; then echo "Error: run_create_index_scripts.sh failed." exit 1 diff --git a/infra/scripts/quota_check_params.sh b/infra/scripts/quota_check_params.sh old mode 100644 new mode 100755 diff --git a/infra/scripts/run_create_index_scripts.sh b/infra/scripts/run_create_index_scripts.sh index dbe33af00..fcf775723 100644 --- a/infra/scripts/run_create_index_scripts.sh +++ b/infra/scripts/run_create_index_scripts.sh @@ -1,5 +1,4 @@ #!/bin/bash -echo "started the script" # Variables keyvaultName="$1" @@ -9,6 +8,7 @@ resourceGroupName="$4" sqlServerName="$5" aiFoundryName="$6" aiSearchName="$7" +resourceGroupNameFoundry="$8" echo "Script Started" @@ -16,6 +16,7 @@ echo "Script Started" if az account show &> /dev/null; then echo "Already authenticated with Azure." else + echo "Not authenticated with Azure. Attempting to authenticate..." if [ -n "$managedIdentityClientId" ]; then # Use managed identity if running in Azure echo "Authenticating with Managed Identity..." @@ -25,111 +26,117 @@ else echo "Authenticating with Azure CLI..." az login fi - echo "Not authenticated with Azure. Attempting to authenticate..." fi -# if using managed identity, skip role assignments as its already provided via bicep -if [ -n "$managedIdentityClientId" ]; then - echo "Skipping role assignments as managed identity is used" -else - # Get signed in user and store the output - echo "Getting signed in user id and display name" - signed_user=$(az ad signed-in-user show --query "{id:id, displayName:displayName}" -o json) - - # Extract id and displayName using grep and sed - signed_user_id=$(echo "$signed_user" | grep -oP '"id":\s*"\K[^"]+') - signed_user_display_name=$(echo "$signed_user" | grep -oP '"displayName":\s*"\K[^"]+') - - # echo "Getting signed in user id" - # signed_user_id=$(az ad signed-in-user show --query id -o tsv) - - ### Assign Key Vault Administrator role to the signed in user ### - - echo "Getting key vault resource id" - key_vault_resource_id=$(az keyvault show --name $keyvaultName --query id --output tsv) - - # Check if the user has the Key Vault Administrator role - echo "Checking if user has the Key Vault Administrator role" - role_assignment=$(MSYS_NO_PATHCONV=1 az role assignment list --assignee $signed_user_id --role "Key Vault Administrator" --scope $key_vault_resource_id --query "[].roleDefinitionId" -o tsv) - if [ -z "$role_assignment" ]; then - echo "User does not have the Key Vault Administrator role. Assigning the role." - MSYS_NO_PATHCONV=1 az role assignment create --assignee $signed_user_id --role "Key Vault Administrator" --scope $key_vault_resource_id --output none - if [ $? -eq 0 ]; then - echo "Key Vault Administrator role assigned successfully." - else - echo "Failed to assign Key Vault Administrator role." - exit 1 - fi +# Get signed in user and store the output +echo "Getting signed in user id and display name" +signed_user=$(az ad signed-in-user show --query "{id:id, displayName:displayName}" -o json) + +# Extract id and displayName using grep and sed +signed_user_id=$(echo "$signed_user" | grep -oP '"id":\s*"\K[^"]+') +signed_user_display_name=$(echo "$signed_user" | grep -oP '"displayName":\s*"\K[^"]+') + +if [ $? -ne 0 ]; then + if [ -z "$managedIdentityClientId" ]; then + echo "Error: Failed to get signed in user id." + exit 1 else - echo "User already has the Key Vault Administrator role." + signed_user_id=$managedIdentityClientId + signed_user_display_name=$(az ad sp show --id "$signed_user_id" --query displayName -o tsv) fi +fi + +### Assign Key Vault Administrator role to the signed in user ### - ### Assign Azure AI User role to the signed in user ### - - echo "Getting Azure AI resource id" - aif_resource_id=$(az cognitiveservices account show --name $aiFoundryName --resource-group $resourceGroupName --query id --output tsv) - - # Check if the user has the Azure AI User role - echo "Checking if user has the Azure AI User role" - role_assignment=$(MSYS_NO_PATHCONV=1 az role assignment list --role 53ca6127-db72-4b80-b1b0-d745d6d5456d --scope $aif_resource_id --assignee $signed_user_id --query "[].roleDefinitionId" -o tsv) - if [ -z "$role_assignment" ]; then - echo "User does not have the Azure AI User role. Assigning the role." - MSYS_NO_PATHCONV=1 az role assignment create --assignee $signed_user_id --role 53ca6127-db72-4b80-b1b0-d745d6d5456d --scope $aif_resource_id --output none - if [ $? -eq 0 ]; then - echo "Azure AI User role assigned successfully." - else - echo "Failed to assign Azure AI User role." - exit 1 - fi +echo "Getting key vault resource id" +key_vault_resource_id=$(az keyvault show --name $keyvaultName --query id --output tsv) + +# Check if the user has the Key Vault Administrator role +echo "Checking if user has the Key Vault Administrator role" +role_assignment=$(MSYS_NO_PATHCONV=1 az role assignment list --assignee $signed_user_id --role "Key Vault Administrator" --scope $key_vault_resource_id --query "[].roleDefinitionId" -o tsv) +if [ -z "$role_assignment" ]; then + echo "User does not have the Key Vault Administrator role. Assigning the role." + MSYS_NO_PATHCONV=1 az role assignment create --assignee $signed_user_id --role "Key Vault Administrator" --scope $key_vault_resource_id --output none + if [ $? -eq 0 ]; then + echo "Key Vault Administrator role assigned successfully." else - echo "User already has the Azure AI User role." + echo "Failed to assign Key Vault Administrator role." + exit 1 fi +else + echo "User already has the Key Vault Administrator role." +fi + +### Assign Azure AI User role to the signed in user ### + +echo "Getting Azure AI Foundry resource id" +aif_resource_id=$(az cognitiveservices account show --name $aiFoundryName --resource-group $resourceGroupNameFoundry --query id --output tsv) - ### Assign Search Index Data Contributor role to the signed in user ### - - echo "Getting Azure Search resource id" - search_resource_id=$(az search service show --name $aiSearchName --resource-group $resourceGroupName --query id --output tsv) - - # Check if the user has the Search Index Data Contributor role - echo "Checking if user has the Search Index Data Contributor role" - role_assignment=$(MSYS_NO_PATHCONV=1 az role assignment list --assignee $signed_user_id --role 8ebe5a00-799e-43f5-93ac-243d3dce84a7 --scope $search_resource_id --query "[].roleDefinitionId" -o tsv) - if [ -z "$role_assignment" ]; then - echo "User does not have the Search Index Data Contributor role. Assigning the role." - MSYS_NO_PATHCONV=1 az role assignment create --assignee $signed_user_id --role 8ebe5a00-799e-43f5-93ac-243d3dce84a7 --scope $search_resource_id --output none - if [ $? -eq 0 ]; then - echo "Search Index Data Contributor role assigned successfully." - else - echo "Failed to assign Search Index Data Contributor role." - exit 1 - fi +# Check if the user has the Azure AI User role +echo "Checking if user has the Azure AI User role" +role_assignment=$(MSYS_NO_PATHCONV=1 az role assignment list --role 53ca6127-db72-4b80-b1b0-d745d6d5456d --scope $aif_resource_id --assignee $signed_user_id --query "[].roleDefinitionId" -o tsv) +if [ -z "$role_assignment" ]; then + echo "User does not have the Azure AI User role. Assigning the role." + MSYS_NO_PATHCONV=1 az role assignment create --assignee $signed_user_id --role 53ca6127-db72-4b80-b1b0-d745d6d5456d --scope $aif_resource_id --output none + if [ $? -eq 0 ]; then + echo "Azure AI User role assigned successfully." else - echo "User already has the Search Index Data Contributor role." + echo "Failed to assign Azure AI User role." + exit 1 fi +else + echo "User already has the Azure AI User role." +fi - ### Assign signed in user as SQL Server Admin ### - echo "Getting Azure SQL Server resource id" - sql_server_resource_id=$(az sql server show --name $sqlServerName --resource-group $resourceGroupName --query id --output tsv) +### Assign Search Index Data Contributor role to the signed in user ### - # Check if the user is Azure SQL Server Admin - echo "Checking if user is Azure SQL Server Admin" - admin=$(MSYS_NO_PATHCONV=1 az sql server ad-admin list --ids $sql_server_resource_id --query "[?sid == '$signed_user_id']" -o tsv) +echo "Getting Azure Search resource id" +search_resource_id=$(az search service show --name $aiSearchName --resource-group $resourceGroupName --query id --output tsv) - # Check if the role exists - if [ -n "$admin" ]; then - echo "User is already Azure SQL Server Admin" +# Check if the user has the Search Index Data Contributor role +echo "Checking if user has the Search Index Data Contributor role" +role_assignment=$(MSYS_NO_PATHCONV=1 az role assignment list --assignee $signed_user_id --role 8ebe5a00-799e-43f5-93ac-243d3dce84a7 --scope $search_resource_id --query "[].roleDefinitionId" -o tsv) +if [ -z "$role_assignment" ]; then + echo "User does not have the Search Index Data Contributor role. Assigning the role." + MSYS_NO_PATHCONV=1 az role assignment create --assignee $signed_user_id --role 8ebe5a00-799e-43f5-93ac-243d3dce84a7 --scope $search_resource_id --output none + if [ $? -eq 0 ]; then + echo "Search Index Data Contributor role assigned successfully." else - echo "User is not Azure SQL Server Admin. Assigning the role." - MSYS_NO_PATHCONV=1 az sql server ad-admin create --display-name "$signed_user_display_name" --object-id $signed_user_id --resource-group $resourceGroupName --server $sqlServerName --output none - if [ $? -eq 0 ]; then - echo "Assigned user as Azure SQL Server Admin." - else - echo "Failed to assign Azure SQL Server Admin role." - exit 1 - fi + echo "Failed to assign Search Index Data Contributor role." + exit 1 fi +else + echo "User already has the Search Index Data Contributor role." fi + +### Assign signed in user as SQL Server Admin ### + +echo "Getting Azure SQL Server resource id" +sql_server_resource_id=$(az sql server show --name $sqlServerName --resource-group $resourceGroupName --query id --output tsv) + +# Check if the user is Azure SQL Server Admin +echo "Checking if user is Azure SQL Server Admin" +admin=$(MSYS_NO_PATHCONV=1 az sql server ad-admin list --ids $sql_server_resource_id --query "[?sid == '$signed_user_id']" -o tsv) + +# Check if the role exists +if [ -n "$admin" ]; then + echo "User is already Azure SQL Server Admin" +else + echo "User is not Azure SQL Server Admin. Assigning the role." + MSYS_NO_PATHCONV=1 az sql server ad-admin create --display-name "$signed_user_display_name" --object-id $signed_user_id --resource-group $resourceGroupName --server $sqlServerName --output none + if [ $? -eq 0 ]; then + echo "Assigned user as Azure SQL Server Admin." + else + echo "Failed to assign Azure SQL Server Admin role." + exit 1 + fi +fi + +# echo "Getting signed in user id" +# signed_user_id=$(az ad signed-in-user show --query id -o tsv) + # RUN apt-get update # RUN apt-get install python3 python3-dev g++ unixodbc-dev unixodbc libpq-dev # apk add python3 python3-dev g++ unixodbc-dev unixodbc libpq-dev @@ -140,7 +147,7 @@ fi pythonScriptPath="infra/scripts/index_scripts/" # Check if running in Azure Container App -if !([ -z "$baseUrl" ] && [ -z "$managedIdentityClientId" ]); then +if [ -n "$baseUrl" ] && [ -n "$managedIdentityClientId" ]; then requirementFile="requirements.txt" requirementFileUrl=${baseUrl}${pythonScriptPath}"requirements.txt" @@ -158,7 +165,7 @@ if !([ -z "$baseUrl" ] && [ -z "$managedIdentityClientId" ]); then fi -#Replace key vault name +# Replace key vault name sed -i "s/kv_to-be-replaced/${keyvaultName}/g" ${pythonScriptPath}"create_search_index.py" sed -i "s/kv_to-be-replaced/${keyvaultName}/g" ${pythonScriptPath}"create_sql_tables.py" if [ -n "$managedIdentityClientId" ]; then diff --git a/src/App/app.py b/src/App/app.py index 6127e0268..901494b2b 100644 --- a/src/App/app.py +++ b/src/App/app.py @@ -78,14 +78,19 @@ def create_app(): # Setup agent initialization and cleanup @app.before_serving async def startup(): - app.agent = await AgentFactory.get_instance() - logging.info("Agent initialized during application startup") + app.wealth_advisor_agent = await AgentFactory.get_wealth_advisor_agent() + logging.info("Wealth Advisor Agent initialized during application startup") + app.search_agent = await AgentFactory.get_search_agent() + logging.info( + "Call Transcript Search Agent initialized during application startup" + ) @app.after_serving async def shutdown(): - await AgentFactory.delete_instance() - app.agent = None - logging.info("Agent cleaned up during application shutdown") + await AgentFactory.delete_all_agent_instance() + app.wealth_advisor_agent = None + app.search_agent = None + logging.info("Agents cleaned up during application shutdown") # app.secret_key = secrets.token_hex(16) # app.session_interface = SecureCookieSessionInterface() diff --git a/src/App/backend/agents/agent_factory.py b/src/App/backend/agents/agent_factory.py index 604f38f05..92c291852 100644 --- a/src/App/backend/agents/agent_factory.py +++ b/src/App/backend/agents/agent_factory.py @@ -7,28 +7,33 @@ """ import asyncio +from typing import Optional +from azure.ai.projects import AIProjectClient +from azure.identity import DefaultAzureCredential as DefaultAzureCredentialSync from azure.identity.aio import DefaultAzureCredential from semantic_kernel.agents import AzureAIAgent, AzureAIAgentSettings +from backend.common.config import config from backend.plugins.chat_with_data_plugin import ChatWithDataPlugin class AgentFactory: """ - Singleton factory for creating and managing an AzureAIAgent instance. + Singleton factory for creating and managing an AzureAIAgent instances. """ - _instance = None _lock = asyncio.Lock() + _wealth_advisor_agent: Optional[AzureAIAgent] = None + _search_agent: Optional[dict] = None @classmethod - async def get_instance(cls): + async def get_wealth_advisor_agent(cls): """ - Get or create the singleton AzureAIAgent instance. + Get or create the singleton WealthAdvisor AzureAIAgent instance. """ async with cls._lock: - if cls._instance is None: + if cls._wealth_advisor_agent is None: ai_agent_settings = AzureAIAgentSettings() creds = DefaultAzureCredential() client = AzureAIAgent.create_client( @@ -48,16 +53,55 @@ async def get_instance(cls): definition=agent_definition, plugins=[ChatWithDataPlugin()], ) - cls._instance = agent - return cls._instance + cls._wealth_advisor_agent = agent + return cls._wealth_advisor_agent @classmethod - async def delete_instance(cls): + async def get_search_agent(cls): """ - Delete the singleton AzureAIAgent instance if it exists. - Also deletes all threads in ChatService.thread_cache. + Get or create the singleton CallTranscriptSearch AzureAIAgent instance. """ async with cls._lock: - if cls._instance is not None: - await cls._instance.client.agents.delete_agent(cls._instance.id) - cls._instance = None + if cls._search_agent is None: + + agent_instructions = config.CALL_TRANSCRIPT_SYSTEM_PROMPT + if not agent_instructions: + agent_instructions = ( + "You are an assistant who supports wealth advisors in preparing for client meetings. " + "You have access to the client's past meeting call transcripts via AI Search tool. " + "When answering questions, especially summary requests, provide a detailed and structured response that includes key topics, concerns, decisions, and trends. " + "If no data is available, state 'No relevant data found for previous meetings.'" + ) + + project_client = AIProjectClient( + endpoint=config.AI_PROJECT_ENDPOINT, + credential=DefaultAzureCredentialSync(), + api_version="2025-05-01", + ) + + agent = project_client.agents.create_agent( + model=config.AZURE_OPENAI_MODEL, + instructions=agent_instructions, + name="CallTranscriptSearchAgent", + ) + cls._search_agent = {"agent": agent, "client": project_client} + return cls._search_agent + + @classmethod + async def delete_all_agent_instance(cls): + """ + Delete the singleton AzureAIAgent instances if it exists. + """ + async with cls._lock: + if cls._wealth_advisor_agent is not None: + await cls._wealth_advisor_agent.client.agents.delete_agent( + cls._wealth_advisor_agent.id + ) + cls._wealth_advisor_agent = None + + if cls._search_agent is not None: + cls._search_agent["client"].agents.delete_agent( + cls._search_agent["agent"].id + ) + cls._search_agent["client"].close() + cls._search_agent = None diff --git a/src/App/backend/common/config.py b/src/App/backend/common/config.py index 38afe161b..06073a427 100644 --- a/src/App/backend/common/config.py +++ b/src/App/backend/common/config.py @@ -72,6 +72,9 @@ def __init__(self): "AZURE_SEARCH_PERMITTED_GROUPS_COLUMN" ) self.AZURE_SEARCH_STRICTNESS = os.environ.get("AZURE_SEARCH_STRICTNESS", 3) + self.AZURE_SEARCH_CONNECTION_NAME = os.environ.get( + "AZURE_SEARCH_CONNECTION_NAME", "foundry-search-connection" + ) # AOAI Integration Settings self.AZURE_OPENAI_RESOURCE = os.environ.get("AZURE_OPENAI_RESOURCE") diff --git a/src/App/backend/plugins/chat_with_data_plugin.py b/src/App/backend/plugins/chat_with_data_plugin.py index 13f3952ae..83bedd264 100644 --- a/src/App/backend/plugins/chat_with_data_plugin.py +++ b/src/App/backend/plugins/chat_with_data_plugin.py @@ -1,6 +1,13 @@ +import logging from typing import Annotated import openai +from azure.ai.agents.models import ( + Agent, + AzureAISearchQueryType, + AzureAISearchTool, + MessageRole, +) from azure.ai.projects import AIProjectClient from azure.identity import DefaultAzureCredential, get_bearer_token_provider from semantic_kernel.functions.kernel_function_decorator import kernel_function @@ -19,7 +26,7 @@ class ChatWithDataPlugin: name="GreetingsResponse", description="Respond to any greeting or general questions", ) - def greeting( + async def greeting( self, input: Annotated[str, "the question"] ) -> Annotated[str, "The output is a string"]: """ @@ -55,7 +62,7 @@ def greeting( name="ChatWithSQLDatabase", description="Given a query about client assets, investments and scheduled meetings (including upcoming or next meeting dates/times), get details from the database based on the provided question and client id", ) - def get_SQL_Response( + async def get_SQL_Response( self, input: Annotated[str, "the question"], ClientId: Annotated[str, "the ClientId"], @@ -155,7 +162,7 @@ def get_SQL_Response( name="ChatWithCallTranscripts", description="given a query about meetings summary or actions or notes, get answer from search index for a given ClientId", ) - def get_answers_from_calltranscripts( + async def get_answers_from_calltranscripts( self, question: Annotated[str, "the question"], ClientId: Annotated[str, "the ClientId"], @@ -169,73 +176,90 @@ def get_answers_from_calltranscripts( return "Error: Question input is required" try: - client = self.get_openai_client() - - system_message = config.CALL_TRANSCRIPT_SYSTEM_PROMPT - if not system_message: - system_message = ( - "You are an assistant who supports wealth advisors in preparing for client meetings. " - "You have access to the client's past meeting call transcripts. " - "When answering questions, especially summary requests, provide a detailed and structured response that includes key topics, concerns, decisions, and trends. " - "If no data is available, state 'No relevant data found for previous meetings.'" + response_text = "" + + from backend.agents.agent_factory import AgentFactory + + agent_info: dict = await AgentFactory.get_search_agent() + + agent: Agent = agent_info["agent"] + project_client: AIProjectClient = agent_info["client"] + + try: + field_mapping = { + "contentFields": ["content"], + "urlField": "sourceurl", + "titleField": "chunk_id", + "vector_fields": ["contentVector"], + } + + project_index = project_client.indexes.create_or_update( + name=f"project-index-{config.AZURE_SEARCH_INDEX}", + version="1", + body={ + "connectionName": config.AZURE_SEARCH_CONNECTION_NAME, + "indexName": config.AZURE_SEARCH_INDEX, + "type": "AzureSearch", + "fieldMapping": field_mapping, + }, ) - completion = client.chat.completions.create( - model=config.AZURE_OPENAI_MODEL, - messages=[ - {"role": "system", "content": system_message}, - {"role": "user", "content": question}, - ], - seed=42, - temperature=0, - top_p=1, - n=1, - max_tokens=800, - extra_body={ - "data_sources": [ - { - "type": "azure_search", - "parameters": { - "endpoint": config.AZURE_SEARCH_ENDPOINT, - "index_name": "transcripts_index", - "query_type": "vector_simple_hybrid", - "fields_mapping": { - "content_fields_separator": "\n", - "content_fields": ["content"], - "filepath_field": "chunk_id", - "title_field": "", - "url_field": "sourceurl", - "vector_fields": ["contentVector"], - }, - "semantic_configuration": "my-semantic-config", - "in_scope": "true", - # "role_information": system_message, - "filter": f"client_id eq '{ClientId}'", - "strictness": 3, - "top_n_documents": 5, - "authentication": { - "type": "system_assigned_managed_identity" - }, - "embedding_dependency": { - "type": "deployment_name", - "deployment_name": "text-embedding-ada-002", - }, - }, - } - ] - }, - ) + ai_search_tool = AzureAISearchTool( + index_asset_id=f"{project_index.name}/versions/{project_index.version}", + index_connection_id=None, + index_name=None, + query_type=AzureAISearchQueryType.VECTOR_SIMPLE_HYBRID, + filter=f"client_id eq '{ClientId}'", + ) - if not completion.choices: - return "No data found for that client." + agent = project_client.agents.update_agent( + agent_id=agent.id, + tools=ai_search_tool.definitions, + tool_resources=ai_search_tool.resources, + ) + + thread = project_client.agents.threads.create() + + project_client.agents.messages.create( + thread_id=thread.id, + role=MessageRole.USER, + content=question, + ) + + run = project_client.agents.runs.create_and_process( + thread_id=thread.id, + agent_id=agent.id, + tool_choice={"type": "azure_ai_search"}, + temperature=0.0, + ) + + if run.status == "failed": + logging.error(f"AI Search Agent Run failed: {run.last_error}") + return "Error retrieving data from call transcripts" + else: + message = ( + project_client.agents.messages.get_last_message_text_by_role( + thread_id=thread.id, role=MessageRole.AGENT + ) + ) + if message: + response_text = message.text.value + + except Exception as e: + logging.error(f"Error in AI Search Tool: {str(e)}") + return "Error retrieving data from call transcripts" + + finally: + if thread: + project_client.agents.threads.delete(thread.id) - response_text = completion.choices[0].message.content if not response_text.strip(): return "No data found for that client." return response_text except Exception as e: - return f"Error retrieving data from call transcripts: {str(e)}" + logging.error(f"Error in get_answers_from_calltranscripts: {str(e)}") + return "Error retrieving data from call transcripts" def get_openai_client(self): token_provider = get_bearer_token_provider( diff --git a/src/App/backend/services/chat_service.py b/src/App/backend/services/chat_service.py index 8dc8375a4..56a1daf08 100644 --- a/src/App/backend/services/chat_service.py +++ b/src/App/backend/services/chat_service.py @@ -24,7 +24,7 @@ async def stream_response_from_wealth_assistant(query: str, client_id: str): additional_instructions = ( "The currently selected client's name is '{SelectedClientName}'. Treat any case-insensitive or partial mention as referring to this client." "If the user mentions no name, assume they are asking about '{SelectedClientName}'." - "If the user references a name that clearly differs from '{SelectedClientName}', respond only with: 'Please only ask questions about the selected client or select another client.' Otherwise, provide thorough answers for every question using only data from SQL or call transcripts." + "If the user references a name that clearly differs from '{SelectedClientName}' or comparing with other clients, respond only with: 'Please only ask questions about the selected client or select another client.' Otherwise, provide thorough answers for every question using only data from SQL or call transcripts." "If no data is found, respond with 'No data found for that client.' Remove any client identifiers from the final response." "Always send clientId as '{client_id}'." ) @@ -37,7 +37,7 @@ async def stream_response_from_wealth_assistant(query: str, client_id: str): "{client_id}", client_id ) - agent: AzureAIAgent = current_app.agent + agent: AzureAIAgent = current_app.wealth_advisor_agent thread: AzureAIAgentThread = None message = ChatMessageContent(role=AuthorRole.USER, content=query) diff --git a/src/App/tests/backend/agents/test_agent_factory.py b/src/App/tests/backend/agents/test_agent_factory.py index a5af29787..dcae796ad 100644 --- a/src/App/tests/backend/agents/test_agent_factory.py +++ b/src/App/tests/backend/agents/test_agent_factory.py @@ -1,4 +1,4 @@ -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -11,51 +11,156 @@ class TestAgentFactory: @pytest.fixture def reset_singleton(self): """Fixture to reset the singleton between tests""" - original_instance = AgentFactory._instance - AgentFactory._instance = None + original_wealth_advisor = AgentFactory._wealth_advisor_agent + original_search_agent = AgentFactory._search_agent + AgentFactory._wealth_advisor_agent = None + AgentFactory._search_agent = None yield - AgentFactory._instance = original_instance + AgentFactory._wealth_advisor_agent = original_wealth_advisor + AgentFactory._search_agent = original_search_agent @pytest.mark.asyncio @patch("backend.agents.agent_factory.AzureAIAgent") @patch("backend.agents.agent_factory.DefaultAzureCredential") @patch("backend.agents.agent_factory.AzureAIAgentSettings") - async def test_get_instance_creates_agent_when_none_exists( - self, mock_settings, mock_credential, mock_agent, reset_singleton + @patch("backend.agents.agent_factory.ChatWithDataPlugin") + async def test_get_wealth_advisor_agent_creates_agent_when_none_exists( + self, mock_plugin, mock_settings, mock_credential, mock_agent, reset_singleton ): - """Test that get_instance creates a new agent when none exists.""" + """Test that get_wealth_advisor_agent creates a new agent when none exists.""" # Arrange mock_agent_instance = AsyncMock() mock_agent.return_value = mock_agent_instance mock_client = AsyncMock() mock_agent.create_client.return_value = mock_client + mock_agent_definition = AsyncMock() + mock_client.agents.create_agent.return_value = mock_agent_definition + mock_settings_instance = MagicMock() + mock_settings_instance.endpoint = "https://test.endpoint.com" + mock_settings_instance.model_deployment_name = "test-model" + mock_settings.return_value = mock_settings_instance # Act - result = await AgentFactory.get_instance() + result = await AgentFactory.get_wealth_advisor_agent() # Assert assert result is not None - assert AgentFactory._instance is not None - assert AgentFactory._instance is result - assert mock_agent.create_client.called - assert mock_agent.called + assert AgentFactory._wealth_advisor_agent is not None + assert AgentFactory._wealth_advisor_agent is result + mock_agent.create_client.assert_called_once() + mock_client.agents.create_agent.assert_called_once_with( + model="test-model", + name="WealthAdvisor", + instructions="You are a helpful assistant to a Wealth Advisor.", + ) + mock_agent.assert_called_once() @pytest.mark.asyncio - async def test_get_instance_returns_existing_agent(self, reset_singleton): - """Test that get_instance returns existing agent when one exists.""" + async def test_get_wealth_advisor_agent_returns_existing_agent( + self, reset_singleton + ): + """Test that get_wealth_advisor_agent returns existing agent when one exists.""" # Arrange mock_instance = AsyncMock() - AgentFactory._instance = mock_instance + AgentFactory._wealth_advisor_agent = mock_instance # Act - result = await AgentFactory.get_instance() + result = await AgentFactory.get_wealth_advisor_agent() # Assert assert result is mock_instance @pytest.mark.asyncio - async def test_multiple_calls_return_same_instance(self, reset_singleton): - """Test that multiple calls to get_instance return the same instance.""" + @patch("backend.agents.agent_factory.config") + @patch("backend.agents.agent_factory.AIProjectClient") + @patch("backend.agents.agent_factory.DefaultAzureCredentialSync") + async def test_get_search_agent_creates_agent_when_none_exists( + self, mock_credential_sync, mock_ai_project_client, mock_config, reset_singleton + ): + """Test that get_search_agent creates a new agent when none exists.""" + # Arrange + mock_config.CALL_TRANSCRIPT_SYSTEM_PROMPT = "Test search agent instructions" + mock_config.AI_PROJECT_ENDPOINT = "https://test.ai.endpoint.com" + mock_config.AZURE_OPENAI_MODEL = "test-search-model" + + mock_project_client_instance = MagicMock() + mock_ai_project_client.return_value = mock_project_client_instance + mock_agent = MagicMock() + mock_project_client_instance.agents.create_agent.return_value = mock_agent + + # Act + result = await AgentFactory.get_search_agent() + + # Assert + assert result is not None + assert AgentFactory._search_agent is not None + assert AgentFactory._search_agent is result + assert result["agent"] is mock_agent + assert result["client"] is mock_project_client_instance + mock_ai_project_client.assert_called_once_with( + endpoint="https://test.ai.endpoint.com", + credential=mock_credential_sync.return_value, + api_version="2025-05-01", + ) + mock_project_client_instance.agents.create_agent.assert_called_once_with( + model="test-search-model", + instructions="Test search agent instructions", + name="CallTranscriptSearchAgent", + ) + + @pytest.mark.asyncio + @patch("backend.agents.agent_factory.config") + @patch("backend.agents.agent_factory.AIProjectClient") + @patch("backend.agents.agent_factory.DefaultAzureCredentialSync") + async def test_get_search_agent_with_default_instructions( + self, mock_credential_sync, mock_ai_project_client, mock_config, reset_singleton + ): + """Test that get_search_agent uses default instructions when config is empty.""" + # Arrange + mock_config.CALL_TRANSCRIPT_SYSTEM_PROMPT = None + mock_config.AI_PROJECT_ENDPOINT = "https://test.ai.endpoint.com" + mock_config.AZURE_OPENAI_MODEL = "test-search-model" + + mock_project_client_instance = MagicMock() + mock_ai_project_client.return_value = mock_project_client_instance + mock_agent = MagicMock() + mock_project_client_instance.agents.create_agent.return_value = mock_agent + + # Act + result = await AgentFactory.get_search_agent() + + # Assert + assert result is not None + expected_default_instructions = ( + "You are an assistant who supports wealth advisors in preparing for client meetings. " + "You have access to the client's past meeting call transcripts via AI Search tool. " + "When answering questions, especially summary requests, provide a detailed and structured response that includes key topics, concerns, decisions, and trends. " + "If no data is available, state 'No relevant data found for previous meetings.'" + ) + mock_project_client_instance.agents.create_agent.assert_called_once_with( + model="test-search-model", + instructions=expected_default_instructions, + name="CallTranscriptSearchAgent", + ) + + @pytest.mark.asyncio + async def test_get_search_agent_returns_existing_agent(self, reset_singleton): + """Test that get_search_agent returns existing agent when one exists.""" + # Arrange + mock_agent_dict = {"agent": MagicMock(), "client": MagicMock()} + AgentFactory._search_agent = mock_agent_dict + + # Act + result = await AgentFactory.get_search_agent() + + # Assert + assert result is mock_agent_dict + + @pytest.mark.asyncio + async def test_multiple_calls_return_same_wealth_advisor_instance( + self, reset_singleton + ): + """Test that multiple calls to get_wealth_advisor_agent return the same instance.""" # Arrange mock_client = AsyncMock() mock_agent_definition = AsyncMock() @@ -70,37 +175,133 @@ async def test_multiple_calls_return_same_instance(self, reset_singleton): with patch("backend.agents.agent_factory.DefaultAzureCredential"): with patch("backend.agents.agent_factory.AzureAIAgentSettings"): - # Act - instance1 = await AgentFactory.get_instance() - instance2 = await AgentFactory.get_instance() + with patch("backend.agents.agent_factory.ChatWithDataPlugin"): + # Act + instance1 = await AgentFactory.get_wealth_advisor_agent() + instance2 = await AgentFactory.get_wealth_advisor_agent() # Assert assert instance1 is instance2 @pytest.mark.asyncio - async def test_delete_instance_when_none_exists(self, reset_singleton): - """Test that delete_instance handles when no agent exists.""" + async def test_multiple_calls_return_same_search_agent_instance( + self, reset_singleton + ): + """Test that multiple calls to get_search_agent return the same instance.""" + with patch("backend.agents.agent_factory.config") as mock_config: + with patch( + "backend.agents.agent_factory.AIProjectClient" + ) as mock_ai_project_client: + with patch("backend.agents.agent_factory.DefaultAzureCredentialSync"): + mock_config.CALL_TRANSCRIPT_SYSTEM_PROMPT = "Test instructions" + mock_config.AI_PROJECT_ENDPOINT = "https://test.endpoint.com" + mock_config.AZURE_OPENAI_MODEL = "test-model" + + mock_project_client_instance = MagicMock() + mock_ai_project_client.return_value = mock_project_client_instance + mock_agent = MagicMock() + mock_project_client_instance.agents.create_agent.return_value = ( + mock_agent + ) + + # Act + instance1 = await AgentFactory.get_search_agent() + instance2 = await AgentFactory.get_search_agent() + + # Assert + assert instance1 is instance2 + + @pytest.mark.asyncio + async def test_delete_all_agent_instance_when_none_exists(self, reset_singleton): + """Test that delete_all_agent_instance handles when no agents exist.""" + # Arrange + AgentFactory._wealth_advisor_agent = None + AgentFactory._search_agent = None + + # Act + await AgentFactory.delete_all_agent_instance() + + # Assert + assert AgentFactory._wealth_advisor_agent is None + assert AgentFactory._search_agent is None + + @pytest.mark.asyncio + async def test_delete_all_agent_instance_removes_existing_agents( + self, reset_singleton + ): + """Test that delete_all_agent_instance properly removes existing agents.""" + # Arrange + mock_wealth_advisor_agent = AsyncMock() + mock_wealth_advisor_agent.client = AsyncMock() + mock_wealth_advisor_agent.id = "test-wealth-advisor-id" + AgentFactory._wealth_advisor_agent = mock_wealth_advisor_agent + + mock_search_client = MagicMock() + mock_search_agent = MagicMock() + mock_search_agent.id = "test-search-agent-id" + AgentFactory._search_agent = { + "agent": mock_search_agent, + "client": mock_search_client, + } + + # Act + await AgentFactory.delete_all_agent_instance() + + # Assert + assert AgentFactory._wealth_advisor_agent is None + assert AgentFactory._search_agent is None + mock_wealth_advisor_agent.client.agents.delete_agent.assert_called_once_with( + "test-wealth-advisor-id" + ) + mock_search_client.agents.delete_agent.assert_called_once_with( + "test-search-agent-id" + ) + mock_search_client.close.assert_called_once() + + @pytest.mark.asyncio + async def test_delete_all_agent_instance_handles_only_wealth_advisor( + self, reset_singleton + ): + """Test that delete_all_agent_instance handles when only wealth advisor exists.""" # Arrange - AgentFactory._instance = None + mock_wealth_advisor_agent = AsyncMock() + mock_wealth_advisor_agent.client = AsyncMock() + mock_wealth_advisor_agent.id = "test-wealth-advisor-id" + AgentFactory._wealth_advisor_agent = mock_wealth_advisor_agent + AgentFactory._search_agent = None # Act - await AgentFactory.delete_instance() + await AgentFactory.delete_all_agent_instance() # Assert - assert AgentFactory._instance is None + assert AgentFactory._wealth_advisor_agent is None + assert AgentFactory._search_agent is None + mock_wealth_advisor_agent.client.agents.delete_agent.assert_called_once_with( + "test-wealth-advisor-id" + ) @pytest.mark.asyncio - async def test_delete_instance_removes_existing_agent(self, reset_singleton): - """Test that delete_instance properly removes an existing agent.""" + async def test_delete_all_agent_instance_handles_only_search_agent( + self, reset_singleton + ): + """Test that delete_all_agent_instance handles when only search agent exists.""" # Arrange - mock_agent = AsyncMock() - mock_agent.client = AsyncMock() - mock_agent.id = "test-agent-id" - AgentFactory._instance = mock_agent + mock_search_client = MagicMock() + mock_search_agent = MagicMock() + mock_search_agent.id = "test-search-agent-id" + AgentFactory._wealth_advisor_agent = None + AgentFactory._search_agent = { + "agent": mock_search_agent, + "client": mock_search_client, + } # Act - await AgentFactory.delete_instance() + await AgentFactory.delete_all_agent_instance() # Assert - assert AgentFactory._instance is None - mock_agent.client.agents.delete_agent.assert_called_once_with(mock_agent.id) + assert AgentFactory._wealth_advisor_agent is None + assert AgentFactory._search_agent is None + mock_search_client.agents.delete_agent.assert_called_once_with( + "test-search-agent-id" + ) + mock_search_client.close.assert_called_once() diff --git a/src/App/tests/backend/plugins/test_chat_with_data_plugin.py b/src/App/tests/backend/plugins/test_chat_with_data_plugin.py index d38a2c320..826cf4c5f 100644 --- a/src/App/tests/backend/plugins/test_chat_with_data_plugin.py +++ b/src/App/tests/backend/plugins/test_chat_with_data_plugin.py @@ -1,5 +1,7 @@ from unittest.mock import MagicMock, patch +import pytest + from backend.plugins.chat_with_data_plugin import ChatWithDataPlugin @@ -10,8 +12,9 @@ def setup_method(self): """Setup method to initialize plugin instance for each test.""" self.plugin = ChatWithDataPlugin() + @pytest.mark.asyncio @patch.object(ChatWithDataPlugin, "get_openai_client") - def test_greeting_returns_response(self, mock_get_openai_client): + async def test_greeting_returns_response(self, mock_get_openai_client): """Test that greeting method calls OpenAI and returns response.""" # Setup mock mock_client = MagicMock() @@ -24,7 +27,7 @@ def test_greeting_returns_response(self, mock_get_openai_client): ) mock_client.chat.completions.create.return_value = mock_completion - result = self.plugin.greeting("Hello") + result = await self.plugin.greeting("Hello") assert result == "Hello! I'm your Wealth Assistant. How can I help you today?" mock_client.chat.completions.create.assert_called_once() @@ -97,9 +100,10 @@ def test_get_project_openai_client_success( api_version="2025-04-01-preview" ) + @pytest.mark.asyncio @patch("backend.plugins.chat_with_data_plugin.get_connection") @patch.object(ChatWithDataPlugin, "get_openai_client") - def test_get_sql_response_success( + async def test_get_sql_response_success( self, mock_get_openai_client, mock_get_connection ): """Test successful SQL response generation with AAD authentication.""" @@ -122,7 +126,7 @@ def test_get_sql_response_success( mock_connection.cursor.return_value = mock_cursor mock_get_connection.return_value = mock_connection - result = self.plugin.get_SQL_Response("Find client details", "client123") + result = await self.plugin.get_SQL_Response("Find client details", "client123") # Verify the result assert "John Doe" in result @@ -138,9 +142,10 @@ def test_get_sql_response_success( mock_cursor.fetchall.assert_called_once() mock_connection.close.assert_called_once() + @pytest.mark.asyncio @patch("backend.plugins.chat_with_data_plugin.get_connection") @patch.object(ChatWithDataPlugin, "get_openai_client") - def test_get_sql_response_database_error( + async def test_get_sql_response_database_error( self, mock_get_openai_client, mock_get_connection ): """Test SQL response when database connection fails.""" @@ -155,13 +160,14 @@ def test_get_sql_response_database_error( # Simulate database connection error mock_get_connection.side_effect = Exception("Database connection failed") - result = self.plugin.get_SQL_Response("Get all clients", "client123") + result = await self.plugin.get_SQL_Response("Get all clients", "client123") assert "Error retrieving data from SQL" in result assert "Database connection failed" in result + @pytest.mark.asyncio @patch.object(ChatWithDataPlugin, "get_openai_client") - def test_get_sql_response_openai_error(self, mock_get_openai_client): + async def test_get_sql_response_openai_error(self, mock_get_openai_client): """Test SQL response when OpenAI call fails.""" mock_client = MagicMock() mock_get_openai_client.return_value = mock_client @@ -169,27 +175,54 @@ def test_get_sql_response_openai_error(self, mock_get_openai_client): # Simulate OpenAI error mock_client.chat.completions.create.side_effect = Exception("OpenAI API error") - result = self.plugin.get_SQL_Response("Get client data", "client123") + result = await self.plugin.get_SQL_Response("Get client data", "client123") assert "Error retrieving data from SQL" in result assert "OpenAI API error" in result - @patch.object(ChatWithDataPlugin, "get_openai_client") - def test_get_answers_from_calltranscripts_success(self, mock_get_openai_client): - """Test successful retrieval of answers from call transcripts using AAD authentication.""" - # Setup mocks - mock_client = MagicMock() - mock_get_openai_client.return_value = mock_client - - # Mock OpenAI response (this method uses extra_body with data_sources) - mock_completion = MagicMock() - mock_completion.choices = [MagicMock()] - mock_completion.choices[0].message.content = ( - "Based on call transcripts, the customer discussed investment options and risk tolerance." + @pytest.mark.asyncio + @patch("backend.agents.agent_factory.AgentFactory.get_search_agent") + async def test_get_answers_from_calltranscripts_success( + self, mock_get_search_agent + ): + """Test successful retrieval of answers from call transcripts using AI Search Agent.""" + # Setup mocks for agent factory + mock_agent = MagicMock() + mock_agent.id = "test-agent-id" + + mock_project_client = MagicMock() + mock_get_search_agent.return_value = { + "agent": mock_agent, + "client": mock_project_client, + } + + # Mock project index creation + mock_index = MagicMock() + mock_index.name = "project-index-test" + mock_index.version = "1" + mock_project_client.indexes.create_or_update.return_value = mock_index + + # Mock agent update + mock_project_client.agents.update_agent.return_value = mock_agent + + # Mock thread creation + mock_thread = MagicMock() + mock_thread.id = "test-thread-id" + mock_project_client.agents.threads.create.return_value = mock_thread + + # Mock run creation and processing + mock_run = MagicMock() + mock_run.status = "completed" + mock_project_client.agents.runs.create_and_process.return_value = mock_run + + # Mock message response + mock_message = MagicMock() + mock_message.text.value = "Based on call transcripts, the customer discussed investment options and risk tolerance." + mock_project_client.agents.messages.get_last_message_text_by_role.return_value = ( + mock_message ) - mock_client.chat.completions.create.return_value = mock_completion - result = self.plugin.get_answers_from_calltranscripts( + result = await self.plugin.get_answers_from_calltranscripts( "What did the customer discuss?", "client123" ) @@ -197,80 +230,234 @@ def test_get_answers_from_calltranscripts_success(self, mock_get_openai_client): assert "Based on call transcripts" in result assert "investment options" in result - # Verify OpenAI was called with data_sources for Azure Search - mock_client.chat.completions.create.assert_called_once() - call_args = mock_client.chat.completions.create.call_args - assert "extra_body" in call_args[1] - assert "data_sources" in call_args[1]["extra_body"] + # Verify agent factory was called + mock_get_search_agent.assert_called_once() - # Verify the filter contains the client ID - data_sources = call_args[1]["extra_body"]["data_sources"] - assert len(data_sources) > 0 - assert "client_id eq 'client123'" in data_sources[0]["parameters"]["filter"] + # Verify project index was created/updated + mock_project_client.indexes.create_or_update.assert_called_once() - @patch.object(ChatWithDataPlugin, "get_openai_client") - def test_get_answers_from_calltranscripts_no_results(self, mock_get_openai_client): - """Test call transcripts search with no results.""" - mock_client = MagicMock() - mock_get_openai_client.return_value = mock_client + # Verify agent was updated with search tool + mock_project_client.agents.update_agent.assert_called_once() - # Mock empty response - mock_completion = MagicMock() - mock_completion.choices = [] - mock_client.chat.completions.create.return_value = mock_completion + # Verify thread was created and deleted + mock_project_client.agents.threads.create.assert_called_once() + mock_project_client.agents.threads.delete.assert_called_once_with( + "test-thread-id" + ) + + # Verify message was created and run was processed + mock_project_client.agents.messages.create.assert_called_once() + mock_project_client.agents.runs.create_and_process.assert_called_once() - result = self.plugin.get_answers_from_calltranscripts( + @pytest.mark.asyncio + @patch("backend.agents.agent_factory.AgentFactory.get_search_agent") + async def test_get_answers_from_calltranscripts_no_results( + self, mock_get_search_agent + ): + """Test call transcripts search with no results.""" + # Setup mocks for agent factory + mock_agent = MagicMock() + mock_agent.id = "test-agent-id" + + mock_project_client = MagicMock() + mock_get_search_agent.return_value = { + "agent": mock_agent, + "client": mock_project_client, + } + + # Mock project index creation + mock_index = MagicMock() + mock_index.name = "project-index-test" + mock_index.version = "1" + mock_project_client.indexes.create_or_update.return_value = mock_index + + # Mock agent update + mock_project_client.agents.update_agent.return_value = mock_agent + + # Mock thread creation + mock_thread = MagicMock() + mock_thread.id = "test-thread-id" + mock_project_client.agents.threads.create.return_value = mock_thread + + # Mock run creation and processing + mock_run = MagicMock() + mock_run.status = "completed" + mock_project_client.agents.runs.create_and_process.return_value = mock_run + + # Mock empty message response + mock_project_client.agents.messages.get_last_message_text_by_role.return_value = ( + None + ) + + result = await self.plugin.get_answers_from_calltranscripts( "Nonexistent query", "client123" ) assert "No data found for that client." in result - @patch.object(ChatWithDataPlugin, "get_openai_client") - def test_get_answers_from_calltranscripts_openai_error( - self, mock_get_openai_client + @pytest.mark.asyncio + @patch("backend.agents.agent_factory.AgentFactory.get_search_agent") + async def test_get_answers_from_calltranscripts_openai_error( + self, mock_get_search_agent ): - """Test call transcripts with OpenAI processing error.""" - mock_client = MagicMock() - mock_get_openai_client.return_value = mock_client + """Test call transcripts with AI Search processing error.""" + # Setup mocks for agent factory + mock_agent = MagicMock() + mock_agent.id = "test-agent-id" + + mock_project_client = MagicMock() + mock_get_search_agent.return_value = { + "agent": mock_agent, + "client": mock_project_client, + } + + # Mock project index creation + mock_index = MagicMock() + mock_index.name = "project-index-test" + mock_index.version = "1" + mock_project_client.indexes.create_or_update.return_value = mock_index + + # Mock agent update + mock_project_client.agents.update_agent.return_value = mock_agent + + # Mock thread creation + mock_thread = MagicMock() + mock_thread.id = "test-thread-id" + mock_project_client.agents.threads.create.return_value = mock_thread + + # Simulate AI Search error + mock_project_client.agents.runs.create_and_process.side_effect = Exception( + "AI Search processing failed" + ) - # Simulate OpenAI error - mock_client.chat.completions.create.side_effect = Exception( - "OpenAI processing failed" + result = await self.plugin.get_answers_from_calltranscripts( + "Test query", "client123" ) - result = self.plugin.get_answers_from_calltranscripts("Test query", "client123") + assert "Error retrieving data from call transcripts" in result + + @pytest.mark.asyncio + @patch("backend.agents.agent_factory.AgentFactory.get_search_agent") + async def test_get_answers_from_calltranscripts_failed_run( + self, mock_get_search_agent + ): + """Test call transcripts with failed AI Search run.""" + # Setup mocks for agent factory + mock_agent = MagicMock() + mock_agent.id = "test-agent-id" + + mock_project_client = MagicMock() + mock_get_search_agent.return_value = { + "agent": mock_agent, + "client": mock_project_client, + } + + # Mock project index creation + mock_index = MagicMock() + mock_index.name = "project-index-test" + mock_index.version = "1" + mock_project_client.indexes.create_or_update.return_value = mock_index + + # Mock agent update + mock_project_client.agents.update_agent.return_value = mock_agent + + # Mock thread creation + mock_thread = MagicMock() + mock_thread.id = "test-thread-id" + mock_project_client.agents.threads.create.return_value = mock_thread + + # Mock failed run + mock_run = MagicMock() + mock_run.status = "failed" + mock_run.last_error = "AI Search run failed" + mock_project_client.agents.runs.create_and_process.return_value = mock_run + + result = await self.plugin.get_answers_from_calltranscripts( + "Test query", "client123" + ) assert "Error retrieving data from call transcripts" in result - assert "OpenAI processing failed" in result - def test_get_sql_response_missing_client_id(self): + @pytest.mark.asyncio + @patch("backend.agents.agent_factory.AgentFactory.get_search_agent") + async def test_get_answers_from_calltranscripts_empty_response( + self, mock_get_search_agent + ): + """Test call transcripts with empty response text.""" + # Setup mocks for agent factory + mock_agent = MagicMock() + mock_agent.id = "test-agent-id" + + mock_project_client = MagicMock() + mock_get_search_agent.return_value = { + "agent": mock_agent, + "client": mock_project_client, + } + + # Mock project index creation + mock_index = MagicMock() + mock_index.name = "project-index-test" + mock_index.version = "1" + mock_project_client.indexes.create_or_update.return_value = mock_index + + # Mock agent update + mock_project_client.agents.update_agent.return_value = mock_agent + + # Mock thread creation + mock_thread = MagicMock() + mock_thread.id = "test-thread-id" + mock_project_client.agents.threads.create.return_value = mock_thread + + # Mock run creation and processing + mock_run = MagicMock() + mock_run.status = "completed" + mock_project_client.agents.runs.create_and_process.return_value = mock_run + + # Mock message with empty response + mock_message = MagicMock() + mock_message.text.value = " " # Empty/whitespace response + mock_project_client.agents.messages.get_last_message_text_by_role.return_value = ( + mock_message + ) + + result = await self.plugin.get_answers_from_calltranscripts( + "Test query", "client123" + ) + + assert "No data found for that client." in result + + @pytest.mark.asyncio + async def test_get_sql_response_missing_client_id(self): """Test SQL response with missing ClientId.""" - result = self.plugin.get_SQL_Response("Test query", "") + result = await self.plugin.get_SQL_Response("Test query", "") assert "Error: ClientId is required" in result - result = self.plugin.get_SQL_Response("Test query", None) + result = await self.plugin.get_SQL_Response("Test query", None) assert "Error: ClientId is required" in result - def test_get_sql_response_missing_input(self): + @pytest.mark.asyncio + async def test_get_sql_response_missing_input(self): """Test SQL response with missing input query.""" - result = self.plugin.get_SQL_Response("", "client123") + result = await self.plugin.get_SQL_Response("", "client123") assert "Error: Query input is required" in result - result = self.plugin.get_SQL_Response(None, "client123") + result = await self.plugin.get_SQL_Response(None, "client123") assert "Error: Query input is required" in result - def test_get_answers_from_calltranscripts_missing_client_id(self): + @pytest.mark.asyncio + async def test_get_answers_from_calltranscripts_missing_client_id(self): """Test call transcripts search with missing ClientId.""" - result = self.plugin.get_answers_from_calltranscripts("Test query", "") + result = await self.plugin.get_answers_from_calltranscripts("Test query", "") assert "Error: ClientId is required" in result - result = self.plugin.get_answers_from_calltranscripts("Test query", None) + result = await self.plugin.get_answers_from_calltranscripts("Test query", None) assert "Error: ClientId is required" in result - def test_get_answers_from_calltranscripts_missing_question(self): + @pytest.mark.asyncio + async def test_get_answers_from_calltranscripts_missing_question(self): """Test call transcripts search with missing question.""" - result = self.plugin.get_answers_from_calltranscripts("", "client123") + result = await self.plugin.get_answers_from_calltranscripts("", "client123") assert "Error: Question input is required" in result - result = self.plugin.get_answers_from_calltranscripts(None, "client123") + result = await self.plugin.get_answers_from_calltranscripts(None, "client123") assert "Error: Question input is required" in result diff --git a/src/App/tests/backend/services/test_chat_service.py b/src/App/tests/backend/services/test_chat_service.py index effa70c2b..70ce3dc10 100644 --- a/src/App/tests/backend/services/test_chat_service.py +++ b/src/App/tests/backend/services/test_chat_service.py @@ -31,9 +31,9 @@ async def mock_stream(): # Mock invoke_stream to return the async generator mock_agent.invoke_stream = MagicMock(return_value=mock_stream()) - # Mock current_app.agent + # Mock current_app.wealth_advisor_agent mock_current_app = MagicMock() - mock_current_app.agent = mock_agent + mock_current_app.wealth_advisor_agent = mock_agent # Mock config mock_config = MagicMock() @@ -79,7 +79,7 @@ async def test_stream_response_exception_handling(self): mock_agent.invoke_stream.side_effect = Exception("Test exception") mock_current_app = MagicMock() - mock_current_app.agent = mock_agent + mock_current_app.wealth_advisor_agent = mock_agent mock_config = MagicMock() mock_config.STREAM_TEXT_SYSTEM_PROMPT = "Test prompt" @@ -116,7 +116,7 @@ async def mock_stream(): mock_agent.invoke_stream = MagicMock(return_value=mock_stream()) mock_current_app = MagicMock() - mock_current_app.agent = mock_agent + mock_current_app.wealth_advisor_agent = mock_agent mock_config = MagicMock() mock_config.STREAM_TEXT_SYSTEM_PROMPT = "" @@ -162,7 +162,7 @@ async def mock_stream(): mock_agent.invoke_stream = MagicMock(return_value=mock_stream()) mock_current_app = MagicMock() - mock_current_app.agent = mock_agent + mock_current_app.wealth_advisor_agent = mock_agent mock_config = MagicMock() mock_config.STREAM_TEXT_SYSTEM_PROMPT = "" # Empty, should use default diff --git a/src/App/tests/test_app.py b/src/App/tests/test_app.py index ffa747097..8828f9b67 100644 --- a/src/App/tests/test_app.py +++ b/src/App/tests/test_app.py @@ -3,8 +3,6 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest -from quart import Response - from app import ( create_app, delete_all_conversations, @@ -13,6 +11,7 @@ init_openai_client, stream_chat_request, ) +from quart import Response # Constants for testing INVALID_API_VERSION = "2022-01-01"