From eacfc19c405ed5eff6c30a19c351193bc6af04a1 Mon Sep 17 00:00:00 2001 From: Venkateswarlu Marthula Date: Fri, 19 Dec 2025 17:48:39 +0530 Subject: [PATCH 01/19] Implemented custom developer experince --- azure_custom.yaml | 278 +++++++ infra/main_custom.bicep | 1522 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 1800 insertions(+) create mode 100644 azure_custom.yaml create mode 100644 infra/main_custom.bicep diff --git a/azure_custom.yaml b/azure_custom.yaml new file mode 100644 index 000000000..afdc3dbd9 --- /dev/null +++ b/azure_custom.yaml @@ -0,0 +1,278 @@ +name: conversation-knowledge-mining-custom + +requiredVersions: + azd: ">= 1.18.0" + +metadata: + template: conversation-knowledge-mining@1.0 + +environment: + name: conversation-knowledge-mining + location: japaneast + +infra: + provider: bicep + path: infra + module: main + parameters: infra/main.parameters.json + +hooks: + preprovision: + windows: + shell: pwsh + interactive: true + continueOnError: false + run: | + $ErrorActionPreference = 'Stop' + + Write-Host "[preprovision] Preparing ACR and building local images..." -ForegroundColor Cyan + + try { az account show | Out-Null } catch { Write-Error "Azure CLI not logged in. Run 'az login' first."; exit 1 } + + $location = $env:AZURE_LOCATION + if (-not $location) { $location = 'japaneast' } + $envName = $env:AZURE_ENV_NAME + if (-not $envName) { $envName = 'conversation-knowledge-mining' } + + $env:AZURE_LOCATION = $location + azd env set AZURE_LOCATION $location | Out-Null + $env:AZURE_ENV_NAME = $envName + azd env set AZURE_ENV_NAME $envName | Out-Null + + $acrRg = "rg-$envName-acr" + if ((az group exists -n $acrRg) -ne 'true') { + Write-Host "Creating resource group $acrRg in $location" -ForegroundColor Yellow + az group create -n $acrRg -l $location | Out-Null + } + + $acrNameBase = 'kmcontainerreg' + $acrName = $acrNameBase + if ((az acr check-name --name $acrNameBase --query nameAvailable -o tsv) -ne 'true') { + $suffix = Get-Random -Maximum 99999 + $acrName = "$acrNameBase$suffix" + Write-Host "Base ACR name unavailable. Using $acrName" -ForegroundColor Yellow + } + + $acrShowName = az acr show -n $acrName -g $acrRg --only-show-errors --query name -o tsv 2>$null + if ($acrShowName -ne $acrName) { + Write-Host "Creating ACR $acrName" -ForegroundColor Yellow + az acr create -n $acrName -g $acrRg -l $location --sku Basic --only-show-errors | Out-Null + $acrServer = '' + for ($i = 0; $i -lt 30; $i++) { + $acrServer = az acr show -n $acrName --query loginServer -o tsv 2>$null + if ($acrServer) { break } + Start-Sleep -Seconds 5 + } + if (-not $acrServer) { Write-Error "ACR $acrName did not become ready (no loginServer)"; exit 1 } + } + + az acr update -n $acrName --admin-enabled true | Out-Null + $acrServer = az acr show -n $acrName --query loginServer -o tsv + $acrCreds = az acr credential show -n $acrName --only-show-errors | ConvertFrom-Json + if (-not $acrCreds -or -not $acrCreds.passwords -or $acrCreds.passwords.Count -lt 1) { + Write-Error "ACR credentials unavailable."; exit 1 + } + $acrUser = $acrCreds.username + $acrPass = $acrCreds.passwords[0].value + + $tag = "latest_waf_{0}" -f ((Get-Date).ToString("yyyy-MM-dd_HHmm")) + $env:AZURE_ENV_IMAGETAG = $tag + $env:AZURE_ENV_CONTAINER_REGISTRY_ENDPOINT = $acrServer + $env:AZURE_ENV_ACR_USERNAME = $acrUser + $env:AZURE_ENV_ACR_PASSWORD = $acrPass + + azd env set AZURE_ENV_IMAGETAG $tag | Out-Null + azd env set AZURE_ENV_CONTAINER_REGISTRY_ENDPOINT $acrServer | Out-Null + azd env set AZURE_ENV_ACR_USERNAME $acrUser | Out-Null + azd env set AZURE_ENV_ACR_PASSWORD $acrPass | Out-Null + + Write-Host "Using registry: $acrServer" -ForegroundColor Cyan + Write-Host "Image tag: $tag" -ForegroundColor Cyan + + Write-Host "Building API image (km-api:$tag) via ACR Build" -ForegroundColor Yellow + $repoRoot = (Get-Location).Path + $apiCtx = Join-Path $repoRoot 'src\api' + $apiDockerfile = Join-Path $apiCtx 'ApiApp.Dockerfile' + if (-not (Test-Path $apiDockerfile)) { Write-Error "API Dockerfile not found at $apiDockerfile"; exit 1 } + $apiBuildSucceeded = $true + az acr build -r $acrName -t "km-api:$tag" -f $apiDockerfile $apiCtx + if ($LASTEXITCODE -ne 0) { $apiBuildSucceeded = $false } + if (-not $apiBuildSucceeded) { + Write-Warning "ACR build failed for km-api. Falling back to local Docker build & push." + if (-not (Get-Command docker -ErrorAction SilentlyContinue)) { Write-Error "Docker is not installed or not in PATH for local fallback."; exit 1 } + az acr login -n $acrName | Out-Null + docker build -f $apiDockerfile -t "$acrServer/km-api:$tag" $apiCtx + docker push "$acrServer/km-api:$tag" + } + + Write-Host "Building Frontend image (km-app:$tag) via ACR Build" -ForegroundColor Yellow + $feCtx = Join-Path $repoRoot 'src\App' + $feDockerfile = Join-Path $feCtx 'WebApp.Dockerfile' + if (-not (Test-Path $feDockerfile)) { Write-Error "Frontend Dockerfile not found at $feDockerfile"; exit 1 } + $feBuildSucceeded = $true + az acr build -r $acrName -t "km-app:$tag" -f $feDockerfile $feCtx + if ($LASTEXITCODE -ne 0) { $feBuildSucceeded = $false } + if (-not $feBuildSucceeded) { + Write-Warning "ACR build failed for km-app. Falling back to local Docker build & push." + if (-not (Get-Command docker -ErrorAction SilentlyContinue)) { Write-Error "Docker is not installed or not in PATH for local fallback."; exit 1 } + az acr login -n $acrName | Out-Null + docker build -f $feDockerfile -t "$acrServer/km-app:$tag" $feCtx + docker push "$acrServer/km-app:$tag" + } + + Write-Host "[preprovision] Images built & pushed. Proceeding to provision..." -ForegroundColor Green + + posix: + shell: sh + interactive: true + continueOnError: false + run: | + set -euo pipefail + echo "[preprovision] Preparing ACR and building local images..." + az account show >/dev/null || { echo "Run 'az login' first"; exit 1; } + : "${AZURE_LOCATION:=japaneast}" + : "${AZURE_ENV_NAME:=conversation-knowledge-mining}" + azd env set AZURE_LOCATION "$AZURE_LOCATION" >/dev/null + azd env set AZURE_ENV_NAME "$AZURE_ENV_NAME" >/dev/null + acr_rg="rg-${AZURE_ENV_NAME}-acr" + az group create -n "$acr_rg" -l "$AZURE_LOCATION" >/dev/null 2>&1 || true + acr_name_base="kmcontainerreg" + name_ok=$(az acr check-name --name "$acr_name_base" --query nameAvailable -o tsv) + if [ "$name_ok" != "true" ]; then + suffix=$(printf "%05d" $(shuf -i 0-99999 -n 1)) + acr_name="${acr_name_base}${suffix}" + echo "Base ACR name unavailable. Using $acr_name" + else + acr_name="$acr_name_base" + fi + az acr show -n "$acr_name" -g "$acr_rg" >/dev/null 2>&1 || az acr create -n "$acr_name" -g "$acr_rg" -l "$AZURE_LOCATION" --sku Basic >/dev/null + az acr update -n "$acr_name" --admin-enabled true >/dev/null + acr_server=$(az acr show -n "$acr_name" --query loginServer -o tsv) + acr_user=$(az acr credential show -n "$acr_name" --query username -o tsv) + acr_pass=$(az acr credential show -n "$acr_name" --query passwords[0].value -o tsv) + tag="latest_waf_$(date +%Y-%m-%d_%H%M)" + export AZURE_ENV_IMAGETAG="$tag" + export AZURE_ENV_CONTAINER_REGISTRY_ENDPOINT="$acr_server" + export AZURE_ENV_ACR_USERNAME="$acr_user" + export AZURE_ENV_ACR_PASSWORD="$acr_pass" + azd env set AZURE_ENV_IMAGETAG "$tag" >/dev/null + azd env set AZURE_ENV_CONTAINER_REGISTRY_ENDPOINT "$acr_server" >/dev/null + azd env set AZURE_ENV_ACR_USERNAME "$acr_user" >/dev/null + azd env set AZURE_ENV_ACR_PASSWORD "$acr_pass" >/dev/null + echo "Using registry: $acr_server" + echo "Image tag: $tag" + echo "Building API image (km-api:$tag) via ACR Build" + api_ctx="$(pwd)/src/api" + api_df="$api_ctx/ApiApp.Dockerfile" + [ -f "$api_df" ] || { echo "API Dockerfile not found at $api_df"; exit 1; } + if ! az acr build -r "$acr_name" -t "km-api:$tag" -f "$api_df" "$api_ctx"; then + echo "ACR build failed for km-api. Falling back to local Docker build & push." + command -v docker >/dev/null 2>&1 || { echo "Docker not installed for local fallback."; exit 1; } + az acr login -n "$acr_name" >/dev/null + docker build -f "$api_df" -t "$acr_server/km-api:$tag" "$api_ctx" + docker push "$acr_server/km-api:$tag" + fi + + echo "Building Frontend image (km-app:$tag) via ACR Build" + fe_ctx="$(pwd)/src/App" + fe_df="$fe_ctx/WebApp.Dockerfile" + [ -f "$fe_df" ] || { echo "Frontend Dockerfile not found at $fe_df"; exit 1; } + if ! az acr build -r "$acr_name" -t "km-app:$tag" -f "$fe_df" "$fe_ctx"; then + echo "ACR build failed for km-app. Falling back to local Docker build & push." + command -v docker >/dev/null 2>&1 || { echo "Docker not installed for local fallback."; exit 1; } + az acr login -n "$acr_name" >/dev/null + docker build -f "$fe_df" -t "$acr_server/km-app:$tag" "$fe_ctx" + docker push "$acr_server/km-app:$tag" + fi + echo "[preprovision] Images built & pushed. Proceeding to provision..." + + postprovision: + windows: + shell: pwsh + interactive: true + continueOnError: false + run: | + $ErrorActionPreference = 'Stop' + + Write-Host "[postprovision] Configuring App Services to use ACR credentials..." -ForegroundColor Cyan + + $rg = $env:RESOURCE_GROUP_NAME + $acrServer = $env:AZURE_ENV_CONTAINER_REGISTRY_ENDPOINT + $acrUser = $env:AZURE_ENV_ACR_USERNAME + $acrPass = $env:AZURE_ENV_ACR_PASSWORD + + if (-not $rg) { Write-Warning "RESOURCE_GROUP_NAME not set; skipping ACR appsettings."; exit 0 } + if (-not $acrServer -or -not $acrUser -or -not $acrPass) { Write-Warning "ACR env vars missing; skipping appsettings."; exit 0 } + + $webAppName = ([uri]$env:WEB_APP_URL).Host.Split('.')[0] + $apiAppName = ([uri]$env:API_APP_URL).Host.Split('.')[0] + + foreach ($appName in @($webAppName, $apiAppName)) { + if (-not $appName) { continue } + Write-Host "Updating appsettings for $appName" -ForegroundColor Yellow + az webapp config appsettings set -g $rg -n $appName --settings "DOCKER_REGISTRY_SERVER_URL=https://$acrServer" "DOCKER_REGISTRY_SERVER_USERNAME=$acrUser" "DOCKER_REGISTRY_SERVER_PASSWORD=$acrPass" | Out-Null + } + + # Ensure container images are updated to the freshly built tag + if ($acrServer -and $env:AZURE_ENV_IMAGETAG) { + $tag = $env:AZURE_ENV_IMAGETAG + if ($apiAppName) { + Write-Host "Setting API container image to $acrServer/km-api:$tag" -ForegroundColor Yellow + az webapp config container set -g $rg -n $apiAppName --docker-custom-image-name "$acrServer/km-api:$tag" | Out-Null + az webapp restart -g $rg -n $apiAppName | Out-Null + $apiFx = az webapp config container show -g $rg -n $apiAppName --query linuxFxVersion -o tsv + Write-Host "API linuxFxVersion: $apiFx" -ForegroundColor Green + } + if ($webAppName) { + Write-Host "Setting Web container image to $acrServer/km-app:$tag" -ForegroundColor Yellow + az webapp config container set -g $rg -n $webAppName --docker-custom-image-name "$acrServer/km-app:$tag" | Out-Null + az webapp restart -g $rg -n $webAppName | Out-Null + $webFx = az webapp config container show -g $rg -n $webAppName --query linuxFxVersion -o tsv + Write-Host "Web linuxFxVersion: $webFx" -ForegroundColor Green + } + } else { + Write-Warning "AZURE_ENV_CONTAINER_REGISTRY_ENDPOINT or AZURE_ENV_IMAGETAG missing; skipping container image update." + } + + Write-Host "[postprovision] App Services configured for private ACR." -ForegroundColor Green + + posix: + shell: sh + interactive: true + continueOnError: false + run: | + set -euo pipefail + echo "[postprovision] Configuring App Services to use ACR credentials..." + rg="${RESOURCE_GROUP_NAME:-}" + acr_server="${AZURE_ENV_CONTAINER_REGISTRY_ENDPOINT:-}" + acr_user="${AZURE_ENV_ACR_USERNAME:-}" + acr_pass="${AZURE_ENV_ACR_PASSWORD:-}" + [ -z "$rg" ] && echo "RESOURCE_GROUP_NAME not set; skipping." && exit 0 + [ -z "$acr_server" ] || [ -z "$acr_user" ] || [ -z "$acr_pass" ] && echo "ACR env vars missing; skipping." && exit 0 + web_app_name=$(echo "$WEB_APP_URL" | sed -E 's#https?://([^/]+)/?.*#\1#' | cut -d'.' -f1) + api_app_name=$(echo "$API_APP_URL" | sed -E 's#https?://([^/]+)/?.*#\1#' | cut -d'.' -f1) + for app in "$web_app_name" "$api_app_name"; do + [ -z "$app" ] && continue + echo "Updating appsettings for $app" + az webapp config appsettings set -g "$rg" -n "$app" --settings \ + DOCKER_REGISTRY_SERVER_URL="https://$acr_server" \ + DOCKER_REGISTRY_SERVER_USERNAME="$acr_user" \ + DOCKER_REGISTRY_SERVER_PASSWORD="$acr_pass" >/dev/null + done + # Ensure container images are updated to the freshly built tag + if [ -n "$acr_server" ] && [ -n "${AZURE_ENV_IMAGETAG:-}" ]; then + tag="$AZURE_ENV_IMAGETAG" + if [ -n "$api_app_name" ]; then + echo "Setting API container image to $acr_server/km-api:$tag" + az webapp config container set -g "$rg" -n "$api_app_name" --docker-custom-image-name "$acr_server/km-api:$tag" >/dev/null + az webapp restart -g "$rg" -n "$api_app_name" >/dev/null + fi + if [ -n "$web_app_name" ]; then + echo "Setting Web container image to $acr_server/km-app:$tag" + az webapp config container set -g "$rg" -n "$web_app_name" --docker-custom-image-name "$acr_server/km-app:$tag" >/dev/null + az webapp restart -g "$rg" -n "$web_app_name" >/dev/null + fi + else + echo "AZURE_ENV_CONTAINER_REGISTRY_ENDPOINT or AZURE_ENV_IMAGETAG missing; skipping container image update." + fi + echo "[postprovision] App Services configured for private ACR." diff --git a/infra/main_custom.bicep b/infra/main_custom.bicep new file mode 100644 index 000000000..9f870872f --- /dev/null +++ b/infra/main_custom.bicep @@ -0,0 +1,1522 @@ +// ========== main.bicep ========== // +targetScope = 'resourceGroup' + +@minLength(3) +@maxLength(16) +@description('Required. A unique prefix for all resources in this deployment. This should be 3-20 characters long:') +param solutionName string = 'kmgen' + +@metadata({ azd: { type: 'location' } }) +@description('Required. Azure region for all services. Regions are restricted to guarantee compatibility with paired regions and replica locations for data redundancy and failover scenarios based on articles [Azure regions list](https://learn.microsoft.com/azure/reliability/regions-list) and [Azure Database for MySQL Flexible Server - Azure Regions](https://learn.microsoft.com/azure/mysql/flexible-server/overview#azure-regions).') +@allowed([ + 'australiaeast' + 'centralus' + 'eastasia' + 'eastus2' + 'japaneast' + 'northeurope' + 'southeastasia' + 'uksouth' +]) +param location string + +@allowed([ + 'australiaeast' + 'eastus' + 'eastus2' + 'francecentral' + 'japaneast' + 'swedencentral' + 'uksouth' + 'westus' + 'westus3' +]) +@metadata({ + azd: { + type: 'location' + usageName: [ + 'OpenAI.GlobalStandard.gpt-4o-mini,150' + 'OpenAI.GlobalStandard.text-embedding-ada-002,80' + ] + } +}) +@description('Required. Location for AI Foundry deployment. This is the location where the AI Foundry resources will be deployed.') +param aiServiceLocation string + +@minLength(1) +@description('Required. Industry use case for deployment:') +@allowed([ + 'telecom' + 'IT_helpdesk' +]) +param usecase string + +@minLength(1) +@description('Optional. Location for the Content Understanding service deployment:') +@allowed(['swedencentral', 'australiaeast']) +@metadata({ + azd: { + type: 'location' + } +}) +param contentUnderstandingLocation string = 'swedencentral' + +@minLength(1) +@description('Optional. Secondary location for databases creation(example:eastus2):') +param secondaryLocation string = 'eastus2' + +@minLength(1) +@description('Optional. GPT model deployment type:') +@allowed([ + 'Standard' + 'GlobalStandard' +]) +param deploymentType string = 'GlobalStandard' + +@description('Optional. Name of the GPT model to deploy:') +param gptModelName string = 'gpt-4o-mini' + +@description('Optional. Version of the GPT model to deploy:') +param gptModelVersion string = '2024-07-18' + +@description('Optional. Version of the OpenAI.') +param azureOpenAIApiVersion string = '2025-01-01-preview' + +@description('Optional. Version of AI Agent API.') +param azureAiAgentApiVersion string = '2025-05-01' + +@description('Optional. Version of Content Understanding API.') +param azureContentUnderstandingApiVersion string = '2024-12-01-preview' + +// You can increase this, but capacity is limited per model/region, so you will get errors if you go over +// https://learn.microsoft.com/en-us/azure/ai-services/openai/quotas-limits +@minValue(10) +@description('Optional. Capacity of the GPT deployment:') +param gptDeploymentCapacity int = 150 + +@minLength(1) +@description('Optional. Name of the Text Embedding model to deploy:') +@allowed([ + 'text-embedding-ada-002' +]) +param embeddingModel string = 'text-embedding-ada-002' + +@minValue(10) +@description('Optional. Capacity of the Embedding Model deployment.') +param embeddingDeploymentCapacity int = 80 + +@description('Optional. The Container Registry hostname where the docker images for the backend are located.') +param backendContainerRegistryHostname string = 'kmcontainerreg.azurecr.io' + +@description('Optional. The Container Image Name to deploy on the backend.') +param backendContainerImageName string = 'km-api' + +@description('Optional. The Container Image Tag to deploy on the backend.') +param backendContainerImageTag string = 'latest_waf_2025-09-18_898' + +@description('Optional. The Container Registry hostname where the docker images for the frontend are located.') +param frontendContainerRegistryHostname string = 'kmcontainerreg.azurecr.io' + +@description('Optional. The Container Image Name to deploy on the frontend.') +param frontendContainerImageName string = 'km-app' + +@description('Optional. The Container Image Tag to deploy on the frontend.') +param frontendContainerImageTag string = 'latest_waf_2025-09-18_898' + +@description('Optional. The tags to apply to all deployed Azure resources.') +param tags resourceInput<'Microsoft.Resources/resourceGroups@2025-04-01'>.tags = {} + +@description('Optional. Enable private networking for applicable resources, aligned with the Well Architected Framework recommendations. Defaults to false.') +param enablePrivateNetworking bool = false + +@description('Optional. Enable/Disable usage telemetry for module.') +param enableTelemetry bool = true + +@description('Optional. Enable monitoring applicable resources, aligned with the Well Architected Framework recommendations. This setting enables Application Insights and Log Analytics and configures all the resources applicable resources to send logs. Defaults to false.') +param enableMonitoring bool = false + +@description('Optional. Enable redundancy for applicable resources, aligned with the Well Architected Framework recommendations. Defaults to false.') +param enableRedundancy bool = false + +@description('Optional. Enable scalability for applicable resources, aligned with the Well Architected Framework recommendations. Defaults to false.') +param enableScalability bool = false + +@description('Optional. Admin username for the Jumpbox Virtual Machine. Set to custom value if enablePrivateNetworking is true.') +@secure() +param vmAdminUsername string? + +@description('Optional. Admin password for the Jumpbox Virtual Machine. Set to custom value if enablePrivateNetworking is true.') +@secure() +param vmAdminPassword string? + +@description('Optional. Size of the Jumpbox Virtual Machine when created. Set to custom value if enablePrivateNetworking is true.') +param vmSize string = 'Standard_DS2_v2' + +@description('Optional: Existing Log Analytics Workspace Resource ID') +param existingLogAnalyticsWorkspaceId string = '' + +@description('Optional. Use this parameter to use an existing AI project resource ID') +param existingAiFoundryAiProjectResourceId string = '' + +@description('Optional. created by user name') +param createdBy string = contains(deployer(), 'userPrincipalName')? split(deployer().userPrincipalName, '@')[0]: deployer().objectId + + +@maxLength(5) +@description('Optional. A unique text value for the solution. This is used to ensure resource names are unique for global resources. Defaults to a 5-character substring of the unique string generated from the subscription ID, resource group name, and solution name.') +param solutionUniqueText string = substring(uniqueString(subscription().id, resourceGroup().name, solutionName), 0, 5) + +var solutionSuffix = toLower(trim(replace( + replace( + replace(replace(replace(replace('${solutionName}${solutionUniqueText}', '-', ''), '_', ''), '.', ''), '/', ''), + ' ', + '' + ), + '*', + '' +))) + +var acrName = 'kmcontainerreg' +// Replica regions list based on article in [Azure regions list](https://learn.microsoft.com/azure/reliability/regions-list) and [Enhance resilience by replicating your Log Analytics workspace across regions](https://learn.microsoft.com/azure/azure-monitor/logs/workspace-replication#supported-regions) for supported regions for Log Analytics Workspace. +var replicaRegionPairs = { + australiaeast: 'australiasoutheast' + centralus: 'westus' + eastasia: 'japaneast' + eastus: 'centralus' + eastus2: 'centralus' + japaneast: 'eastasia' + northeurope: 'westeurope' + southeastasia: 'eastasia' + uksouth: 'westeurope' + westeurope: 'northeurope' +} +var replicaLocation = replicaRegionPairs[resourceGroup().location] +// Region pairs list based on article in [Azure Database for MySQL Flexible Server - Azure Regions](https://learn.microsoft.com/azure/mysql/flexible-server/overview#azure-regions) for supported high availability regions for CosmosDB. +var cosmosDbZoneRedundantHaRegionPairs = { + australiaeast: 'uksouth' //'southeastasia' + centralus: 'eastus2' + eastasia: 'southeastasia' + eastus: 'centralus' + eastus2: 'centralus' + japaneast: 'australiaeast' + northeurope: 'westeurope' + southeastasia: 'eastasia' + uksouth: 'westeurope' + westeurope: 'northeurope' +} +// Paired location calculated based on 'location' parameter. This location will be used by applicable resources if `enableScalability` is set to `true` +var cosmosDbHaLocation = cosmosDbZoneRedundantHaRegionPairs[resourceGroup().location] + +// Extracts subscription, resource group, and workspace name from the resource ID when using an existing Log Analytics workspace +var useExistingLogAnalytics = !empty(existingLogAnalyticsWorkspaceId) +var logAnalyticsWorkspaceResourceId = useExistingLogAnalytics + ? existingLogAnalyticsWorkspaceId + : logAnalyticsWorkspace!.outputs.resourceId +// ========== Resource Group Tag ========== // +resource resourceGroupTags 'Microsoft.Resources/tags@2021-04-01' = { + name: 'default' + properties: { + tags: union( + reference( + resourceGroup().id, + '2021-04-01', + 'Full' + ).tags ?? {}, + { + TemplateName: 'KM-Generic' + Type: enablePrivateNetworking ? 'WAF' : 'Non-WAF' + CreatedBy: createdBy + }, + tags + ) + } +} + +#disable-next-line no-deployments-resources +resource avmTelemetry 'Microsoft.Resources/deployments@2024-03-01' = if (enableTelemetry) { + name: '46d3xbcp.ptn.sa-multiagentcustauteng.${replace('-..--..-', '.', '-')}.${substring(uniqueString(deployment().name, location), 0, 4)}' + properties: { + mode: 'Incremental' + template: { + '$schema': 'https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#' + contentVersion: '1.0.0.0' + resources: [] + outputs: { + telemetry: { + type: 'String' + value: 'For more information, see https://aka.ms/avm/TelemetryInfo' + } + } + } + } +} + +// ========== Log Analytics Workspace ========== // +// WAF best practices for Log Analytics: https://learn.microsoft.com/en-us/azure/well-architected/service-guides/azure-log-analytics +// WAF PSRules for Log Analytics: https://azure.github.io/PSRule.Rules.Azure/en/rules/resource/#azure-monitor-logs +var logAnalyticsWorkspaceResourceName = 'log-${solutionSuffix}' +module logAnalyticsWorkspace 'br/public:avm/res/operational-insights/workspace:0.12.0' = if (enableMonitoring && !useExistingLogAnalytics) { + name: take('avm.res.operational-insights.workspace.${logAnalyticsWorkspaceResourceName}', 64) + params: { + name: logAnalyticsWorkspaceResourceName + tags: tags + location: location + enableTelemetry: enableTelemetry + skuName: 'PerGB2018' + dataRetention: 365 + features: { enableLogAccessUsingOnlyResourcePermissions: true } + diagnosticSettings: [{ useThisWorkspace: true }] + // WAF aligned configuration for Redundancy + dailyQuotaGb: enableRedundancy ? 10 : null //WAF recommendation: 10 GB per day is a good starting point for most workloads + replication: enableRedundancy + ? { + enabled: true + location: replicaLocation + } + : null + // WAF aligned configuration for Private Networking + publicNetworkAccessForIngestion: enablePrivateNetworking ? 'Disabled' : 'Enabled' + publicNetworkAccessForQuery: enablePrivateNetworking ? 'Disabled' : 'Enabled' + dataSources: enablePrivateNetworking + ? [ + { + tags: tags + eventLogName: 'Application' + eventTypes: [ + { + eventType: 'Error' + } + { + eventType: 'Warning' + } + { + eventType: 'Information' + } + ] + kind: 'WindowsEvent' + name: 'applicationEvent' + } + { + counterName: '% Processor Time' + instanceName: '*' + intervalSeconds: 60 + kind: 'WindowsPerformanceCounter' + name: 'windowsPerfCounter1' + objectName: 'Processor' + } + { + kind: 'IISLogs' + name: 'sampleIISLog1' + state: 'OnPremiseEnabled' + } + ] + : null + } +} + +// ========== Application Insights ========== // +// WAF best practices for Application Insights: https://learn.microsoft.com/en-us/azure/well-architected/service-guides/application-insights +// WAF PSRules for Application Insights: https://azure.github.io/PSRule.Rules.Azure/en/rules/resource/#application-insights +var applicationInsightsResourceName = 'appi-${solutionSuffix}' +module applicationInsights 'br/public:avm/res/insights/component:0.6.0' = if (enableMonitoring) { + name: take('avm.res.insights.component.${applicationInsightsResourceName}', 64) + params: { + name: applicationInsightsResourceName + tags: tags + location: location + enableTelemetry: enableTelemetry + retentionInDays: 365 + kind: 'web' + disableIpMasking: false + flowType: 'Bluefield' + // WAF aligned configuration for Monitoring + workspaceResourceId: enableMonitoring ? logAnalyticsWorkspaceResourceId : '' + diagnosticSettings: enableMonitoring ? [{ workspaceResourceId: logAnalyticsWorkspaceResourceId }] : null + } +} +// ========== Virtual Network and Networking Components ========== // + +// Virtual Network with NSGs and Subnets +module virtualNetwork 'modules/virtualNetwork.bicep' = if (enablePrivateNetworking) { + name: take('module.virtualNetwork.${solutionSuffix}', 64) + params: { + name: 'vnet-${solutionSuffix}' + addressPrefixes: ['10.0.0.0/20'] // 4096 addresses (enough for 8 /23 subnets or 16 /24) + location: location + tags: tags + logAnalyticsWorkspaceId: logAnalyticsWorkspaceResourceId + resourceSuffix: solutionSuffix + enableTelemetry: enableTelemetry + } +} +// Azure Bastion Host +var bastionHostName = 'bas-${solutionSuffix}' +module bastionHost 'br/public:avm/res/network/bastion-host:0.6.1' = if (enablePrivateNetworking) { + name: take('avm.res.network.bastion-host.${bastionHostName}', 64) + params: { + name: bastionHostName + skuName: 'Standard' + location: location + virtualNetworkResourceId: virtualNetwork!.outputs.resourceId + diagnosticSettings: [ + { + name: 'bastionDiagnostics' + workspaceResourceId: logAnalyticsWorkspaceResourceId + logCategoriesAndGroups: [ + { + categoryGroup: 'allLogs' + enabled: true + } + ] + } + ] + tags: tags + enableTelemetry: enableTelemetry + publicIPAddressObject: { + name: 'pip-${bastionHostName}' + zones: [] + } + } +} + +// Jumpbox Virtual Machine +var jumpboxVmName = take('vm-jumpbox-${solutionSuffix}', 15) +module jumpboxVM 'br/public:avm/res/compute/virtual-machine:0.15.0' = if (enablePrivateNetworking) { + name: take('avm.res.compute.virtual-machine.${jumpboxVmName}', 64) + params: { + name: take(jumpboxVmName, 15) // Shorten VM name to 15 characters to avoid Azure limits + vmSize: vmSize ?? 'Standard_DS2_v2' + location: location + adminUsername: vmAdminUsername ?? 'JumpboxAdminUser' + adminPassword: vmAdminPassword ?? 'JumpboxAdminP@ssw0rd1234!' + tags: tags + zone: 0 + imageReference: { + offer: 'WindowsServer' + publisher: 'MicrosoftWindowsServer' + sku: '2019-datacenter' + version: 'latest' + } + osType: 'Windows' + osDisk: { + name: 'osdisk-${jumpboxVmName}' + managedDisk: { + storageAccountType: 'Standard_LRS' + } + } + encryptionAtHost: false // Some Azure subscriptions do not support encryption at host + nicConfigurations: [ + { + name: 'nic-${jumpboxVmName}' + ipConfigurations: [ + { + name: 'ipconfig1' + subnetResourceId: virtualNetwork!.outputs.jumpboxSubnetResourceId + } + ] + diagnosticSettings: [ + { + name: 'jumpboxDiagnostics' + workspaceResourceId: logAnalyticsWorkspaceResourceId + logCategoriesAndGroups: [ + { + categoryGroup: 'allLogs' + enabled: true + } + ] + metricCategories: [ + { + category: 'AllMetrics' + enabled: true + } + ] + } + ] + } + ] + enableTelemetry: enableTelemetry + } +} +// ========== Private DNS Zones ========== // +var privateDnsZones = [ + 'privatelink.cognitiveservices.azure.com' + 'privatelink.openai.azure.com' + 'privatelink.services.ai.azure.com' + 'privatelink.blob.${environment().suffixes.storage}' + 'privatelink.queue.${environment().suffixes.storage}' + 'privatelink.file.${environment().suffixes.storage}' + 'privatelink.dfs.${environment().suffixes.storage}' + 'privatelink.documents.azure.com' + 'privatelink${environment().suffixes.sqlServerHostname}' + 'privatelink.search.windows.net' +] +// DNS Zone Index Constants +var dnsZoneIndex = { + cognitiveServices: 0 + openAI: 1 + aiServices: 2 + storageBlob: 3 + storageQueue: 4 + storageFile: 5 + storageDfs: 6 + cosmosDB: 7 + sqlServer: 8 + search: 9 +} + +// =================================================== +// DEPLOY PRIVATE DNS ZONES +// - Deploys all zones if no existing Foundry project is used +// - Excludes AI-related zones when using with an existing Foundry project +// =================================================== +@batchSize(5) +module avmPrivateDnsZones 'br/public:avm/res/network/private-dns-zone:0.7.1' = [ + for (zone, i) in privateDnsZones: if (enablePrivateNetworking) { + name: 'avm.res.network.private-dns-zone.${split(zone, '.')[1]}' + params: { + name: zone + tags: tags + enableTelemetry: enableTelemetry + virtualNetworkLinks: [ + { + name: take('vnetlink-${virtualNetwork!.outputs.name}-${split(zone, '.')[1]}', 80) + virtualNetworkResourceId: virtualNetwork!.outputs.resourceId + } + ] + } + } +] + +// ========== AVM WAF ========== // +// ========== User Assigned Identity ========== // +// WAF best practices for identity and access management: https://learn.microsoft.com/en-us/azure/well-architected/security/identity-access +var userAssignedIdentityResourceName = 'id-${solutionSuffix}' +module userAssignedIdentity 'br/public:avm/res/managed-identity/user-assigned-identity:0.4.1' = { + name: take('avm.res.managed-identity.user-assigned-identity.${userAssignedIdentityResourceName}', 64) + params: { + name: userAssignedIdentityResourceName + location: location + tags: tags + enableTelemetry: enableTelemetry + } +} + +// ========== SQL Operations User Assigned Identity ========== // +// Dedicated identity for backend SQL operations with limited permissions (db_datareader, db_datawriter) +var backendUserAssignedIdentityResourceName = 'id-backend-${solutionSuffix}' +module backendUserAssignedIdentity 'br/public:avm/res/managed-identity/user-assigned-identity:0.4.1' = { + name: take('avm.res.managed-identity.user-assigned-identity.${backendUserAssignedIdentityResourceName}', 64) + params: { + name: backendUserAssignedIdentityResourceName + location: location + tags: tags + enableTelemetry: enableTelemetry + } +} + +// ========== AVM WAF ========== // +// ==========AI Foundry and related resources ========== // +// ========== AI Foundry: AI Services ========== // +// WAF best practices for Open AI: https://learn.microsoft.com/en-us/azure/well-architected/service-guides/azure-openai + +var existingOpenAIEndpoint = !empty(existingAiFoundryAiProjectResourceId) ? format('https://{0}.openai.azure.com/', split(existingAiFoundryAiProjectResourceId, '/')[8]) : '' +var existingProjEndpoint = !empty(existingAiFoundryAiProjectResourceId) ? format('https://{0}.services.ai.azure.com/api/projects/{1}', split(existingAiFoundryAiProjectResourceId, '/')[8], split(existingAiFoundryAiProjectResourceId, '/')[10]) : '' +var existingAIServicesName = !empty(existingAiFoundryAiProjectResourceId) ? split(existingAiFoundryAiProjectResourceId, '/')[8] : '' +var existingAIProjectName = !empty(existingAiFoundryAiProjectResourceId) ? split(existingAiFoundryAiProjectResourceId, '/')[10] : '' + +var aiFoundryAiServicesSubscriptionId = useExistingAiFoundryAiProject + ? split(existingAiFoundryAiProjectResourceId, '/')[2] + : subscription().id +var useExistingAiFoundryAiProject = !empty(existingAiFoundryAiProjectResourceId) +var aiFoundryAiServicesResourceGroupName = useExistingAiFoundryAiProject + ? split(existingAiFoundryAiProjectResourceId, '/')[4] + : 'rg-${solutionSuffix}' +var aiFoundryAiServicesResourceName = useExistingAiFoundryAiProject + ? split(existingAiFoundryAiProjectResourceId, '/')[8] + : 'aif-${solutionSuffix}' +var aiFoundryAiProjectResourceName = useExistingAiFoundryAiProject + ? split(existingAiFoundryAiProjectResourceId, '/')[10] + : 'proj-${solutionSuffix}' + +// NOTE: Required version 'Microsoft.CognitiveServices/accounts@2024-04-01-preview' not available in AVM +// var aiFoundryAiServicesResourceName = 'aif-${solutionSuffix}' +var aiFoundryAiServicesAiProjectResourceName = 'proj-${solutionSuffix}' +var aiFoundryAIservicesEnabled = true +var aiModelDeployments = [ + { + name: gptModelName + format: 'OpenAI' + model: gptModelName + sku: { + name: deploymentType + capacity: gptDeploymentCapacity + } + version: gptModelVersion + raiPolicyName: 'Microsoft.Default' + } + { + name: embeddingModel + format: 'OpenAI' + model: embeddingModel + sku: { + name: 'GlobalStandard' + capacity: embeddingDeploymentCapacity + } + version: '2' + raiPolicyName: 'Microsoft.Default' + } +] + +resource existingAiFoundryAiServices 'Microsoft.CognitiveServices/accounts@2025-04-01-preview' existing = if (useExistingAiFoundryAiProject) { + name: aiFoundryAiServicesResourceName + scope: resourceGroup(aiFoundryAiServicesSubscriptionId, aiFoundryAiServicesResourceGroupName) +} + +resource existingAiFoundryAiServicesProject 'Microsoft.CognitiveServices/accounts/projects@2025-04-01-preview' existing = if (useExistingAiFoundryAiProject) { + name: aiFoundryAiProjectResourceName + parent: existingAiFoundryAiServices +} + + +//TODO: update to AVM module when AI Projects and AI Projects RBAC are supported +module aiFoundryAiServices 'modules/ai-services.bicep' = if (aiFoundryAIservicesEnabled) { + name: take('avm.res.cognitive-services.account.${aiFoundryAiServicesResourceName}', 64) + params: { + name: aiFoundryAiServicesResourceName + location: aiServiceLocation + tags: tags + existingFoundryProjectResourceId: existingAiFoundryAiProjectResourceId + projectName: !empty(existingAIProjectName) ? existingAIProjectName : aiFoundryAiServicesAiProjectResourceName + projectDescription: 'AI Foundry Project' + sku: 'S0' + kind: 'AIServices' + disableLocalAuth: true + customSubDomainName: aiFoundryAiServicesResourceName + apiProperties: { + //staticsEnabled: false + } + networkAcls: { + defaultAction: 'Allow' + virtualNetworkRules: [] + ipRules: [] + bypass: 'AzureServices' + } + managedIdentities: { userAssignedResourceIds: [userAssignedIdentity!.outputs.resourceId] } //To create accounts or projects, you must enable a managed identity on your resource + roleAssignments: [ + { + roleDefinitionIdOrName: '53ca6127-db72-4b80-b1b0-d745d6d5456d' // Azure AI User + principalId: userAssignedIdentity.outputs.principalId + principalType: 'ServicePrincipal' + } + { + roleDefinitionIdOrName: '53ca6127-db72-4b80-b1b0-d745d6d5456d' // Azure AI User + principalId: backendUserAssignedIdentity.outputs.principalId + principalType: 'ServicePrincipal' + } + { + roleDefinitionIdOrName: '64702f94-c441-49e6-a78b-ef80e0188fee' // Azure AI Developer + principalId: userAssignedIdentity.outputs.principalId + principalType: 'ServicePrincipal' + } + { + roleDefinitionIdOrName: '5e0bd9bd-7b93-4f28-af87-19fc36ad61bd' // Cognitive Services OpenAI User + principalId: userAssignedIdentity.outputs.principalId + principalType: 'ServicePrincipal' + } + { + roleDefinitionIdOrName: '64702f94-c441-49e6-a78b-ef80e0188fee' // Azure AI Developer + principalId: backendUserAssignedIdentity.outputs.principalId + principalType: 'ServicePrincipal' + } + { + roleDefinitionIdOrName: '5e0bd9bd-7b93-4f28-af87-19fc36ad61bd' // Cognitive Services OpenAI User + principalId: backendUserAssignedIdentity.outputs.principalId + principalType: 'ServicePrincipal' + } + ] + // WAF aligned configuration for Monitoring + diagnosticSettings: enableMonitoring ? [{ workspaceResourceId: logAnalyticsWorkspaceResourceId }] : null + publicNetworkAccess: enablePrivateNetworking ? 'Disabled' : 'Enabled' + privateEndpoints: (enablePrivateNetworking && empty(existingAiFoundryAiProjectResourceId)) + ? ([ + { + name: 'pep-${aiFoundryAiServicesResourceName}' + customNetworkInterfaceName: 'nic-${aiFoundryAiServicesResourceName}' + subnetResourceId: virtualNetwork!.outputs.pepsSubnetResourceId + privateDnsZoneGroup: { + privateDnsZoneGroupConfigs: [ + { + name: 'ai-services-dns-zone-cognitiveservices' + privateDnsZoneResourceId: avmPrivateDnsZones[dnsZoneIndex.cognitiveServices]!.outputs.resourceId + } + { + name: 'ai-services-dns-zone-openai' + privateDnsZoneResourceId: avmPrivateDnsZones[dnsZoneIndex.openAI]!.outputs.resourceId + } + { + name: 'ai-services-dns-zone-aiservices' + privateDnsZoneResourceId: avmPrivateDnsZones[dnsZoneIndex.aiServices]!.outputs.resourceId + } + ] + } + } + ]) + : [] + deployments: [ + { + name: aiModelDeployments[0].name + model: { + format: aiModelDeployments[0].format + name: aiModelDeployments[0].name + version: aiModelDeployments[0].version + } + raiPolicyName: aiModelDeployments[0].raiPolicyName + sku: { + name: aiModelDeployments[0].sku.name + capacity: aiModelDeployments[0].sku.capacity + } + } + { + name: aiModelDeployments[1].name + model: { + format: aiModelDeployments[1].format + name: aiModelDeployments[1].name + version: aiModelDeployments[1].version + } + raiPolicyName: aiModelDeployments[1].raiPolicyName + sku: { + name: aiModelDeployments[1].sku.name + capacity: aiModelDeployments[1].sku.capacity + } + } + ] + } +} + +// AI Foundry: AI Services Content Understanding +var aiFoundryAiServicesCUResourceName = 'aif-${solutionSuffix}-cu' +var aiServicesName_cu = 'aisa-${solutionSuffix}-cu' +// NOTE: Required version 'Microsoft.CognitiveServices/accounts/deployments@2023-05-01' not available in AVM +module cognitiveServicesCu 'br/public:avm/res/cognitive-services/account:0.10.1' = { + name: take('avm.res.cognitive-services.account.${aiFoundryAiServicesCUResourceName}', 64) + params: { + name: aiServicesName_cu + location: contentUnderstandingLocation + tags: tags + enableTelemetry: enableTelemetry + diagnosticSettings: enableMonitoring ? [{ workspaceResourceId: logAnalyticsWorkspaceResourceId }] : null + sku: 'S0' + kind: 'AIServices' + networkAcls: { + defaultAction: 'Allow' + virtualNetworkRules: [] + ipRules: [] + } + managedIdentities: { userAssignedResourceIds: [userAssignedIdentity!.outputs.resourceId] } //To create accounts or projects, you must enable a managed identity on your resource + disableLocalAuth: false //Added this in order to retrieve the keys. Evaluate alternatives + customSubDomainName: aiServicesName_cu + apiProperties: { + // staticsEnabled: false + } + publicNetworkAccess: enablePrivateNetworking ? 'Disabled' : 'Enabled' + privateEndpoints: (enablePrivateNetworking) + ? ([ + { + name: 'pep-${aiFoundryAiServicesCUResourceName}' + customNetworkInterfaceName: 'nic-${aiFoundryAiServicesCUResourceName}' + subnetResourceId: virtualNetwork!.outputs.pepsSubnetResourceId + privateDnsZoneGroup: { + privateDnsZoneGroupConfigs: [ + { + name: 'ai-services-cu-dns-zone-cognitiveservices' + privateDnsZoneResourceId: avmPrivateDnsZones[dnsZoneIndex.cognitiveServices]!.outputs.resourceId + } + { + name: 'ai-services-cu-dns-zone-openai' + privateDnsZoneResourceId: avmPrivateDnsZones[dnsZoneIndex.openAI]!.outputs.resourceId + } + { + name: 'ai-services-cu-dns-zone-aiservices' + privateDnsZoneResourceId: avmPrivateDnsZones[dnsZoneIndex.aiServices]!.outputs.resourceId + } + ] + } + } + ]) + : [] + roleAssignments: [ + { + roleDefinitionIdOrName: '53ca6127-db72-4b80-b1b0-d745d6d5456d' // Azure AI User + principalId: userAssignedIdentity.outputs.principalId + principalType: 'ServicePrincipal' + } + ] + } +} + +// ========== AVM WAF ========== // +// ========== AI Foundry: AI Search ========== // +var aiSearchName = 'srch-${solutionSuffix}' +module searchSearchServices 'br/public:avm/res/search/search-service:0.11.1' = { + name: take('avm.res.search.search-service.${aiSearchName}', 64) + params: { + // Required parameters + name: aiSearchName + authOptions: { + aadOrApiKey: { + aadAuthFailureMode: 'http401WithBearerChallenge' + } + } + diagnosticSettings: enableMonitoring ? [ + { + workspaceResourceId: logAnalyticsWorkspaceResourceId + } + ] : null + disableLocalAuth: false + hostingMode: 'default' + managedIdentities: { + systemAssigned: true + } + networkRuleSet: { + bypass: 'AzureServices' + ipRules: [] + } + roleAssignments: [ + { + roleDefinitionIdOrName: '7ca78c08-252a-4471-8644-bb5ff32d4ba0' + principalId: userAssignedIdentity.outputs.principalId + principalType: 'ServicePrincipal' + } + { + roleDefinitionIdOrName: '5e0bd9bd-7b93-4f28-af87-19fc36ad61bd' + principalId: userAssignedIdentity.outputs.principalId + principalType: 'ServicePrincipal' + } + { + roleDefinitionIdOrName: '8ebe5a00-799e-43f5-93ac-243d3dce84a7' //'Search Index Data Contributor' + principalId: userAssignedIdentity.outputs.principalId + principalType: 'ServicePrincipal' + } + { + roleDefinitionIdOrName: '1407120a-92aa-4202-b7e9-c0e197c71c8f' + principalId: userAssignedIdentity.outputs.principalId + principalType: 'ServicePrincipal' + } + { + roleDefinitionIdOrName: '1407120a-92aa-4202-b7e9-c0e197c71c8f' + principalId: backendUserAssignedIdentity.outputs.principalId + principalType: 'ServicePrincipal' + } + { + roleDefinitionIdOrName: '1407120a-92aa-4202-b7e9-c0e197c71c8f' // Search Index Data Reader + principalId: !useExistingAiFoundryAiProject ? aiFoundryAiServices.outputs.aiProjectInfo.aiprojectSystemAssignedMIPrincipalId : existingAiFoundryAiServicesProject!.identity.principalId + principalType: 'ServicePrincipal' + } + { + roleDefinitionIdOrName: '7ca78c08-252a-4471-8644-bb5ff32d4ba0' // Search Service Contributor + principalId: !useExistingAiFoundryAiProject ? aiFoundryAiServices.outputs.aiProjectInfo.aiprojectSystemAssignedMIPrincipalId : existingAiFoundryAiServicesProject!.identity.principalId + principalType: 'ServicePrincipal' + } + ] + partitionCount: 1 + replicaCount: 1 + sku: 'standard' + semanticSearch: 'free' + // Use the deployment tags provided to the template + tags: tags + publicNetworkAccess: 'Enabled' //enablePrivateNetworking ? 'Disabled' : 'Enabled' + privateEndpoints: false //enablePrivateNetworking + ? [ + { + name: 'pep-${aiSearchName}' + customNetworkInterfaceName: 'nic-${aiSearchName}' + privateDnsZoneGroup: { + privateDnsZoneGroupConfigs: [ + { privateDnsZoneResourceId: avmPrivateDnsZones[dnsZoneIndex.search]!.outputs.resourceId } + ] + } + service: 'searchService' + subnetResourceId: virtualNetwork!.outputs.pepsSubnetResourceId + } + ] + : [] + } +} + +// ========== Search Service to AI Services Role Assignment ========== // +resource searchServiceToAiServicesRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (!useExistingAiFoundryAiProject){ + name: guid(aiSearchName, '5e0bd9bd-7b93-4f28-af87-19fc36ad61bd', aiFoundryAiServicesResourceName) + properties: { + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '5e0bd9bd-7b93-4f28-af87-19fc36ad61bd') // Cognitive Services OpenAI User + principalId: searchSearchServices.outputs.systemAssignedMIPrincipalId! + principalType: 'ServicePrincipal' + } +} + +resource projectAISearchConnection 'Microsoft.CognitiveServices/accounts/projects/connections@2025-04-01-preview' = if (!useExistingAiFoundryAiProject){ + name: '${aiFoundryAiServicesResourceName}/${aiFoundryAiServicesAiProjectResourceName}/${aiSearchName}' + properties: { + category: 'CognitiveSearch' + target: 'https://${aiSearchName}.search.windows.net' + authType: 'AAD' + isSharedToAll: true + metadata: { + ApiType: 'Azure' + ResourceId: searchSearchServices.outputs.resourceId + location: searchSearchServices.outputs.location + } + } +} + +module existing_AIProject_SearchConnectionModule 'modules/deploy_aifp_aisearch_connection.bicep' = if (useExistingAiFoundryAiProject) { + name: 'aiProjectSearchConnectionDeployment' + scope: resourceGroup(aiFoundryAiServicesSubscriptionId, aiFoundryAiServicesResourceGroupName) + params: { + existingAIProjectName: aiFoundryAiProjectResourceName + existingAIFoundryName: aiFoundryAiServicesResourceName + aiSearchName: aiSearchName + aiSearchResourceId: searchSearchServices.outputs.resourceId + aiSearchLocation: searchSearchServices.outputs.location + aiSearchConnectionName: aiSearchName + } +} + +// Role assignment for existing AI Services scenario +module searchServiceToExistingAiServicesRoleAssignment 'modules/role-assignment.bicep' = if (useExistingAiFoundryAiProject) { + name: 'searchToExistingAiServices-roleAssignment' + scope: resourceGroup(aiFoundryAiServicesSubscriptionId, aiFoundryAiServicesResourceGroupName) + params: { + principalId: searchSearchServices.outputs.systemAssignedMIPrincipalId! + roleDefinitionId: '5e0bd9bd-7b93-4f28-af87-19fc36ad61bd' // Cognitive Services OpenAI User + targetResourceName: aiFoundryAiServices.outputs.name + } +} + +// ========== AVM WAF ========== // +// ========== Storage account module ========== // +var storageAccountName = 'st${solutionSuffix}' +module storageAccount 'br/public:avm/res/storage/storage-account:0.20.0' = { + name: take('avm.res.storage.storage-account.${storageAccountName}', 64) + params: { + name: storageAccountName + location: location + managedIdentities: { + systemAssigned: true + userAssignedResourceIds: [ userAssignedIdentity!.outputs.resourceId ] + } + minimumTlsVersion: 'TLS1_2' + supportsHttpsTrafficOnly: true + accessTier: 'Hot' + enableTelemetry: enableTelemetry + tags: tags + enableHierarchicalNamespace: true + roleAssignments: [ + { + principalId: userAssignedIdentity.outputs.principalId + roleDefinitionIdOrName: 'Storage Blob Data Contributor' + principalType: 'ServicePrincipal' + } + { + principalId: userAssignedIdentity.outputs.principalId + roleDefinitionIdOrName: 'Storage Account Contributor' + principalType: 'ServicePrincipal' + } + { + principalId: userAssignedIdentity.outputs.principalId + roleDefinitionIdOrName: 'Storage File Data Privileged Contributor' + principalType: 'ServicePrincipal' + } + ] + networkAcls: { + bypass: 'AzureServices, Logging, Metrics' + defaultAction: 'Allow' + virtualNetworkRules: [] + } + allowSharedKeyAccess: true + allowBlobPublicAccess: true + publicNetworkAccess: enablePrivateNetworking ? 'Disabled' : 'Enabled' + privateEndpoints: enablePrivateNetworking ? [ + { + name: 'pep-blob-${solutionSuffix}' + service: 'blob' + subnetResourceId: virtualNetwork!.outputs.pepsSubnetResourceId + privateDnsZoneGroup: { + privateDnsZoneGroupConfigs: [ + { + name: 'storage-dns-zone-group-blob' + privateDnsZoneResourceId: avmPrivateDnsZones[dnsZoneIndex.storageBlob]!.outputs.resourceId + } + ] + } + } + { + name: 'pep-queue-${solutionSuffix}' + service: 'queue' + subnetResourceId: virtualNetwork!.outputs.pepsSubnetResourceId + privateDnsZoneGroup: { + privateDnsZoneGroupConfigs: [ + { + name: 'storage-dns-zone-group-queue' + privateDnsZoneResourceId: avmPrivateDnsZones[dnsZoneIndex.storageQueue]!.outputs.resourceId + } + ] + } + } + { + name: 'pep-file-${solutionSuffix}' + service: 'file' + subnetResourceId: virtualNetwork!.outputs.pepsSubnetResourceId + privateDnsZoneGroup: { + privateDnsZoneGroupConfigs: [ + { + name: 'storage-dns-zone-group-file' + privateDnsZoneResourceId: avmPrivateDnsZones[dnsZoneIndex.storageFile]!.outputs.resourceId + } + ] + } + } + { + name: 'pep-dfs-${solutionSuffix}' + service: 'dfs' + subnetResourceId: virtualNetwork!.outputs.pepsSubnetResourceId + privateDnsZoneGroup: { + privateDnsZoneGroupConfigs: [ + { + name: 'storage-dns-zone-group-dfs' + privateDnsZoneResourceId: avmPrivateDnsZones[dnsZoneIndex.storageDfs]!.outputs.resourceId + } + ] + } + } + ] : [] + blobServices: { + corsRules: [] + deleteRetentionPolicyEnabled: false + changeFeedEnabled: false + restorePolicyEnabled: false + isVersioningEnabled: false + containerDeleteRetentionPolicyEnabled: false + lastAccessTimeTrackingPolicy: { + enable: false + } + containers: [ + { + name: 'data' + } + ] + } + } +} + +//========== AVM WAF ========== // +//========== Cosmos DB module ========== // +var cosmosDbResourceName = 'cosmos-${solutionSuffix}' +var cosmosDbDatabaseName = 'db_conversation_history' +var collectionName = 'conversations' +module cosmosDb 'br/public:avm/res/document-db/database-account:0.15.0' = { + name: take('avm.res.document-db.database-account.${cosmosDbResourceName}', 64) + params: { + // Required parameters + name: cosmosDbResourceName + location: location + tags: tags + enableTelemetry: enableTelemetry + sqlDatabases: [ + { + name: cosmosDbDatabaseName + containers: [ + { + name: collectionName + paths: [ + '/userId' + ] + } + ] + } + ] + dataPlaneRoleDefinitions: [ + { + // Cosmos DB Built-in Data Contributor: https://docs.azure.cn/en-us/cosmos-db/nosql/security/reference-data-plane-roles#cosmos-db-built-in-data-contributor + roleName: 'Cosmos DB SQL Data Contributor' + dataActions: [ + 'Microsoft.DocumentDB/databaseAccounts/readMetadata' + 'Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers/*' + 'Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers/items/*' + ] + assignments: [{ principalId: backendUserAssignedIdentity.outputs.principalId }] + } + ] + // WAF aligned configuration for Monitoring + diagnosticSettings: enableMonitoring ? [{ workspaceResourceId: logAnalyticsWorkspaceResourceId }] : null + // WAF aligned configuration for Private Networking + networkRestrictions: { + networkAclBypass: 'None' + publicNetworkAccess: enablePrivateNetworking ? 'Disabled' : 'Enabled' + } + privateEndpoints: enablePrivateNetworking + ? [ + { + name: 'pep-${cosmosDbResourceName}' + customNetworkInterfaceName: 'nic-${cosmosDbResourceName}' + privateDnsZoneGroup: { + privateDnsZoneGroupConfigs: [ + { privateDnsZoneResourceId: avmPrivateDnsZones[dnsZoneIndex.cosmosDB]!.outputs.resourceId } + ] + } + service: 'Sql' + subnetResourceId: virtualNetwork!.outputs.pepsSubnetResourceId + } + ] + : [] + // WAF aligned configuration for Redundancy + zoneRedundant: enableRedundancy ? true : false + capabilitiesToAdd: enableRedundancy ? null : ['EnableServerless'] + automaticFailover: enableRedundancy ? true : false + failoverLocations: enableRedundancy + ? [ + { + failoverPriority: 0 + isZoneRedundant: true + locationName: location + } + { + failoverPriority: 1 + isZoneRedundant: true + locationName: cosmosDbHaLocation + } + ] + : [ + { + locationName: location + failoverPriority: 0 + isZoneRedundant: false + } + ] + } + dependsOn: [storageAccount] +} + +//========== AVM WAF ========== // +//========== SQL Database module ========== // +var sqlServerResourceName = 'sql-${solutionSuffix}' +var sqlDbModuleName = 'sqldb-${solutionSuffix}' +module sqlDBModule 'br/public:avm/res/sql/server:0.20.1' = { + name: take('avm.res.sql.server.${sqlServerResourceName}', 64) + params: { + // Required parameters + name: sqlServerResourceName + // Non-required parameters + administrators: { + azureADOnlyAuthentication: true + login: userAssignedIdentity.outputs.name + principalType: 'Application' + sid: userAssignedIdentity.outputs.principalId + tenantId: subscription().tenantId + } + connectionPolicy: 'Redirect' + databases: [ + { + availabilityZone: enableRedundancy ? 1 : -1 + collation: 'SQL_Latin1_General_CP1_CI_AS' + diagnosticSettings: enableMonitoring + ? [{ workspaceResourceId: logAnalyticsWorkspaceResourceId }] + : null + licenseType: 'LicenseIncluded' + maxSizeBytes: 34359738368 + name: sqlDbModuleName + minCapacity: '1' + sku: { + name: 'GP_S_Gen5' + tier: 'GeneralPurpose' + family: 'Gen5' + capacity: 2 + } + zoneRedundant: enableRedundancy ? true : false + } + ] + location: secondaryLocation + managedIdentities: { + systemAssigned: true + userAssignedResourceIds: [ + userAssignedIdentity.outputs.resourceId + backendUserAssignedIdentity.outputs.resourceId + ] + } + primaryUserAssignedIdentityResourceId: userAssignedIdentity.outputs.resourceId + publicNetworkAccess: enablePrivateNetworking ? 'Disabled' : 'Enabled' + firewallRules: (!enablePrivateNetworking) ? [ + { + endIpAddress: '255.255.255.255' + name: 'AllowSpecificRange' + startIpAddress: '0.0.0.0' + } + { + endIpAddress: '0.0.0.0' + name: 'AllowAllWindowsAzureIps' + startIpAddress: '0.0.0.0' + } + ] : [] + tags: tags + } +} + +// ========== SQL Server Private Endpoint (separated) ========== // +module sqlDbPrivateEndpoint 'br/public:avm/res/network/private-endpoint:0.11.1' = if (enablePrivateNetworking) { + name: take('avm.res.network.private-endpoint.sql-${solutionSuffix}', 64) + params: { + name: 'pep-sql-${solutionSuffix}' + location: location + tags: tags + enableTelemetry: enableTelemetry + subnetResourceId: virtualNetwork!.outputs.pepsSubnetResourceId + customNetworkInterfaceName: 'nic-sql-${solutionSuffix}' + privateLinkServiceConnections: [ + { + name: 'pl-sqlserver-${solutionSuffix}' + properties: { + privateLinkServiceId: sqlDBModule.outputs.resourceId + groupIds: ['sqlServer'] + } + } + ] + privateDnsZoneGroup: { + privateDnsZoneGroupConfigs: [ + { + privateDnsZoneResourceId: avmPrivateDnsZones[dnsZoneIndex.sqlServer]!.outputs.resourceId + } + ] + } + } +} + +// ========== AVM WAF server farm ========== // +// WAF best practices for Web Application Services: https://learn.microsoft.com/en-us/azure/well-architected/service-guides/app-service-web-apps +// PSRule for Web Server Farm: https://azure.github.io/PSRule.Rules.Azure/en/rules/resource/#app-service +var webServerFarmResourceName = 'asp-${solutionSuffix}' +module webServerFarm 'br/public:avm/res/web/serverfarm:0.5.0' = { + name: 'deploy_app_service_plan_serverfarm' + params: { + name: webServerFarmResourceName + tags: tags + enableTelemetry: enableTelemetry + location: location + reserved: true + kind: 'linux' + // WAF aligned configuration for Monitoring + diagnosticSettings: enableMonitoring ? [{ workspaceResourceId: logAnalyticsWorkspaceResourceId }] : null + // WAF aligned configuration for Scalability + skuName: enableScalability || enableRedundancy ? 'P1v3' : 'B3' + skuCapacity: enableScalability ? 1 : 1 + // WAF aligned configuration for Redundancy + zoneRedundant: enableRedundancy ? true : false + } +} + +var reactAppLayoutConfig ='''{ + "appConfig": { + "THREE_COLUMN": { + "DASHBOARD": 50, + "CHAT": 33, + "CHATHISTORY": 17 + }, + "TWO_COLUMN": { + "DASHBOARD_CHAT": { + "DASHBOARD": 65, + "CHAT": 35 + }, + "CHAT_CHATHISTORY": { + "CHAT": 80, + "CHATHISTORY": 20 + } + } + }, + "charts": [ + { + "id": "SATISFIED", + "name": "Satisfied", + "type": "card", + "layout": { "row": 1, "column": 1, "height": 11 } + }, + { + "id": "TOTAL_CALLS", + "name": "Total Calls", + "type": "card", + "layout": { "row": 1, "column": 2, "span": 1 } + }, + { + "id": "AVG_HANDLING_TIME", + "name": "Average Handling Time", + "type": "card", + "layout": { "row": 1, "column": 3, "span": 1 } + }, + { + "id": "SENTIMENT", + "name": "Topics Overview", + "type": "donutchart", + "layout": { "row": 2, "column": 1, "width": 40, "height": 44.5 } + }, + { + "id": "AVG_HANDLING_TIME_BY_TOPIC", + "name": "Average Handling Time By Topic", + "type": "bar", + "layout": { "row": 2, "column": 2, "row-span": 2, "width": 60 } + }, + { + "id": "TOPICS", + "name": "Trending Topics", + "type": "table", + "layout": { "row": 3, "column": 1, "span": 2 } + }, + { + "id": "KEY_PHRASES", + "name": "Key Phrases", + "type": "wordcloud", + "layout": { "row": 3, "column": 2, "height": 44.5 } + } + ] +}''' +var backendWebSiteResourceName = 'api-${solutionSuffix}' +module webSiteBackend 'modules/web-sites.bicep' = { + name: take('module.web-sites.${backendWebSiteResourceName}', 64) + params: { + name: backendWebSiteResourceName + tags: tags + location: location + kind: 'app,linux,container' + serverFarmResourceId: webServerFarm.?outputs.resourceId + managedIdentities: { + systemAssigned: true + userAssignedResourceIds: [ + backendUserAssignedIdentity.outputs.resourceId + ] + } + siteConfig: { + linuxFxVersion: 'DOCKER|${backendContainerRegistryHostname}/${backendContainerImageName}:${backendContainerImageTag}' + minTlsVersion: '1.2' + } + configs: [ + { + name: 'appsettings' + properties: { + REACT_APP_LAYOUT_CONFIG: reactAppLayoutConfig + AZURE_OPENAI_DEPLOYMENT_MODEL: gptModelName + AZURE_OPENAI_ENDPOINT: !empty(existingOpenAIEndpoint) ? existingOpenAIEndpoint : 'https://${aiFoundryAiServices.outputs.name}.openai.azure.com/' + AZURE_OPENAI_API_VERSION: azureOpenAIApiVersion + AZURE_OPENAI_RESOURCE: aiFoundryAiServices.outputs.name + AZURE_AI_AGENT_ENDPOINT: !empty(existingProjEndpoint) ? existingProjEndpoint : aiFoundryAiServices.outputs.aiProjectInfo.apiEndpoint + AZURE_AI_AGENT_API_VERSION: azureAiAgentApiVersion + AZURE_AI_AGENT_MODEL_DEPLOYMENT_NAME: gptModelName + USE_CHAT_HISTORY_ENABLED: 'True' + AZURE_COSMOSDB_ACCOUNT: cosmosDb.outputs.name + AZURE_COSMOSDB_CONVERSATIONS_CONTAINER: collectionName + AZURE_COSMOSDB_DATABASE: cosmosDbDatabaseName + AZURE_COSMOSDB_ENABLE_FEEDBACK: 'True' + SQLDB_DATABASE: 'sqldb-${solutionSuffix}' + SQLDB_SERVER: '${sqlDBModule.outputs.name }${environment().suffixes.sqlServerHostname}' + SQLDB_USER_MID: backendUserAssignedIdentity.outputs.clientId + AZURE_AI_SEARCH_ENDPOINT: 'https://${aiSearchName}.search.windows.net' + AZURE_AI_SEARCH_INDEX: 'call_transcripts_index' + AZURE_AI_SEARCH_CONNECTION_NAME: aiSearchName + USE_AI_PROJECT_CLIENT: 'True' + DISPLAY_CHART_DEFAULT: 'False' + APPLICATIONINSIGHTS_CONNECTION_STRING: enableMonitoring ? applicationInsights!.outputs.connectionString : '' + DUMMY_TEST: 'True' + SOLUTION_NAME: solutionSuffix + APP_ENV: 'Prod' + AZURE_CLIENT_ID: backendUserAssignedIdentity.outputs.clientId + AZURE_BASIC_LOGGING_LEVEL: 'INFO' + AZURE_PACKAGE_LOGGING_LEVEL: 'WARNING' + AZURE_LOGGING_PACKAGES: '' + } + // WAF aligned configuration for Monitoring + applicationInsightResourceId: enableMonitoring ? applicationInsights!.outputs.resourceId : null + } + ] + diagnosticSettings: enableMonitoring ? [{ workspaceResourceId: logAnalyticsWorkspaceResourceId }] : null + // WAF aligned configuration for Private Networking + vnetRouteAllEnabled: enablePrivateNetworking ? true : false + vnetImagePullEnabled: enablePrivateNetworking ? true : false + virtualNetworkSubnetId: enablePrivateNetworking ? virtualNetwork!.outputs.webSubnetResourceId : null + publicNetworkAccess: 'Enabled' + } +} + +// ========== Web App module ========== // +// WAF best practices for Web Application Services: https://learn.microsoft.com/en-us/azure/well-architected/service-guides/app-service-web-apps +//NOTE: AVM module adds 1 MB of overhead to the template. Keeping vanilla resource to save template size. +var webSiteResourceName = 'app-${solutionSuffix}' +module webSiteFrontend 'modules/web-sites.bicep' = { + name: take('module.web-sites.${webSiteResourceName}', 64) + params: { + name: webSiteResourceName + tags: tags + location: location + kind: 'app,linux,container' + serverFarmResourceId: webServerFarm.outputs.resourceId + siteConfig: { + linuxFxVersion: 'DOCKER|${frontendContainerRegistryHostname}/${frontendContainerImageName}:${frontendContainerImageTag}' + minTlsVersion: '1.2' + } + configs: [ + { + name: 'appsettings' + properties: { + APP_API_BASE_URL: 'https://api-${solutionSuffix}.azurewebsites.net' + } + applicationInsightResourceId: enableMonitoring ? applicationInsights!.outputs.resourceId : null + } + ] + vnetRouteAllEnabled: enablePrivateNetworking ? true : false + vnetImagePullEnabled: enablePrivateNetworking ? true : false + virtualNetworkSubnetId: enablePrivateNetworking ? virtualNetwork!.outputs.webSubnetResourceId : null + diagnosticSettings: enableMonitoring ? [{ workspaceResourceId: logAnalyticsWorkspaceResourceId }] : null + publicNetworkAccess: 'Enabled' + } +} + +@description('Contains Solution Name.') +output SOLUTION_NAME string = solutionSuffix + +@description('Contains Resource Group Name.') +output RESOURCE_GROUP_NAME string = resourceGroup().name + +@description('Contains Resource Group Location.') +output RESOURCE_GROUP_LOCATION string = location + +@description('Contains Azure Content Understanding Location.') +output AZURE_CONTENT_UNDERSTANDING_LOCATION string = contentUnderstandingLocation + +// @description('Contains Azure Secondary Location.') +// output AZURE_SECONDARY_LOCATION string = secondaryLocation + +@description('Contains Application Insights Instrumentation Key.') +output APPINSIGHTS_INSTRUMENTATIONKEY string = enableMonitoring ? applicationInsights!.outputs.instrumentationKey : '' + +@description('Contains AI Project Connection String.') +output AZURE_AI_PROJECT_CONN_STRING string = !empty(existingProjEndpoint) ? existingProjEndpoint : aiFoundryAiServices.outputs.endpoint + +@description('Contains Azure AI Agent API Version.') +output AZURE_AI_AGENT_API_VERSION string = azureAiAgentApiVersion + +@description('Contains Azure AI Foundry service name.') +output AZURE_AI_FOUNDRY_NAME string = !empty(existingAIServicesName) ? existingAIServicesName : aiFoundryAiServices.outputs.name + +@description('Contains Azure AI Project name.') +output AZURE_AI_PROJECT_NAME string = !empty(existingAIProjectName) ? existingAIProjectName : aiFoundryAiServices.outputs.aiProjectInfo.name + +@description('Contains Azure AI Search service name.') +output AZURE_AI_SEARCH_NAME string = aiSearchName + +@description('Contains Azure AI Search endpoint URL.') +output AZURE_AI_SEARCH_ENDPOINT string = 'https://${aiSearchName}.search.windows.net' + +@description('Contains Azure AI Search index name.') +output AZURE_AI_SEARCH_INDEX string = 'call_transcripts_index' + +@description('Contains Azure AI Search connection name.') +output AZURE_AI_SEARCH_CONNECTION_NAME string = aiSearchName + +@description('Contains Azure Cosmos DB account name.') +output AZURE_COSMOSDB_ACCOUNT string = cosmosDb.outputs.name + +@description('Contains Azure Cosmos DB conversations container name.') +output AZURE_COSMOSDB_CONVERSATIONS_CONTAINER string = 'conversations' + +@description('Contains Azure Cosmos DB database name.') +output AZURE_COSMOSDB_DATABASE string = 'db_conversation_history' + +@description('Contains Azure Cosmos DB feedback enablement setting.') +output AZURE_COSMOSDB_ENABLE_FEEDBACK string = 'True' + +@description('Contains Azure OpenAI deployment model name.') +output AZURE_OPENAI_DEPLOYMENT_MODEL string = gptModelName + +@description('Contains Azure OpenAI deployment model capacity.') +output AZURE_OPENAI_DEPLOYMENT_MODEL_CAPACITY int = gptDeploymentCapacity + +@description('Contains Azure OpenAI endpoint URL.') +output AZURE_OPENAI_ENDPOINT string = 'https://${aiFoundryAiServices.outputs.name}.openai.azure.com/' + +@description('Contains Azure OpenAI model deployment type.') +output AZURE_OPENAI_MODEL_DEPLOYMENT_TYPE string = deploymentType + +@description('Contains Azure OpenAI embedding model name.') +output AZURE_OPENAI_EMBEDDING_MODEL string = embeddingModel + +@description('Contains Azure OpenAI embedding model capacity.') +output AZURE_OPENAI_EMBEDDING_MODEL_CAPACITY int = embeddingDeploymentCapacity + +@description('Contains Azure OpenAI API version.') +output AZURE_OPENAI_API_VERSION string = azureOpenAIApiVersion + +@description('Contains Content Understanding API version.') +output AZURE_CONTENT_UNDERSTANDING_API_VERSION string = azureContentUnderstandingApiVersion + +@description('Contains Azure OpenAI resource name.') +output AZURE_OPENAI_RESOURCE string = aiFoundryAiServices.outputs.name + +@description('Contains React app layout configuration.') +output REACT_APP_LAYOUT_CONFIG string = reactAppLayoutConfig + +@description('Contains SQL database name.') +output SQLDB_DATABASE string = 'sqldb-${solutionSuffix}' + +@description('Contains SQL server name.') +output SQLDB_SERVER string = '${sqlDBModule.outputs.name }${environment().suffixes.sqlServerHostname}' + +@description('Display name of the backend API user-assigned managed identity (also used for SQL database access).') +output BACKEND_USER_MID_NAME string = backendUserAssignedIdentity.outputs.name + +@description('Client ID of the backend API user-assigned managed identity (also used for SQL database access).') +output BACKEND_USER_MID string = backendUserAssignedIdentity.outputs.clientId + +@description('Contains AI project client usage setting.') +output USE_AI_PROJECT_CLIENT string = 'False' + +@description('Contains chat history enablement setting.') +output USE_CHAT_HISTORY_ENABLED string = 'True' + +@description('Contains default chart display setting.') +output DISPLAY_CHART_DEFAULT string = 'False' + +@description('Contains Azure AI Agent endpoint URL.') +output AZURE_AI_AGENT_ENDPOINT string = !empty(existingProjEndpoint) ? existingProjEndpoint : aiFoundryAiServices.outputs.aiProjectInfo.apiEndpoint + +@description('Contains Azure AI Agent model deployment name.') +output AZURE_AI_AGENT_MODEL_DEPLOYMENT_NAME string = gptModelName + +@description('Contains Azure Container Registry name.') +output ACR_NAME string = acrName + +@description('Contains Azure environment image tag.') +output AZURE_ENV_IMAGETAG string = backendContainerImageTag + +@description('Contains existing AI project resource ID.') +output AZURE_EXISTING_AI_PROJECT_RESOURCE_ID string = existingAiFoundryAiProjectResourceId + +@description('Contains Application Insights connection string.') +output APPLICATIONINSIGHTS_CONNECTION_STRING string = enableMonitoring ? applicationInsights!.outputs.connectionString : '' + +@description('Contains API application URL.') +output API_APP_URL string = 'https://api-${solutionSuffix}.azurewebsites.net' + +@description('Contains web application URL.') +output WEB_APP_URL string = 'https://app-${solutionSuffix}.azurewebsites.net' + +@description('Name of the Storage Account.') +output STORAGE_ACCOUNT_NAME string = storageAccount.outputs.name + +@description('Name of the Storage Container.') +output STORAGE_CONTAINER_NAME string = 'data' + +@description('Resource ID of the AI Foundry Project.') +output AI_FOUNDRY_RESOURCE_ID string = aiFoundryAIservicesEnabled ? aiFoundryAiServices.outputs.resourceId : '' + +@description('Resource ID of the Content Understanding AI Foundry.') +output CU_FOUNDRY_RESOURCE_ID string = cognitiveServicesCu.outputs.resourceId + +@description('Azure OpenAI Content Understanding endpoint URL.') +output AZURE_OPENAI_CU_ENDPOINT string = cognitiveServicesCu.outputs.endpoint + +@description('Industry Use Case.') +output USE_CASE string = usecase From 5b20563ac581c4893ee541d986de1bc025c51721 Mon Sep 17 00:00:00 2001 From: Venkateswarlu Marthula Date: Mon, 29 Dec 2025 17:23:25 +0530 Subject: [PATCH 02/19] refactor: Update YAML configurations and enhance service hooks for API and webapp --- azure.yaml | 44 +++++- azure_custom.yaml | 312 +++++++-------------------------------- infra/main_custom.bicep | 23 ++- src/api/requirements.txt | 1 + 4 files changed, 111 insertions(+), 269 deletions(-) diff --git a/azure.yaml b/azure.yaml index 2f0e5d756..fba6b3461 100644 --- a/azure.yaml +++ b/azure.yaml @@ -1,9 +1,3 @@ -# # yaml-language-server: $schema=https://raw.githubusercontent.com/Azure/azure-dev/main/schemas/v1.0/azure.yaml.json -# metadata: -# template: azd-init@1.11.1 -environment: - name: conversation-knowledge-mining - location: eastus name: conversation-knowledge-mining requiredVersions: @@ -12,6 +6,42 @@ requiredVersions: metadata: template: conversation-knowledge-mining@1.0 +infra: + provider: bicep + path: infra + module: main + parameters: infra/main.parameters.json + +services: + api: + project: ./src/api + language: py + host: appservice + hooks: + prepackage: + windows: + shell: pwsh + continueOnError: false + run: pwsh ../../infra/scripts/package_webapp.ps1 + posix: + shell: sh + continueOnError: false + run: bash ../../infra/scripts/package_webapp.sh + webapp: + project: ./src/App/deploy + language: js + host: appservice + hooks: + prepackage: + windows: + shell: pwsh + continueOnError: false + run: pwsh ../../../infra/scripts/package_frontend.ps1 + posix: + shell: sh + continueOnError: false + run: bash ../../../infra/scripts/package_frontend.sh + hooks: postprovision: windows: @@ -32,4 +62,4 @@ hooks: echo "bash ./infra/scripts/process_sample_data.sh" shell: sh continueOnError: false - interactive: true \ No newline at end of file + interactive: true diff --git a/azure_custom.yaml b/azure_custom.yaml index afdc3dbd9..e70b3c41a 100644 --- a/azure_custom.yaml +++ b/azure_custom.yaml @@ -1,4 +1,4 @@ -name: conversation-knowledge-mining-custom +name: conversation-knowledge-mining requiredVersions: azd: ">= 1.18.0" @@ -6,273 +6,75 @@ requiredVersions: metadata: template: conversation-knowledge-mining@1.0 -environment: - name: conversation-knowledge-mining - location: japaneast - infra: provider: bicep path: infra module: main parameters: infra/main.parameters.json -hooks: - preprovision: - windows: - shell: pwsh - interactive: true - continueOnError: false - run: | - $ErrorActionPreference = 'Stop' - - Write-Host "[preprovision] Preparing ACR and building local images..." -ForegroundColor Cyan - - try { az account show | Out-Null } catch { Write-Error "Azure CLI not logged in. Run 'az login' first."; exit 1 } - - $location = $env:AZURE_LOCATION - if (-not $location) { $location = 'japaneast' } - $envName = $env:AZURE_ENV_NAME - if (-not $envName) { $envName = 'conversation-knowledge-mining' } - - $env:AZURE_LOCATION = $location - azd env set AZURE_LOCATION $location | Out-Null - $env:AZURE_ENV_NAME = $envName - azd env set AZURE_ENV_NAME $envName | Out-Null - - $acrRg = "rg-$envName-acr" - if ((az group exists -n $acrRg) -ne 'true') { - Write-Host "Creating resource group $acrRg in $location" -ForegroundColor Yellow - az group create -n $acrRg -l $location | Out-Null - } - - $acrNameBase = 'kmcontainerreg' - $acrName = $acrNameBase - if ((az acr check-name --name $acrNameBase --query nameAvailable -o tsv) -ne 'true') { - $suffix = Get-Random -Maximum 99999 - $acrName = "$acrNameBase$suffix" - Write-Host "Base ACR name unavailable. Using $acrName" -ForegroundColor Yellow - } - - $acrShowName = az acr show -n $acrName -g $acrRg --only-show-errors --query name -o tsv 2>$null - if ($acrShowName -ne $acrName) { - Write-Host "Creating ACR $acrName" -ForegroundColor Yellow - az acr create -n $acrName -g $acrRg -l $location --sku Basic --only-show-errors | Out-Null - $acrServer = '' - for ($i = 0; $i -lt 30; $i++) { - $acrServer = az acr show -n $acrName --query loginServer -o tsv 2>$null - if ($acrServer) { break } - Start-Sleep -Seconds 5 - } - if (-not $acrServer) { Write-Error "ACR $acrName did not become ready (no loginServer)"; exit 1 } - } - - az acr update -n $acrName --admin-enabled true | Out-Null - $acrServer = az acr show -n $acrName --query loginServer -o tsv - $acrCreds = az acr credential show -n $acrName --only-show-errors | ConvertFrom-Json - if (-not $acrCreds -or -not $acrCreds.passwords -or $acrCreds.passwords.Count -lt 1) { - Write-Error "ACR credentials unavailable."; exit 1 - } - $acrUser = $acrCreds.username - $acrPass = $acrCreds.passwords[0].value - - $tag = "latest_waf_{0}" -f ((Get-Date).ToString("yyyy-MM-dd_HHmm")) - $env:AZURE_ENV_IMAGETAG = $tag - $env:AZURE_ENV_CONTAINER_REGISTRY_ENDPOINT = $acrServer - $env:AZURE_ENV_ACR_USERNAME = $acrUser - $env:AZURE_ENV_ACR_PASSWORD = $acrPass - - azd env set AZURE_ENV_IMAGETAG $tag | Out-Null - azd env set AZURE_ENV_CONTAINER_REGISTRY_ENDPOINT $acrServer | Out-Null - azd env set AZURE_ENV_ACR_USERNAME $acrUser | Out-Null - azd env set AZURE_ENV_ACR_PASSWORD $acrPass | Out-Null - - Write-Host "Using registry: $acrServer" -ForegroundColor Cyan - Write-Host "Image tag: $tag" -ForegroundColor Cyan - - Write-Host "Building API image (km-api:$tag) via ACR Build" -ForegroundColor Yellow - $repoRoot = (Get-Location).Path - $apiCtx = Join-Path $repoRoot 'src\api' - $apiDockerfile = Join-Path $apiCtx 'ApiApp.Dockerfile' - if (-not (Test-Path $apiDockerfile)) { Write-Error "API Dockerfile not found at $apiDockerfile"; exit 1 } - $apiBuildSucceeded = $true - az acr build -r $acrName -t "km-api:$tag" -f $apiDockerfile $apiCtx - if ($LASTEXITCODE -ne 0) { $apiBuildSucceeded = $false } - if (-not $apiBuildSucceeded) { - Write-Warning "ACR build failed for km-api. Falling back to local Docker build & push." - if (-not (Get-Command docker -ErrorAction SilentlyContinue)) { Write-Error "Docker is not installed or not in PATH for local fallback."; exit 1 } - az acr login -n $acrName | Out-Null - docker build -f $apiDockerfile -t "$acrServer/km-api:$tag" $apiCtx - docker push "$acrServer/km-api:$tag" - } - - Write-Host "Building Frontend image (km-app:$tag) via ACR Build" -ForegroundColor Yellow - $feCtx = Join-Path $repoRoot 'src\App' - $feDockerfile = Join-Path $feCtx 'WebApp.Dockerfile' - if (-not (Test-Path $feDockerfile)) { Write-Error "Frontend Dockerfile not found at $feDockerfile"; exit 1 } - $feBuildSucceeded = $true - az acr build -r $acrName -t "km-app:$tag" -f $feDockerfile $feCtx - if ($LASTEXITCODE -ne 0) { $feBuildSucceeded = $false } - if (-not $feBuildSucceeded) { - Write-Warning "ACR build failed for km-app. Falling back to local Docker build & push." - if (-not (Get-Command docker -ErrorAction SilentlyContinue)) { Write-Error "Docker is not installed or not in PATH for local fallback."; exit 1 } - az acr login -n $acrName | Out-Null - docker build -f $feDockerfile -t "$acrServer/km-app:$tag" $feCtx - docker push "$acrServer/km-app:$tag" - } - - Write-Host "[preprovision] Images built & pushed. Proceeding to provision..." -ForegroundColor Green - - posix: - shell: sh - interactive: true - continueOnError: false - run: | - set -euo pipefail - echo "[preprovision] Preparing ACR and building local images..." - az account show >/dev/null || { echo "Run 'az login' first"; exit 1; } - : "${AZURE_LOCATION:=japaneast}" - : "${AZURE_ENV_NAME:=conversation-knowledge-mining}" - azd env set AZURE_LOCATION "$AZURE_LOCATION" >/dev/null - azd env set AZURE_ENV_NAME "$AZURE_ENV_NAME" >/dev/null - acr_rg="rg-${AZURE_ENV_NAME}-acr" - az group create -n "$acr_rg" -l "$AZURE_LOCATION" >/dev/null 2>&1 || true - acr_name_base="kmcontainerreg" - name_ok=$(az acr check-name --name "$acr_name_base" --query nameAvailable -o tsv) - if [ "$name_ok" != "true" ]; then - suffix=$(printf "%05d" $(shuf -i 0-99999 -n 1)) - acr_name="${acr_name_base}${suffix}" - echo "Base ACR name unavailable. Using $acr_name" - else - acr_name="$acr_name_base" - fi - az acr show -n "$acr_name" -g "$acr_rg" >/dev/null 2>&1 || az acr create -n "$acr_name" -g "$acr_rg" -l "$AZURE_LOCATION" --sku Basic >/dev/null - az acr update -n "$acr_name" --admin-enabled true >/dev/null - acr_server=$(az acr show -n "$acr_name" --query loginServer -o tsv) - acr_user=$(az acr credential show -n "$acr_name" --query username -o tsv) - acr_pass=$(az acr credential show -n "$acr_name" --query passwords[0].value -o tsv) - tag="latest_waf_$(date +%Y-%m-%d_%H%M)" - export AZURE_ENV_IMAGETAG="$tag" - export AZURE_ENV_CONTAINER_REGISTRY_ENDPOINT="$acr_server" - export AZURE_ENV_ACR_USERNAME="$acr_user" - export AZURE_ENV_ACR_PASSWORD="$acr_pass" - azd env set AZURE_ENV_IMAGETAG "$tag" >/dev/null - azd env set AZURE_ENV_CONTAINER_REGISTRY_ENDPOINT "$acr_server" >/dev/null - azd env set AZURE_ENV_ACR_USERNAME "$acr_user" >/dev/null - azd env set AZURE_ENV_ACR_PASSWORD "$acr_pass" >/dev/null - echo "Using registry: $acr_server" - echo "Image tag: $tag" - echo "Building API image (km-api:$tag) via ACR Build" - api_ctx="$(pwd)/src/api" - api_df="$api_ctx/ApiApp.Dockerfile" - [ -f "$api_df" ] || { echo "API Dockerfile not found at $api_df"; exit 1; } - if ! az acr build -r "$acr_name" -t "km-api:$tag" -f "$api_df" "$api_ctx"; then - echo "ACR build failed for km-api. Falling back to local Docker build & push." - command -v docker >/dev/null 2>&1 || { echo "Docker not installed for local fallback."; exit 1; } - az acr login -n "$acr_name" >/dev/null - docker build -f "$api_df" -t "$acr_server/km-api:$tag" "$api_ctx" - docker push "$acr_server/km-api:$tag" - fi - - echo "Building Frontend image (km-app:$tag) via ACR Build" - fe_ctx="$(pwd)/src/App" - fe_df="$fe_ctx/WebApp.Dockerfile" - [ -f "$fe_df" ] || { echo "Frontend Dockerfile not found at $fe_df"; exit 1; } - if ! az acr build -r "$acr_name" -t "km-app:$tag" -f "$fe_df" "$fe_ctx"; then - echo "ACR build failed for km-app. Falling back to local Docker build & push." - command -v docker >/dev/null 2>&1 || { echo "Docker not installed for local fallback."; exit 1; } - az acr login -n "$acr_name" >/dev/null - docker build -f "$fe_df" -t "$acr_server/km-app:$tag" "$fe_ctx" - docker push "$acr_server/km-app:$tag" - fi - echo "[preprovision] Images built & pushed. Proceeding to provision..." +services: + api: + project: ./src/api + language: py + host: appservice + webapp: + project: ./src/App + language: js + host: appservice + dist: ./build + hooks: + prepackage: + windows: + shell: pwsh + continueOnError: false + run: | + Write-Host "=== React Build Starting ===" -ForegroundColor Cyan + Write-Host "Current directory: $(Get-Location)" -ForegroundColor Yellow + $apiUrl = azd env get-value API_APP_URL + if (-not $apiUrl) { + Write-Error "API_APP_URL not found. Run 'azd provision' first." + exit 1 + } + Write-Host "Setting REACT_APP_API_BASE_URL=$apiUrl" -ForegroundColor Yellow + $env:REACT_APP_API_BASE_URL = $apiUrl + Write-Host "Installing dependencies..." -ForegroundColor Cyan + npm install + Write-Host "Building React app..." -ForegroundColor Cyan + npm run build + Write-Host "=== React Build Complete ===" -ForegroundColor Green + posix: + shell: sh + continueOnError: false + run: | + echo "=== React Build Starting ===" + echo "Current directory: $(pwd)" + apiUrl=$(azd env get-value API_APP_URL) + if [ -z "$apiUrl" ]; then + echo "ERROR: API_APP_URL not found. Run 'azd provision' first." + exit 1 + fi + echo "Setting REACT_APP_API_BASE_URL=$apiUrl" + export REACT_APP_API_BASE_URL="$apiUrl" + echo "Installing dependencies..." + npm install + echo "Building React app..." + npm run build + echo "=== React Build Complete ===" +hooks: postprovision: windows: shell: pwsh - interactive: true continueOnError: false + interactive: true run: | - $ErrorActionPreference = 'Stop' - - Write-Host "[postprovision] Configuring App Services to use ACR credentials..." -ForegroundColor Cyan - - $rg = $env:RESOURCE_GROUP_NAME - $acrServer = $env:AZURE_ENV_CONTAINER_REGISTRY_ENDPOINT - $acrUser = $env:AZURE_ENV_ACR_USERNAME - $acrPass = $env:AZURE_ENV_ACR_PASSWORD - - if (-not $rg) { Write-Warning "RESOURCE_GROUP_NAME not set; skipping ACR appsettings."; exit 0 } - if (-not $acrServer -or -not $acrUser -or -not $acrPass) { Write-Warning "ACR env vars missing; skipping appsettings."; exit 0 } - - $webAppName = ([uri]$env:WEB_APP_URL).Host.Split('.')[0] - $apiAppName = ([uri]$env:API_APP_URL).Host.Split('.')[0] - - foreach ($appName in @($webAppName, $apiAppName)) { - if (-not $appName) { continue } - Write-Host "Updating appsettings for $appName" -ForegroundColor Yellow - az webapp config appsettings set -g $rg -n $appName --settings "DOCKER_REGISTRY_SERVER_URL=https://$acrServer" "DOCKER_REGISTRY_SERVER_USERNAME=$acrUser" "DOCKER_REGISTRY_SERVER_PASSWORD=$acrPass" | Out-Null - } - - # Ensure container images are updated to the freshly built tag - if ($acrServer -and $env:AZURE_ENV_IMAGETAG) { - $tag = $env:AZURE_ENV_IMAGETAG - if ($apiAppName) { - Write-Host "Setting API container image to $acrServer/km-api:$tag" -ForegroundColor Yellow - az webapp config container set -g $rg -n $apiAppName --docker-custom-image-name "$acrServer/km-api:$tag" | Out-Null - az webapp restart -g $rg -n $apiAppName | Out-Null - $apiFx = az webapp config container show -g $rg -n $apiAppName --query linuxFxVersion -o tsv - Write-Host "API linuxFxVersion: $apiFx" -ForegroundColor Green - } - if ($webAppName) { - Write-Host "Setting Web container image to $acrServer/km-app:$tag" -ForegroundColor Yellow - az webapp config container set -g $rg -n $webAppName --docker-custom-image-name "$acrServer/km-app:$tag" | Out-Null - az webapp restart -g $rg -n $webAppName | Out-Null - $webFx = az webapp config container show -g $rg -n $webAppName --query linuxFxVersion -o tsv - Write-Host "Web linuxFxVersion: $webFx" -ForegroundColor Green - } - } else { - Write-Warning "AZURE_ENV_CONTAINER_REGISTRY_ENDPOINT or AZURE_ENV_IMAGETAG missing; skipping container image update." - } - - Write-Host "[postprovision] App Services configured for private ACR." -ForegroundColor Green - + Write-Host "Web app URL: $env:WEB_APP_URL" -ForegroundColor Cyan + Write-Host "`nRun: bash ./infra/scripts/process_sample_data.sh" -ForegroundColor Yellow posix: shell: sh - interactive: true continueOnError: false + interactive: true run: | - set -euo pipefail - echo "[postprovision] Configuring App Services to use ACR credentials..." - rg="${RESOURCE_GROUP_NAME:-}" - acr_server="${AZURE_ENV_CONTAINER_REGISTRY_ENDPOINT:-}" - acr_user="${AZURE_ENV_ACR_USERNAME:-}" - acr_pass="${AZURE_ENV_ACR_PASSWORD:-}" - [ -z "$rg" ] && echo "RESOURCE_GROUP_NAME not set; skipping." && exit 0 - [ -z "$acr_server" ] || [ -z "$acr_user" ] || [ -z "$acr_pass" ] && echo "ACR env vars missing; skipping." && exit 0 - web_app_name=$(echo "$WEB_APP_URL" | sed -E 's#https?://([^/]+)/?.*#\1#' | cut -d'.' -f1) - api_app_name=$(echo "$API_APP_URL" | sed -E 's#https?://([^/]+)/?.*#\1#' | cut -d'.' -f1) - for app in "$web_app_name" "$api_app_name"; do - [ -z "$app" ] && continue - echo "Updating appsettings for $app" - az webapp config appsettings set -g "$rg" -n "$app" --settings \ - DOCKER_REGISTRY_SERVER_URL="https://$acr_server" \ - DOCKER_REGISTRY_SERVER_USERNAME="$acr_user" \ - DOCKER_REGISTRY_SERVER_PASSWORD="$acr_pass" >/dev/null - done - # Ensure container images are updated to the freshly built tag - if [ -n "$acr_server" ] && [ -n "${AZURE_ENV_IMAGETAG:-}" ]; then - tag="$AZURE_ENV_IMAGETAG" - if [ -n "$api_app_name" ]; then - echo "Setting API container image to $acr_server/km-api:$tag" - az webapp config container set -g "$rg" -n "$api_app_name" --docker-custom-image-name "$acr_server/km-api:$tag" >/dev/null - az webapp restart -g "$rg" -n "$api_app_name" >/dev/null - fi - if [ -n "$web_app_name" ]; then - echo "Setting Web container image to $acr_server/km-app:$tag" - az webapp config container set -g "$rg" -n "$web_app_name" --docker-custom-image-name "$acr_server/km-app:$tag" >/dev/null - az webapp restart -g "$rg" -n "$web_app_name" >/dev/null - fi - else - echo "AZURE_ENV_CONTAINER_REGISTRY_ENDPOINT or AZURE_ENV_IMAGETAG missing; skipping container image update." - fi - echo "[postprovision] App Services configured for private ACR." + echo "Web app URL: $WEB_APP_URL" + echo "" + echo "Run: bash ./infra/scripts/process_sample_data.sh" diff --git a/infra/main_custom.bicep b/infra/main_custom.bicep index 9f870872f..0f91e5e8a 100644 --- a/infra/main_custom.bicep +++ b/infra/main_custom.bicep @@ -1280,9 +1280,9 @@ module webSiteBackend 'modules/web-sites.bicep' = { name: take('module.web-sites.${backendWebSiteResourceName}', 64) params: { name: backendWebSiteResourceName - tags: tags + tags: union(tags, { 'azd-service-name': 'api' }) location: location - kind: 'app,linux,container' + kind: 'app,linux' serverFarmResourceId: webServerFarm.?outputs.resourceId managedIdentities: { systemAssigned: true @@ -1291,13 +1291,18 @@ module webSiteBackend 'modules/web-sites.bicep' = { ] } siteConfig: { - linuxFxVersion: 'DOCKER|${backendContainerRegistryHostname}/${backendContainerImageName}:${backendContainerImageTag}' + linuxFxVersion: 'PYTHON|3.11' minTlsVersion: '1.2' + alwaysOn: true + appCommandLine: 'gunicorn --bind=0.0.0.0:8000 --timeout 600 --worker-class uvicorn.workers.UvicornWorker app:app' } configs: [ { name: 'appsettings' properties: { + SCM_DO_BUILD_DURING_DEPLOYMENT: 'true' + ENABLE_ORYX_BUILD: 'true' + PYTHONUNBUFFERED: '1' REACT_APP_LAYOUT_CONFIG: reactAppLayoutConfig AZURE_OPENAI_DEPLOYMENT_MODEL: gptModelName AZURE_OPENAI_ENDPOINT: !empty(existingOpenAIEndpoint) ? existingOpenAIEndpoint : 'https://${aiFoundryAiServices.outputs.name}.openai.azure.com/' @@ -1312,7 +1317,7 @@ module webSiteBackend 'modules/web-sites.bicep' = { AZURE_COSMOSDB_DATABASE: cosmosDbDatabaseName AZURE_COSMOSDB_ENABLE_FEEDBACK: 'True' SQLDB_DATABASE: 'sqldb-${solutionSuffix}' - SQLDB_SERVER: '${sqlDBModule.outputs.name }${environment().suffixes.sqlServerHostname}' + SQLDB_SERVER: '${sqlDBModule.outputs.name}${environment().suffixes.sqlServerHostname}' SQLDB_USER_MID: backendUserAssignedIdentity.outputs.clientId AZURE_AI_SEARCH_ENDPOINT: 'https://${aiSearchName}.search.windows.net' AZURE_AI_SEARCH_INDEX: 'call_transcripts_index' @@ -1349,18 +1354,22 @@ module webSiteFrontend 'modules/web-sites.bicep' = { name: take('module.web-sites.${webSiteResourceName}', 64) params: { name: webSiteResourceName - tags: tags + tags: union(tags, { 'azd-service-name': 'webapp' }) location: location - kind: 'app,linux,container' + kind: 'app,linux' serverFarmResourceId: webServerFarm.outputs.resourceId siteConfig: { - linuxFxVersion: 'DOCKER|${frontendContainerRegistryHostname}/${frontendContainerImageName}:${frontendContainerImageTag}' + linuxFxVersion: 'NODE|18-lts' minTlsVersion: '1.2' + alwaysOn: true + appCommandLine: 'pm2 serve /home/site/wwwroot --no-daemon --spa' } configs: [ { name: 'appsettings' properties: { + SCM_DO_BUILD_DURING_DEPLOYMENT: 'false' + WEBSITE_NODE_DEFAULT_VERSION: '~18' APP_API_BASE_URL: 'https://api-${solutionSuffix}.azurewebsites.net' } applicationInsightResourceId: enableMonitoring ? applicationInsights!.outputs.resourceId : null diff --git a/src/api/requirements.txt b/src/api/requirements.txt index 6c0f3b845..afe149eb3 100644 --- a/src/api/requirements.txt +++ b/src/api/requirements.txt @@ -1,4 +1,5 @@ # Base packages +gunicorn==23.0.0 cachetools==6.2.0 python-dotenv==1.1.1 fastapi==0.118.0 From 6527e400db4290d0e5c53c869f45735ad93cc4ea Mon Sep 17 00:00:00 2001 From: Venkateswarlu Marthula Date: Tue, 30 Dec 2025 08:29:42 +0530 Subject: [PATCH 03/19] refactor: Clean up azure.yaml by removing unused infrastructure and service configurations --- azure.yaml | 48 +++++++++--------------------------------------- 1 file changed, 9 insertions(+), 39 deletions(-) diff --git a/azure.yaml b/azure.yaml index fba6b3461..ab1184489 100644 --- a/azure.yaml +++ b/azure.yaml @@ -1,3 +1,9 @@ +# # yaml-language-server: $schema=https://raw.githubusercontent.com/Azure/azure-dev/main/schemas/v1.0/azure.yaml.json +# metadata: +# template: azd-init@1.11.1 +environment: + name: conversation-knowledge-mining + location: eastus name: conversation-knowledge-mining requiredVersions: @@ -6,49 +12,13 @@ requiredVersions: metadata: template: conversation-knowledge-mining@1.0 -infra: - provider: bicep - path: infra - module: main - parameters: infra/main.parameters.json - -services: - api: - project: ./src/api - language: py - host: appservice - hooks: - prepackage: - windows: - shell: pwsh - continueOnError: false - run: pwsh ../../infra/scripts/package_webapp.ps1 - posix: - shell: sh - continueOnError: false - run: bash ../../infra/scripts/package_webapp.sh - webapp: - project: ./src/App/deploy - language: js - host: appservice - hooks: - prepackage: - windows: - shell: pwsh - continueOnError: false - run: pwsh ../../../infra/scripts/package_frontend.ps1 - posix: - shell: sh - continueOnError: false - run: bash ../../../infra/scripts/package_frontend.sh - hooks: postprovision: windows: run: | Write-Host "Web app URL: " Write-Host "$env:WEB_APP_URL" -ForegroundColor Cyan - Write-Host "`nRun the following command in your Bash terminal. It will grant the necessary permissions between resources and your user account, and also process and load the sample data into the application." + Write-Host "`nCreate and activate a virtual environment if not already done, then run the following command in your Bash terminal. It will grant the necessary permissions between resources and your user account, and also process and load the sample data into the application." Write-Host "bash ./infra/scripts/process_sample_data.sh" -ForegroundColor Cyan shell: pwsh continueOnError: false @@ -58,8 +28,8 @@ hooks: echo "Web app URL: " echo $WEB_APP_URL echo "" - echo "Run the following command in your Bash terminal. It will grant the necessary permissions between resources and your user account, and also process and load the sample data into the application." + echo "Create and activate a virtual environment if not already done, then run the following command in your Bash terminal. It will grant the necessary permissions between resources and your user account, and also process and load the sample data into the application." echo "bash ./infra/scripts/process_sample_data.sh" shell: sh continueOnError: false - interactive: true + interactive: true \ No newline at end of file From 6e81262f24ba5503c6503974caa0ae29ac8a5e3c Mon Sep 17 00:00:00 2001 From: Venkateswarlu Marthula Date: Tue, 30 Dec 2025 13:00:45 +0530 Subject: [PATCH 04/19] refactor: Simplify webapp packaging scripts and update build process for Azure deployment --- azure_custom.yaml | 34 ++--------------- infra/scripts/package_webapp.ps1 | 62 ++++++++++++++++++++++++++++++ infra/scripts/package_webapp.sh | 65 ++++++++++++++++++++++++++++++++ 3 files changed, 131 insertions(+), 30 deletions(-) create mode 100644 infra/scripts/package_webapp.ps1 create mode 100644 infra/scripts/package_webapp.sh diff --git a/azure_custom.yaml b/azure_custom.yaml index e70b3c41a..2a525b642 100644 --- a/azure_custom.yaml +++ b/azure_custom.yaml @@ -26,40 +26,14 @@ services: prepackage: windows: shell: pwsh + run: ../../infra/scripts/package_webapp.ps1 + interactive: true continueOnError: false - run: | - Write-Host "=== React Build Starting ===" -ForegroundColor Cyan - Write-Host "Current directory: $(Get-Location)" -ForegroundColor Yellow - $apiUrl = azd env get-value API_APP_URL - if (-not $apiUrl) { - Write-Error "API_APP_URL not found. Run 'azd provision' first." - exit 1 - } - Write-Host "Setting REACT_APP_API_BASE_URL=$apiUrl" -ForegroundColor Yellow - $env:REACT_APP_API_BASE_URL = $apiUrl - Write-Host "Installing dependencies..." -ForegroundColor Cyan - npm install - Write-Host "Building React app..." -ForegroundColor Cyan - npm run build - Write-Host "=== React Build Complete ===" -ForegroundColor Green posix: shell: sh + run: bash ../../infra/scripts/package_webapp.sh + interactive: true continueOnError: false - run: | - echo "=== React Build Starting ===" - echo "Current directory: $(pwd)" - apiUrl=$(azd env get-value API_APP_URL) - if [ -z "$apiUrl" ]; then - echo "ERROR: API_APP_URL not found. Run 'azd provision' first." - exit 1 - fi - echo "Setting REACT_APP_API_BASE_URL=$apiUrl" - export REACT_APP_API_BASE_URL="$apiUrl" - echo "Installing dependencies..." - npm install - echo "Building React app..." - npm run build - echo "=== React Build Complete ===" hooks: postprovision: diff --git a/infra/scripts/package_webapp.ps1 b/infra/scripts/package_webapp.ps1 new file mode 100644 index 000000000..d24ccaa23 --- /dev/null +++ b/infra/scripts/package_webapp.ps1 @@ -0,0 +1,62 @@ +#!/usr/bin/env pwsh + +# Package React webapp for Azure App Service deployment +# This script builds the React frontend with dynamic API URL injection +# Run from workspace root OR from src/App (auto-detects location) + +Write-Host "=== React App Build Started ===" -ForegroundColor Cyan + +$ErrorActionPreference = "Stop" + +# Detect if we're in src/App or workspace root and navigate accordingly +if (Test-Path "package.json") { + # Already in src/App + Write-Host "Running from src/App directory" -ForegroundColor Gray +} elseif (Test-Path "src/App/package.json") { + # In workspace root, navigate to src/App + Write-Host "Navigating to src/App directory" -ForegroundColor Gray + Set-Location -Path "src/App" +} else { + Write-Error "Cannot find React app. Run from workspace root or src/App directory." + exit 1 +} + +# Clean old build folder to ensure fresh build +if (Test-Path "build") { + Write-Host "Cleaning old build folder..." -ForegroundColor Yellow + Remove-Item -Path "build" -Recurse -Force +} + +# Get the API URL from azd environment +Write-Host "Fetching API URL from azd environment..." -ForegroundColor Yellow +$apiUrl = azd env get-value API_APP_URL + +if (-not $apiUrl) { + Write-Error "API_APP_URL not found in azd environment. Run 'azd provision' first." + exit 1 +} + +Write-Host "API URL: $apiUrl" -ForegroundColor Green + +# Set environment variable for React build +$env:REACT_APP_API_BASE_URL = $apiUrl +Write-Host "Set REACT_APP_API_BASE_URL=$apiUrl" -ForegroundColor Yellow + +# Install dependencies +Write-Host "`nInstalling npm dependencies..." -ForegroundColor Cyan +npm install +if ($LASTEXITCODE -ne 0) { + Write-Error "npm install failed" + exit 1 +} + +# Build React app +Write-Host "`nBuilding React application..." -ForegroundColor Cyan +npm run build +if ($LASTEXITCODE -ne 0) { + Write-Error "npm run build failed" + exit 1 +} + +Write-Host "`n=== React App Build Complete ===" -ForegroundColor Green +Write-Host "Built files are in ./build directory" -ForegroundColor Gray diff --git a/infra/scripts/package_webapp.sh b/infra/scripts/package_webapp.sh new file mode 100644 index 000000000..9fe768267 --- /dev/null +++ b/infra/scripts/package_webapp.sh @@ -0,0 +1,65 @@ +#!/bin/bash + +# Package React webapp for Azure App Service deployment +# This script builds the React frontend with dynamic API URL injection +# Run from workspace root OR from src/App (auto-detects location) + +set -e + +echo "=== React App Build Started ===" + +# Detect if we're in src/App or workspace root and navigate accordingly +if [ -f "package.json" ]; then + # Already in src/App + echo "Running from src/App directory" +elif [ -f "src/App/package.json" ]; then + # In workspace root, navigate to src/App + echo "Navigating to src/App directory" + cd src/App +else + echo "ERROR: Cannot find React app. Run from workspace root or src/App directory." + exit 1 +fi + +# Clean old build folder to ensure fresh build +if [ -d "build" ]; then + echo "Cleaning old build folder..." + rm -rf build +fi + +# Get the API URL from azd environment +echo "Fetching API URL from azd environment..." +apiUrl=$(azd env get-value API_APP_URL) + +if [ -z "$apiUrl" ]; then + echo "ERROR: API_APP_URL not found in azd environment. Run 'azd provision' first." + exit 1 +fi + +echo "API URL: $apiUrl" + +# Set environment variable for React build +export REACT_APP_API_BASE_URL="$apiUrl" +echo "Set REACT_APP_API_BASE_URL=$apiUrl" + +# Install dependencies +echo "" +echo "Installing npm dependencies..." +npm install +if [ $? -ne 0 ]; then + echo "ERROR: npm install failed" + exit 1 +fi + +# Build React app +echo "" +echo "Building React application..." +npm run build +if [ $? -ne 0 ]; then + echo "ERROR: npm run build failed" + exit 1 +fi + +echo "" +echo "=== React App Build Complete ===" +echo "Built files are in ./build directory" From bcc518f4634c384f449c02e12438b172f9f03593 Mon Sep 17 00:00:00 2001 From: Venkateswarlu Marthula Date: Tue, 30 Dec 2025 13:15:30 +0530 Subject: [PATCH 05/19] docs: Add instructions for deploying local changes in Deployment Guide --- documents/DeploymentGuide.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/documents/DeploymentGuide.md b/documents/DeploymentGuide.md index 0a772d9d4..0b977cb01 100644 --- a/documents/DeploymentGuide.md +++ b/documents/DeploymentGuide.md @@ -306,6 +306,15 @@ Once you've opened the project in [Codespaces](#github-codespaces), [Dev Contain 10. You can now delete the resources by running `azd down`, if you are done trying out the application. > **Note:** If you deployed with `enableRedundancy=true` and Log Analytics workspace replication is enabled, you must first disable replication before running `azd down` else resource group delete will fail. Follow the steps in [Handling Log Analytics Workspace Deletion with Replication Enabled](./LogAnalyticsReplicationDisable.md), wait until replication returns `false`, then run `azd down`. +### Deploy Your Local Changes + +To deploy your local changes, rename the below files: + +1. Rename `azure.yaml` to `azure_custom2.yaml` and `azure_custom.yaml` to `azure.yaml`. + +2. Go to `infra` directory: + - Rename `main.bicep` to `main_custom2.bicep` and `main_custom.bicep` to `main.bicep`. Continue with the [deploying steps](#deployment-steps-🚀). + ### 🛠️ Troubleshooting If you encounter any issues during the deployment process, please refer [troubleshooting](../documents/TroubleShootingSteps.md) document for detailed steps and solutions From 3ce0aa79e5fba9e25743c229254d30f1aeeebbee Mon Sep 17 00:00:00 2001 From: Venkateswarlu Marthula Date: Tue, 30 Dec 2025 16:54:50 +0530 Subject: [PATCH 06/19] refactor: Update webapp packaging scripts to write API URL to .env.production.local --- azure_custom.yaml | 2 -- infra/scripts/package_webapp.ps1 | 6 +++--- infra/scripts/package_webapp.sh | 6 +++--- src/App/.env | 3 ++- 4 files changed, 8 insertions(+), 9 deletions(-) diff --git a/azure_custom.yaml b/azure_custom.yaml index 2a525b642..d21ccce39 100644 --- a/azure_custom.yaml +++ b/azure_custom.yaml @@ -27,12 +27,10 @@ services: windows: shell: pwsh run: ../../infra/scripts/package_webapp.ps1 - interactive: true continueOnError: false posix: shell: sh run: bash ../../infra/scripts/package_webapp.sh - interactive: true continueOnError: false hooks: diff --git a/infra/scripts/package_webapp.ps1 b/infra/scripts/package_webapp.ps1 index d24ccaa23..dd065ab2c 100644 --- a/infra/scripts/package_webapp.ps1 +++ b/infra/scripts/package_webapp.ps1 @@ -38,9 +38,9 @@ if (-not $apiUrl) { Write-Host "API URL: $apiUrl" -ForegroundColor Green -# Set environment variable for React build -$env:REACT_APP_API_BASE_URL = $apiUrl -Write-Host "Set REACT_APP_API_BASE_URL=$apiUrl" -ForegroundColor Yellow +# Write API URL to .env.production.local (React reads this during build) +"REACT_APP_API_BASE_URL=$apiUrl" | Out-File -FilePath ".env.production.local" -Encoding utf8 +Write-Host "Created .env.production.local with API URL" -ForegroundColor Green # Install dependencies Write-Host "`nInstalling npm dependencies..." -ForegroundColor Cyan diff --git a/infra/scripts/package_webapp.sh b/infra/scripts/package_webapp.sh index 9fe768267..8c158692c 100644 --- a/infra/scripts/package_webapp.sh +++ b/infra/scripts/package_webapp.sh @@ -38,9 +38,9 @@ fi echo "API URL: $apiUrl" -# Set environment variable for React build -export REACT_APP_API_BASE_URL="$apiUrl" -echo "Set REACT_APP_API_BASE_URL=$apiUrl" +# Write API URL to .env.production.local (React reads this during build) +echo "REACT_APP_API_BASE_URL=$apiUrl" > .env.production.local +echo "Created .env.production.local with API URL" # Install dependencies echo "" diff --git a/src/App/.env b/src/App/.env index 23594cbba..fb6a157cd 100644 --- a/src/App/.env +++ b/src/App/.env @@ -1 +1,2 @@ -REACT_APP_API_BASE_URL=APP_API_BASE_URL \ No newline at end of file +# REACT_APP_API_BASE_URL is set dynamically during build by azd prepackage hook +# Do not set it here - it will be injected from azd environment via .env.production.local From 0328702b97179347ad9c9b846cc6150d20aff48b Mon Sep 17 00:00:00 2001 From: Venkateswarlu Marthula Date: Tue, 30 Dec 2025 16:58:31 +0530 Subject: [PATCH 07/19] refactor: Update .env file to set REACT_APP_API_BASE_URL directly --- src/App/.env | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/App/.env b/src/App/.env index fb6a157cd..23594cbba 100644 --- a/src/App/.env +++ b/src/App/.env @@ -1,2 +1 @@ -# REACT_APP_API_BASE_URL is set dynamically during build by azd prepackage hook -# Do not set it here - it will be injected from azd environment via .env.production.local +REACT_APP_API_BASE_URL=APP_API_BASE_URL \ No newline at end of file From a23b5ea2b276ad847e6b1a2c349ddf684539e06b Mon Sep 17 00:00:00 2001 From: Vemarthula-Microsoft Date: Wed, 31 Dec 2025 15:53:25 +0530 Subject: [PATCH 08/19] Change roleName to Custom Data Contributor with unique string --- infra/main_custom.bicep | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/infra/main_custom.bicep b/infra/main_custom.bicep index 0f91e5e8a..ca5acad51 100644 --- a/infra/main_custom.bicep +++ b/infra/main_custom.bicep @@ -1037,7 +1037,7 @@ module cosmosDb 'br/public:avm/res/document-db/database-account:0.15.0' = { dataPlaneRoleDefinitions: [ { // Cosmos DB Built-in Data Contributor: https://docs.azure.cn/en-us/cosmos-db/nosql/security/reference-data-plane-roles#cosmos-db-built-in-data-contributor - roleName: 'Cosmos DB SQL Data Contributor' + roleName: 'Custom Data Contributor-${uniqueString(cosmosDbResourceName, resourceGroup().id)}' dataActions: [ 'Microsoft.DocumentDB/databaseAccounts/readMetadata' 'Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers/*' From b4a492f7ded490a5f9026b27a40b68fb41c3cd99 Mon Sep 17 00:00:00 2001 From: Vemarthula-Microsoft Date: Fri, 2 Jan 2026 17:51:49 +0530 Subject: [PATCH 09/19] Refine parameter descriptions in main_custom.bicep Updated parameter descriptions for clarity and consistency. --- infra/main_custom.bicep | 302 ++++++++++++++++++++++------------------ 1 file changed, 169 insertions(+), 133 deletions(-) diff --git a/infra/main_custom.bicep b/infra/main_custom.bicep index ca5acad51..3d38f6d85 100644 --- a/infra/main_custom.bicep +++ b/infra/main_custom.bicep @@ -3,7 +3,7 @@ targetScope = 'resourceGroup' @minLength(3) @maxLength(16) -@description('Required. A unique prefix for all resources in this deployment. This should be 3-20 characters long:') +@description('Optional. A unique prefix for all resources in this deployment. This should be 3-20 characters long.') param solutionName string = 'kmgen' @metadata({ azd: { type: 'location' } }) @@ -44,15 +44,15 @@ param location string param aiServiceLocation string @minLength(1) -@description('Required. Industry use case for deployment:') +@description('Required. Industry use case for deployment.') @allowed([ 'telecom' 'IT_helpdesk' ]) -param usecase string +param usecase string @minLength(1) -@description('Optional. Location for the Content Understanding service deployment:') +@description('Optional. Location for the Content Understanding service deployment.') @allowed(['swedencentral', 'australiaeast']) @metadata({ azd: { @@ -62,24 +62,24 @@ param usecase string param contentUnderstandingLocation string = 'swedencentral' @minLength(1) -@description('Optional. Secondary location for databases creation(example:eastus2):') +@description('Optional. Secondary location for databases creation (example: eastus2).') param secondaryLocation string = 'eastus2' @minLength(1) -@description('Optional. GPT model deployment type:') +@description('Optional. GPT model deployment type.') @allowed([ 'Standard' 'GlobalStandard' ]) param deploymentType string = 'GlobalStandard' -@description('Optional. Name of the GPT model to deploy:') +@description('Optional. Name of the GPT model to deploy.') param gptModelName string = 'gpt-4o-mini' -@description('Optional. Version of the GPT model to deploy:') +@description('Optional. Version of the GPT model to deploy.') param gptModelVersion string = '2024-07-18' -@description('Optional. Version of the OpenAI.') +@description('Optional. Version of the Azure OpenAI API.') param azureOpenAIApiVersion string = '2025-01-01-preview' @description('Optional. Version of AI Agent API.') @@ -91,11 +91,11 @@ param azureContentUnderstandingApiVersion string = '2024-12-01-preview' // You can increase this, but capacity is limited per model/region, so you will get errors if you go over // https://learn.microsoft.com/en-us/azure/ai-services/openai/quotas-limits @minValue(10) -@description('Optional. Capacity of the GPT deployment:') +@description('Optional. Capacity of the GPT deployment.') param gptDeploymentCapacity int = 150 @minLength(1) -@description('Optional. Name of the Text Embedding model to deploy:') +@description('Optional. Name of the Text Embedding model to deploy.') @allowed([ 'text-embedding-ada-002' ]) @@ -133,7 +133,7 @@ param enablePrivateNetworking bool = false param enableTelemetry bool = true @description('Optional. Enable monitoring applicable resources, aligned with the Well Architected Framework recommendations. This setting enables Application Insights and Log Analytics and configures all the resources applicable resources to send logs. Defaults to false.') -param enableMonitoring bool = false +param enableMonitoring bool = false @description('Optional. Enable redundancy for applicable resources, aligned with the Well Architected Framework recommendations. Defaults to false.') param enableRedundancy bool = false @@ -158,9 +158,10 @@ param existingLogAnalyticsWorkspaceId string = '' @description('Optional. Use this parameter to use an existing AI project resource ID') param existingAiFoundryAiProjectResourceId string = '' -@description('Optional. created by user name') -param createdBy string = contains(deployer(), 'userPrincipalName')? split(deployer().userPrincipalName, '@')[0]: deployer().objectId - +@description('Optional. Created by user name.') +param createdBy string = contains(deployer(), 'userPrincipalName') + ? split(deployer().userPrincipalName, '@')[0] + : deployer().objectId @maxLength(5) @description('Optional. A unique text value for the solution. This is used to ensure resource names are unique for global resources. Defaults to a 5-character substring of the unique string generated from the subscription ID, resource group name, and solution name.') @@ -191,6 +192,7 @@ var replicaRegionPairs = { westeurope: 'northeurope' } var replicaLocation = replicaRegionPairs[resourceGroup().location] + // Region pairs list based on article in [Azure Database for MySQL Flexible Server - Azure Regions](https://learn.microsoft.com/azure/mysql/flexible-server/overview#azure-regions) for supported high availability regions for CosmosDB. var cosmosDbZoneRedundantHaRegionPairs = { australiaeast: 'uksouth' //'southeastasia' @@ -212,16 +214,13 @@ var useExistingLogAnalytics = !empty(existingLogAnalyticsWorkspaceId) var logAnalyticsWorkspaceResourceId = useExistingLogAnalytics ? existingLogAnalyticsWorkspaceId : logAnalyticsWorkspace!.outputs.resourceId + // ========== Resource Group Tag ========== // resource resourceGroupTags 'Microsoft.Resources/tags@2021-04-01' = { name: 'default' properties: { tags: union( - reference( - resourceGroup().id, - '2021-04-01', - 'Full' - ).tags ?? {}, + reference(resourceGroup().id, '2021-04-01', 'Full').tags ?? {}, { TemplateName: 'KM-Generic' Type: enablePrivateNetworking ? 'WAF' : 'Non-WAF' @@ -434,9 +433,10 @@ module jumpboxVM 'br/public:avm/res/compute/virtual-machine:0.15.0' = if (enable ] } ] - enableTelemetry: enableTelemetry + enableTelemetry: enableTelemetry } } + // ========== Private DNS Zones ========== // var privateDnsZones = [ 'privatelink.cognitiveservices.azure.com' @@ -450,6 +450,7 @@ var privateDnsZones = [ 'privatelink${environment().suffixes.sqlServerHostname}' 'privatelink.search.windows.net' ] + // DNS Zone Index Constants var dnsZoneIndex = { cognitiveServices: 0 @@ -487,9 +488,9 @@ module avmPrivateDnsZones 'br/public:avm/res/network/private-dns-zone:0.7.1' = [ } ] -// ========== AVM WAF ========== // -// ========== User Assigned Identity ========== // // WAF best practices for identity and access management: https://learn.microsoft.com/en-us/azure/well-architected/security/identity-access + +// ========== User Assigned Identity ========== // var userAssignedIdentityResourceName = 'id-${solutionSuffix}' module userAssignedIdentity 'br/public:avm/res/managed-identity/user-assigned-identity:0.4.1' = { name: take('avm.res.managed-identity.user-assigned-identity.${userAssignedIdentityResourceName}', 64) @@ -519,10 +520,22 @@ module backendUserAssignedIdentity 'br/public:avm/res/managed-identity/user-assi // ========== AI Foundry: AI Services ========== // // WAF best practices for Open AI: https://learn.microsoft.com/en-us/azure/well-architected/service-guides/azure-openai -var existingOpenAIEndpoint = !empty(existingAiFoundryAiProjectResourceId) ? format('https://{0}.openai.azure.com/', split(existingAiFoundryAiProjectResourceId, '/')[8]) : '' -var existingProjEndpoint = !empty(existingAiFoundryAiProjectResourceId) ? format('https://{0}.services.ai.azure.com/api/projects/{1}', split(existingAiFoundryAiProjectResourceId, '/')[8], split(existingAiFoundryAiProjectResourceId, '/')[10]) : '' -var existingAIServicesName = !empty(existingAiFoundryAiProjectResourceId) ? split(existingAiFoundryAiProjectResourceId, '/')[8] : '' -var existingAIProjectName = !empty(existingAiFoundryAiProjectResourceId) ? split(existingAiFoundryAiProjectResourceId, '/')[10] : '' +var existingOpenAIEndpoint = !empty(existingAiFoundryAiProjectResourceId) + ? format('https://{0}.openai.azure.com/', split(existingAiFoundryAiProjectResourceId, '/')[8]) + : '' +var existingProjEndpoint = !empty(existingAiFoundryAiProjectResourceId) + ? format( + 'https://{0}.services.ai.azure.com/api/projects/{1}', + split(existingAiFoundryAiProjectResourceId, '/')[8], + split(existingAiFoundryAiProjectResourceId, '/')[10] + ) + : '' +var existingAIServicesName = !empty(existingAiFoundryAiProjectResourceId) + ? split(existingAiFoundryAiProjectResourceId, '/')[8] + : '' +var existingAIProjectName = !empty(existingAiFoundryAiProjectResourceId) + ? split(existingAiFoundryAiProjectResourceId, '/')[10] + : '' var aiFoundryAiServicesSubscriptionId = useExistingAiFoundryAiProject ? split(existingAiFoundryAiProjectResourceId, '/')[2] @@ -536,7 +549,7 @@ var aiFoundryAiServicesResourceName = useExistingAiFoundryAiProject : 'aif-${solutionSuffix}' var aiFoundryAiProjectResourceName = useExistingAiFoundryAiProject ? split(existingAiFoundryAiProjectResourceId, '/')[10] - : 'proj-${solutionSuffix}' + : 'proj-${solutionSuffix}' // NOTE: Required version 'Microsoft.CognitiveServices/accounts@2024-04-01-preview' not available in AVM // var aiFoundryAiServicesResourceName = 'aif-${solutionSuffix}' @@ -577,8 +590,6 @@ resource existingAiFoundryAiServicesProject 'Microsoft.CognitiveServices/account parent: existingAiFoundryAiServices } - -//TODO: update to AVM module when AI Projects and AI Projects RBAC are supported module aiFoundryAiServices 'modules/ai-services.bicep' = if (aiFoundryAIservicesEnabled) { name: take('avm.res.cognitive-services.account.${aiFoundryAiServicesResourceName}', 64) params: { @@ -637,7 +648,7 @@ module aiFoundryAiServices 'modules/ai-services.bicep' = if (aiFoundryAIservices // WAF aligned configuration for Monitoring diagnosticSettings: enableMonitoring ? [{ workspaceResourceId: logAnalyticsWorkspaceResourceId }] : null publicNetworkAccess: enablePrivateNetworking ? 'Disabled' : 'Enabled' - privateEndpoints: (enablePrivateNetworking && empty(existingAiFoundryAiProjectResourceId)) + privateEndpoints: (enablePrivateNetworking && empty(existingAiFoundryAiProjectResourceId)) ? ([ { name: 'pep-${aiFoundryAiServicesResourceName}' @@ -767,11 +778,13 @@ module searchSearchServices 'br/public:avm/res/search/search-service:0.11.1' = { aadAuthFailureMode: 'http401WithBearerChallenge' } } - diagnosticSettings: enableMonitoring ? [ - { - workspaceResourceId: logAnalyticsWorkspaceResourceId - } - ] : null + diagnosticSettings: enableMonitoring + ? [ + { + workspaceResourceId: logAnalyticsWorkspaceResourceId + } + ] + : null disableLocalAuth: false hostingMode: 'default' managedIdentities: { @@ -809,12 +822,16 @@ module searchSearchServices 'br/public:avm/res/search/search-service:0.11.1' = { } { roleDefinitionIdOrName: '1407120a-92aa-4202-b7e9-c0e197c71c8f' // Search Index Data Reader - principalId: !useExistingAiFoundryAiProject ? aiFoundryAiServices.outputs.aiProjectInfo.aiprojectSystemAssignedMIPrincipalId : existingAiFoundryAiServicesProject!.identity.principalId + principalId: !useExistingAiFoundryAiProject + ? aiFoundryAiServices.outputs.aiProjectInfo.aiprojectSystemAssignedMIPrincipalId + : existingAiFoundryAiServicesProject!.identity.principalId principalType: 'ServicePrincipal' } { roleDefinitionIdOrName: '7ca78c08-252a-4471-8644-bb5ff32d4ba0' // Search Service Contributor - principalId: !useExistingAiFoundryAiProject ? aiFoundryAiServices.outputs.aiProjectInfo.aiprojectSystemAssignedMIPrincipalId : existingAiFoundryAiServicesProject!.identity.principalId + principalId: !useExistingAiFoundryAiProject + ? aiFoundryAiServices.outputs.aiProjectInfo.aiprojectSystemAssignedMIPrincipalId + : existingAiFoundryAiServicesProject!.identity.principalId principalType: 'ServicePrincipal' } ] @@ -826,34 +843,37 @@ module searchSearchServices 'br/public:avm/res/search/search-service:0.11.1' = { tags: tags publicNetworkAccess: 'Enabled' //enablePrivateNetworking ? 'Disabled' : 'Enabled' privateEndpoints: false //enablePrivateNetworking - ? [ - { - name: 'pep-${aiSearchName}' - customNetworkInterfaceName: 'nic-${aiSearchName}' - privateDnsZoneGroup: { - privateDnsZoneGroupConfigs: [ - { privateDnsZoneResourceId: avmPrivateDnsZones[dnsZoneIndex.search]!.outputs.resourceId } - ] + ? [ + { + name: 'pep-${aiSearchName}' + customNetworkInterfaceName: 'nic-${aiSearchName}' + privateDnsZoneGroup: { + privateDnsZoneGroupConfigs: [ + { privateDnsZoneResourceId: avmPrivateDnsZones[dnsZoneIndex.search]!.outputs.resourceId } + ] + } + service: 'searchService' + subnetResourceId: virtualNetwork!.outputs.pepsSubnetResourceId } - service: 'searchService' - subnetResourceId: virtualNetwork!.outputs.pepsSubnetResourceId - } - ] - : [] + ] + : [] } } // ========== Search Service to AI Services Role Assignment ========== // -resource searchServiceToAiServicesRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (!useExistingAiFoundryAiProject){ +resource searchServiceToAiServicesRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (!useExistingAiFoundryAiProject) { name: guid(aiSearchName, '5e0bd9bd-7b93-4f28-af87-19fc36ad61bd', aiFoundryAiServicesResourceName) properties: { - roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '5e0bd9bd-7b93-4f28-af87-19fc36ad61bd') // Cognitive Services OpenAI User + roleDefinitionId: subscriptionResourceId( + 'Microsoft.Authorization/roleDefinitions', + '5e0bd9bd-7b93-4f28-af87-19fc36ad61bd' + ) // Cognitive Services OpenAI User principalId: searchSearchServices.outputs.systemAssignedMIPrincipalId! principalType: 'ServicePrincipal' } } -resource projectAISearchConnection 'Microsoft.CognitiveServices/accounts/projects/connections@2025-04-01-preview' = if (!useExistingAiFoundryAiProject){ +resource projectAISearchConnection 'Microsoft.CognitiveServices/accounts/projects/connections@2025-04-01-preview' = if (!useExistingAiFoundryAiProject) { name: '${aiFoundryAiServicesResourceName}/${aiFoundryAiServicesAiProjectResourceName}/${aiSearchName}' properties: { category: 'CognitiveSearch' @@ -892,7 +912,6 @@ module searchServiceToExistingAiServicesRoleAssignment 'modules/role-assignment. } } -// ========== AVM WAF ========== // // ========== Storage account module ========== // var storageAccountName = 'st${solutionSuffix}' module storageAccount 'br/public:avm/res/storage/storage-account:0.20.0' = { @@ -900,9 +919,9 @@ module storageAccount 'br/public:avm/res/storage/storage-account:0.20.0' = { params: { name: storageAccountName location: location - managedIdentities: { + managedIdentities: { systemAssigned: true - userAssignedResourceIds: [ userAssignedIdentity!.outputs.resourceId ] + userAssignedResourceIds: [userAssignedIdentity!.outputs.resourceId] } minimumTlsVersion: 'TLS1_2' supportsHttpsTrafficOnly: true @@ -935,60 +954,62 @@ module storageAccount 'br/public:avm/res/storage/storage-account:0.20.0' = { allowSharedKeyAccess: true allowBlobPublicAccess: true publicNetworkAccess: enablePrivateNetworking ? 'Disabled' : 'Enabled' - privateEndpoints: enablePrivateNetworking ? [ - { - name: 'pep-blob-${solutionSuffix}' - service: 'blob' - subnetResourceId: virtualNetwork!.outputs.pepsSubnetResourceId - privateDnsZoneGroup: { - privateDnsZoneGroupConfigs: [ - { - name: 'storage-dns-zone-group-blob' - privateDnsZoneResourceId: avmPrivateDnsZones[dnsZoneIndex.storageBlob]!.outputs.resourceId + privateEndpoints: enablePrivateNetworking + ? [ + { + name: 'pep-blob-${solutionSuffix}' + service: 'blob' + subnetResourceId: virtualNetwork!.outputs.pepsSubnetResourceId + privateDnsZoneGroup: { + privateDnsZoneGroupConfigs: [ + { + name: 'storage-dns-zone-group-blob' + privateDnsZoneResourceId: avmPrivateDnsZones[dnsZoneIndex.storageBlob]!.outputs.resourceId + } + ] } - ] - } - } - { - name: 'pep-queue-${solutionSuffix}' - service: 'queue' - subnetResourceId: virtualNetwork!.outputs.pepsSubnetResourceId - privateDnsZoneGroup: { - privateDnsZoneGroupConfigs: [ - { - name: 'storage-dns-zone-group-queue' - privateDnsZoneResourceId: avmPrivateDnsZones[dnsZoneIndex.storageQueue]!.outputs.resourceId + } + { + name: 'pep-queue-${solutionSuffix}' + service: 'queue' + subnetResourceId: virtualNetwork!.outputs.pepsSubnetResourceId + privateDnsZoneGroup: { + privateDnsZoneGroupConfigs: [ + { + name: 'storage-dns-zone-group-queue' + privateDnsZoneResourceId: avmPrivateDnsZones[dnsZoneIndex.storageQueue]!.outputs.resourceId + } + ] } - ] - } - } - { - name: 'pep-file-${solutionSuffix}' - service: 'file' - subnetResourceId: virtualNetwork!.outputs.pepsSubnetResourceId - privateDnsZoneGroup: { - privateDnsZoneGroupConfigs: [ - { - name: 'storage-dns-zone-group-file' - privateDnsZoneResourceId: avmPrivateDnsZones[dnsZoneIndex.storageFile]!.outputs.resourceId + } + { + name: 'pep-file-${solutionSuffix}' + service: 'file' + subnetResourceId: virtualNetwork!.outputs.pepsSubnetResourceId + privateDnsZoneGroup: { + privateDnsZoneGroupConfigs: [ + { + name: 'storage-dns-zone-group-file' + privateDnsZoneResourceId: avmPrivateDnsZones[dnsZoneIndex.storageFile]!.outputs.resourceId + } + ] } - ] - } - } - { - name: 'pep-dfs-${solutionSuffix}' - service: 'dfs' - subnetResourceId: virtualNetwork!.outputs.pepsSubnetResourceId - privateDnsZoneGroup: { - privateDnsZoneGroupConfigs: [ - { - name: 'storage-dns-zone-group-dfs' - privateDnsZoneResourceId: avmPrivateDnsZones[dnsZoneIndex.storageDfs]!.outputs.resourceId + } + { + name: 'pep-dfs-${solutionSuffix}' + service: 'dfs' + subnetResourceId: virtualNetwork!.outputs.pepsSubnetResourceId + privateDnsZoneGroup: { + privateDnsZoneGroupConfigs: [ + { + name: 'storage-dns-zone-group-dfs' + privateDnsZoneResourceId: avmPrivateDnsZones[dnsZoneIndex.storageDfs]!.outputs.resourceId + } + ] } - ] - } - } - ] : [] + } + ] + : [] blobServices: { corsRules: [] deleteRetentionPolicyEnabled: false @@ -1008,7 +1029,6 @@ module storageAccount 'br/public:avm/res/storage/storage-account:0.20.0' = { } } -//========== AVM WAF ========== // //========== Cosmos DB module ========== // var cosmosDbResourceName = 'cosmos-${solutionSuffix}' var cosmosDbDatabaseName = 'db_conversation_history' @@ -1096,7 +1116,6 @@ module cosmosDb 'br/public:avm/res/document-db/database-account:0.15.0' = { dependsOn: [storageAccount] } -//========== AVM WAF ========== // //========== SQL Database module ========== // var sqlServerResourceName = 'sql-${solutionSuffix}' var sqlDbModuleName = 'sqldb-${solutionSuffix}' @@ -1118,9 +1137,7 @@ module sqlDBModule 'br/public:avm/res/sql/server:0.20.1' = { { availabilityZone: enableRedundancy ? 1 : -1 collation: 'SQL_Latin1_General_CP1_CI_AS' - diagnosticSettings: enableMonitoring - ? [{ workspaceResourceId: logAnalyticsWorkspaceResourceId }] - : null + diagnosticSettings: enableMonitoring ? [{ workspaceResourceId: logAnalyticsWorkspaceResourceId }] : null licenseType: 'LicenseIncluded' maxSizeBytes: 34359738368 name: sqlDbModuleName @@ -1144,18 +1161,20 @@ module sqlDBModule 'br/public:avm/res/sql/server:0.20.1' = { } primaryUserAssignedIdentityResourceId: userAssignedIdentity.outputs.resourceId publicNetworkAccess: enablePrivateNetworking ? 'Disabled' : 'Enabled' - firewallRules: (!enablePrivateNetworking) ? [ - { - endIpAddress: '255.255.255.255' - name: 'AllowSpecificRange' - startIpAddress: '0.0.0.0' - } - { - endIpAddress: '0.0.0.0' - name: 'AllowAllWindowsAzureIps' - startIpAddress: '0.0.0.0' - } - ] : [] + firewallRules: (!enablePrivateNetworking) + ? [ + { + endIpAddress: '255.255.255.255' + name: 'AllowSpecificRange' + startIpAddress: '0.0.0.0' + } + { + endIpAddress: '0.0.0.0' + name: 'AllowAllWindowsAzureIps' + startIpAddress: '0.0.0.0' + } + ] + : [] tags: tags } } @@ -1212,7 +1231,7 @@ module webServerFarm 'br/public:avm/res/web/serverfarm:0.5.0' = { } } -var reactAppLayoutConfig ='''{ +var reactAppLayoutConfig = '''{ "appConfig": { "THREE_COLUMN": { "DASHBOARD": 50, @@ -1275,6 +1294,8 @@ var reactAppLayoutConfig ='''{ } ] }''' + +// ========== Web App module ========== // var backendWebSiteResourceName = 'api-${solutionSuffix}' module webSiteBackend 'modules/web-sites.bicep' = { name: take('module.web-sites.${backendWebSiteResourceName}', 64) @@ -1305,10 +1326,14 @@ module webSiteBackend 'modules/web-sites.bicep' = { PYTHONUNBUFFERED: '1' REACT_APP_LAYOUT_CONFIG: reactAppLayoutConfig AZURE_OPENAI_DEPLOYMENT_MODEL: gptModelName - AZURE_OPENAI_ENDPOINT: !empty(existingOpenAIEndpoint) ? existingOpenAIEndpoint : 'https://${aiFoundryAiServices.outputs.name}.openai.azure.com/' + AZURE_OPENAI_ENDPOINT: !empty(existingOpenAIEndpoint) + ? existingOpenAIEndpoint + : 'https://${aiFoundryAiServices.outputs.name}.openai.azure.com/' AZURE_OPENAI_API_VERSION: azureOpenAIApiVersion AZURE_OPENAI_RESOURCE: aiFoundryAiServices.outputs.name - AZURE_AI_AGENT_ENDPOINT: !empty(existingProjEndpoint) ? existingProjEndpoint : aiFoundryAiServices.outputs.aiProjectInfo.apiEndpoint + AZURE_AI_AGENT_ENDPOINT: !empty(existingProjEndpoint) + ? existingProjEndpoint + : aiFoundryAiServices.outputs.aiProjectInfo.apiEndpoint AZURE_AI_AGENT_API_VERSION: azureAiAgentApiVersion AZURE_AI_AGENT_MODEL_DEPLOYMENT_NAME: gptModelName USE_CHAT_HISTORY_ENABLED: 'True' @@ -1317,7 +1342,7 @@ module webSiteBackend 'modules/web-sites.bicep' = { AZURE_COSMOSDB_DATABASE: cosmosDbDatabaseName AZURE_COSMOSDB_ENABLE_FEEDBACK: 'True' SQLDB_DATABASE: 'sqldb-${solutionSuffix}' - SQLDB_SERVER: '${sqlDBModule.outputs.name}${environment().suffixes.sqlServerHostname}' + SQLDB_SERVER: '${sqlDBModule.outputs.name }${environment().suffixes.sqlServerHostname}' SQLDB_USER_MID: backendUserAssignedIdentity.outputs.clientId AZURE_AI_SEARCH_ENDPOINT: 'https://${aiSearchName}.search.windows.net' AZURE_AI_SEARCH_INDEX: 'call_transcripts_index' @@ -1383,6 +1408,7 @@ module webSiteFrontend 'modules/web-sites.bicep' = { } } +// ========== Outputs ========== // @description('Contains Solution Name.') output SOLUTION_NAME string = solutionSuffix @@ -1402,16 +1428,22 @@ output AZURE_CONTENT_UNDERSTANDING_LOCATION string = contentUnderstandingLocatio output APPINSIGHTS_INSTRUMENTATIONKEY string = enableMonitoring ? applicationInsights!.outputs.instrumentationKey : '' @description('Contains AI Project Connection String.') -output AZURE_AI_PROJECT_CONN_STRING string = !empty(existingProjEndpoint) ? existingProjEndpoint : aiFoundryAiServices.outputs.endpoint +output AZURE_AI_PROJECT_CONN_STRING string = !empty(existingProjEndpoint) + ? existingProjEndpoint + : aiFoundryAiServices.outputs.endpoint @description('Contains Azure AI Agent API Version.') output AZURE_AI_AGENT_API_VERSION string = azureAiAgentApiVersion @description('Contains Azure AI Foundry service name.') -output AZURE_AI_FOUNDRY_NAME string = !empty(existingAIServicesName) ? existingAIServicesName : aiFoundryAiServices.outputs.name +output AZURE_AI_FOUNDRY_NAME string = !empty(existingAIServicesName) + ? existingAIServicesName + : aiFoundryAiServices.outputs.name @description('Contains Azure AI Project name.') -output AZURE_AI_PROJECT_NAME string = !empty(existingAIProjectName) ? existingAIProjectName : aiFoundryAiServices.outputs.aiProjectInfo.name +output AZURE_AI_PROJECT_NAME string = !empty(existingAIProjectName) + ? existingAIProjectName + : aiFoundryAiServices.outputs.aiProjectInfo.name @description('Contains Azure AI Search service name.') output AZURE_AI_SEARCH_NAME string = aiSearchName @@ -1489,7 +1521,9 @@ output USE_CHAT_HISTORY_ENABLED string = 'True' output DISPLAY_CHART_DEFAULT string = 'False' @description('Contains Azure AI Agent endpoint URL.') -output AZURE_AI_AGENT_ENDPOINT string = !empty(existingProjEndpoint) ? existingProjEndpoint : aiFoundryAiServices.outputs.aiProjectInfo.apiEndpoint +output AZURE_AI_AGENT_ENDPOINT string = !empty(existingProjEndpoint) + ? existingProjEndpoint + : aiFoundryAiServices.outputs.aiProjectInfo.apiEndpoint @description('Contains Azure AI Agent model deployment name.') output AZURE_AI_AGENT_MODEL_DEPLOYMENT_NAME string = gptModelName @@ -1504,7 +1538,9 @@ output AZURE_ENV_IMAGETAG string = backendContainerImageTag output AZURE_EXISTING_AI_PROJECT_RESOURCE_ID string = existingAiFoundryAiProjectResourceId @description('Contains Application Insights connection string.') -output APPLICATIONINSIGHTS_CONNECTION_STRING string = enableMonitoring ? applicationInsights!.outputs.connectionString : '' +output APPLICATIONINSIGHTS_CONNECTION_STRING string = enableMonitoring + ? applicationInsights!.outputs.connectionString + : '' @description('Contains API application URL.') output API_APP_URL string = 'https://api-${solutionSuffix}.azurewebsites.net' From 3a5b012da51de81ac5014d837efb58662367fd77 Mon Sep 17 00:00:00 2001 From: Vemarthula-Microsoft Date: Mon, 5 Jan 2026 22:00:09 +0530 Subject: [PATCH 10/19] Update Bicep modules and parameters for AI services --- infra/main_custom.bicep | 222 +++++++++++++++------------------------- 1 file changed, 83 insertions(+), 139 deletions(-) diff --git a/infra/main_custom.bicep b/infra/main_custom.bicep index 3d38f6d85..9ca64c680 100644 --- a/infra/main_custom.bicep +++ b/infra/main_custom.bicep @@ -159,9 +159,7 @@ param existingLogAnalyticsWorkspaceId string = '' param existingAiFoundryAiProjectResourceId string = '' @description('Optional. Created by user name.') -param createdBy string = contains(deployer(), 'userPrincipalName') - ? split(deployer().userPrincipalName, '@')[0] - : deployer().objectId +param createdBy string = contains(deployer(), 'userPrincipalName')? split(deployer().userPrincipalName, '@')[0]: deployer().objectId @maxLength(5) @description('Optional. A unique text value for the solution. This is used to ensure resource names are unique for global resources. Defaults to a 5-character substring of the unique string generated from the subscription ID, resource group name, and solution name.') @@ -216,11 +214,15 @@ var logAnalyticsWorkspaceResourceId = useExistingLogAnalytics : logAnalyticsWorkspace!.outputs.resourceId // ========== Resource Group Tag ========== // -resource resourceGroupTags 'Microsoft.Resources/tags@2021-04-01' = { +resource resourceGroupTags 'Microsoft.Resources/tags@2025-04-01' = { name: 'default' properties: { tags: union( - reference(resourceGroup().id, '2021-04-01', 'Full').tags ?? {}, + reference( + resourceGroup().id, + '2021-04-01', + 'Full' + ).tags ?? {}, { TemplateName: 'KM-Generic' Type: enablePrivateNetworking ? 'WAF' : 'Non-WAF' @@ -254,7 +256,7 @@ resource avmTelemetry 'Microsoft.Resources/deployments@2024-03-01' = if (enableT // WAF best practices for Log Analytics: https://learn.microsoft.com/en-us/azure/well-architected/service-guides/azure-log-analytics // WAF PSRules for Log Analytics: https://azure.github.io/PSRule.Rules.Azure/en/rules/resource/#azure-monitor-logs var logAnalyticsWorkspaceResourceName = 'log-${solutionSuffix}' -module logAnalyticsWorkspace 'br/public:avm/res/operational-insights/workspace:0.12.0' = if (enableMonitoring && !useExistingLogAnalytics) { +module logAnalyticsWorkspace 'br/public:avm/res/operational-insights/workspace:0.14.2' = if (enableMonitoring && !useExistingLogAnalytics) { name: take('avm.res.operational-insights.workspace.${logAnalyticsWorkspaceResourceName}', 64) params: { name: logAnalyticsWorkspaceResourceName @@ -317,7 +319,7 @@ module logAnalyticsWorkspace 'br/public:avm/res/operational-insights/workspace:0 // WAF best practices for Application Insights: https://learn.microsoft.com/en-us/azure/well-architected/service-guides/application-insights // WAF PSRules for Application Insights: https://azure.github.io/PSRule.Rules.Azure/en/rules/resource/#application-insights var applicationInsightsResourceName = 'appi-${solutionSuffix}' -module applicationInsights 'br/public:avm/res/insights/component:0.6.0' = if (enableMonitoring) { +module applicationInsights 'br/public:avm/res/insights/component:0.7.1' = if (enableMonitoring) { name: take('avm.res.insights.component.${applicationInsightsResourceName}', 64) params: { name: applicationInsightsResourceName @@ -350,7 +352,7 @@ module virtualNetwork 'modules/virtualNetwork.bicep' = if (enablePrivateNetworki } // Azure Bastion Host var bastionHostName = 'bas-${solutionSuffix}' -module bastionHost 'br/public:avm/res/network/bastion-host:0.6.1' = if (enablePrivateNetworking) { +module bastionHost 'br/public:avm/res/network/bastion-host:0.8.2' = if (enablePrivateNetworking) { name: take('avm.res.network.bastion-host.${bastionHostName}', 64) params: { name: bastionHostName @@ -373,14 +375,13 @@ module bastionHost 'br/public:avm/res/network/bastion-host:0.6.1' = if (enablePr enableTelemetry: enableTelemetry publicIPAddressObject: { name: 'pip-${bastionHostName}' - zones: [] } } } // Jumpbox Virtual Machine var jumpboxVmName = take('vm-jumpbox-${solutionSuffix}', 15) -module jumpboxVM 'br/public:avm/res/compute/virtual-machine:0.15.0' = if (enablePrivateNetworking) { +module jumpboxVM 'br/public:avm/res/compute/virtual-machine:0.21.0' = if (enablePrivateNetworking) { name: take('avm.res.compute.virtual-machine.${jumpboxVmName}', 64) params: { name: take(jumpboxVmName, 15) // Shorten VM name to 15 characters to avoid Azure limits @@ -389,7 +390,7 @@ module jumpboxVM 'br/public:avm/res/compute/virtual-machine:0.15.0' = if (enable adminUsername: vmAdminUsername ?? 'JumpboxAdminUser' adminPassword: vmAdminPassword ?? 'JumpboxAdminP@ssw0rd1234!' tags: tags - zone: 0 + availabilityZone: -1 imageReference: { offer: 'WindowsServer' publisher: 'MicrosoftWindowsServer' @@ -471,7 +472,7 @@ var dnsZoneIndex = { // - Excludes AI-related zones when using with an existing Foundry project // =================================================== @batchSize(5) -module avmPrivateDnsZones 'br/public:avm/res/network/private-dns-zone:0.7.1' = [ +module avmPrivateDnsZones 'br/public:avm/res/network/private-dns-zone:0.8.0' = [ for (zone, i) in privateDnsZones: if (enablePrivateNetworking) { name: 'avm.res.network.private-dns-zone.${split(zone, '.')[1]}' params: { @@ -492,7 +493,7 @@ module avmPrivateDnsZones 'br/public:avm/res/network/private-dns-zone:0.7.1' = [ // ========== User Assigned Identity ========== // var userAssignedIdentityResourceName = 'id-${solutionSuffix}' -module userAssignedIdentity 'br/public:avm/res/managed-identity/user-assigned-identity:0.4.1' = { +module userAssignedIdentity 'br/public:avm/res/managed-identity/user-assigned-identity:0.4.3' = { name: take('avm.res.managed-identity.user-assigned-identity.${userAssignedIdentityResourceName}', 64) params: { name: userAssignedIdentityResourceName @@ -505,7 +506,7 @@ module userAssignedIdentity 'br/public:avm/res/managed-identity/user-assigned-id // ========== SQL Operations User Assigned Identity ========== // // Dedicated identity for backend SQL operations with limited permissions (db_datareader, db_datawriter) var backendUserAssignedIdentityResourceName = 'id-backend-${solutionSuffix}' -module backendUserAssignedIdentity 'br/public:avm/res/managed-identity/user-assigned-identity:0.4.1' = { +module backendUserAssignedIdentity 'br/public:avm/res/managed-identity/user-assigned-identity:0.4.3' = { name: take('avm.res.managed-identity.user-assigned-identity.${backendUserAssignedIdentityResourceName}', 64) params: { name: backendUserAssignedIdentityResourceName @@ -520,22 +521,10 @@ module backendUserAssignedIdentity 'br/public:avm/res/managed-identity/user-assi // ========== AI Foundry: AI Services ========== // // WAF best practices for Open AI: https://learn.microsoft.com/en-us/azure/well-architected/service-guides/azure-openai -var existingOpenAIEndpoint = !empty(existingAiFoundryAiProjectResourceId) - ? format('https://{0}.openai.azure.com/', split(existingAiFoundryAiProjectResourceId, '/')[8]) - : '' -var existingProjEndpoint = !empty(existingAiFoundryAiProjectResourceId) - ? format( - 'https://{0}.services.ai.azure.com/api/projects/{1}', - split(existingAiFoundryAiProjectResourceId, '/')[8], - split(existingAiFoundryAiProjectResourceId, '/')[10] - ) - : '' -var existingAIServicesName = !empty(existingAiFoundryAiProjectResourceId) - ? split(existingAiFoundryAiProjectResourceId, '/')[8] - : '' -var existingAIProjectName = !empty(existingAiFoundryAiProjectResourceId) - ? split(existingAiFoundryAiProjectResourceId, '/')[10] - : '' +var existingOpenAIEndpoint = !empty(existingAiFoundryAiProjectResourceId) ? format('https://{0}.openai.azure.com/', split(existingAiFoundryAiProjectResourceId, '/')[8]) : '' +var existingProjEndpoint = !empty(existingAiFoundryAiProjectResourceId) ? format('https://{0}.services.ai.azure.com/api/projects/{1}', split(existingAiFoundryAiProjectResourceId, '/')[8], split(existingAiFoundryAiProjectResourceId, '/')[10]) : '' +var existingAIServicesName = !empty(existingAiFoundryAiProjectResourceId) ? split(existingAiFoundryAiProjectResourceId, '/')[8] : '' +var existingAIProjectName = !empty(existingAiFoundryAiProjectResourceId) ? split(existingAiFoundryAiProjectResourceId, '/')[10] : '' var aiFoundryAiServicesSubscriptionId = useExistingAiFoundryAiProject ? split(existingAiFoundryAiProjectResourceId, '/')[2] @@ -549,7 +538,7 @@ var aiFoundryAiServicesResourceName = useExistingAiFoundryAiProject : 'aif-${solutionSuffix}' var aiFoundryAiProjectResourceName = useExistingAiFoundryAiProject ? split(existingAiFoundryAiProjectResourceId, '/')[10] - : 'proj-${solutionSuffix}' + : 'proj-${solutionSuffix}' // NOTE: Required version 'Microsoft.CognitiveServices/accounts@2024-04-01-preview' not available in AVM // var aiFoundryAiServicesResourceName = 'aif-${solutionSuffix}' @@ -648,7 +637,7 @@ module aiFoundryAiServices 'modules/ai-services.bicep' = if (aiFoundryAIservices // WAF aligned configuration for Monitoring diagnosticSettings: enableMonitoring ? [{ workspaceResourceId: logAnalyticsWorkspaceResourceId }] : null publicNetworkAccess: enablePrivateNetworking ? 'Disabled' : 'Enabled' - privateEndpoints: (enablePrivateNetworking && empty(existingAiFoundryAiProjectResourceId)) + privateEndpoints: (enablePrivateNetworking && empty(existingAiFoundryAiProjectResourceId)) ? ([ { name: 'pep-${aiFoundryAiServicesResourceName}' @@ -674,30 +663,17 @@ module aiFoundryAiServices 'modules/ai-services.bicep' = if (aiFoundryAIservices ]) : [] deployments: [ - { - name: aiModelDeployments[0].name + for aiModelDeployment in aiModelDeployments: { + name: aiModelDeployment.name model: { - format: aiModelDeployments[0].format - name: aiModelDeployments[0].name - version: aiModelDeployments[0].version + format: aiModelDeployment.format + name: aiModelDeployment.model + version: aiModelDeployment.version } - raiPolicyName: aiModelDeployments[0].raiPolicyName + raiPolicyName: aiModelDeployment.raiPolicyName sku: { - name: aiModelDeployments[0].sku.name - capacity: aiModelDeployments[0].sku.capacity - } - } - { - name: aiModelDeployments[1].name - model: { - format: aiModelDeployments[1].format - name: aiModelDeployments[1].name - version: aiModelDeployments[1].version - } - raiPolicyName: aiModelDeployments[1].raiPolicyName - sku: { - name: aiModelDeployments[1].sku.name - capacity: aiModelDeployments[1].sku.capacity + name: aiModelDeployment.sku.name + capacity: aiModelDeployment.sku.capacity } } ] @@ -706,12 +682,11 @@ module aiFoundryAiServices 'modules/ai-services.bicep' = if (aiFoundryAIservices // AI Foundry: AI Services Content Understanding var aiFoundryAiServicesCUResourceName = 'aif-${solutionSuffix}-cu' -var aiServicesName_cu = 'aisa-${solutionSuffix}-cu' -// NOTE: Required version 'Microsoft.CognitiveServices/accounts/deployments@2023-05-01' not available in AVM -module cognitiveServicesCu 'br/public:avm/res/cognitive-services/account:0.10.1' = { +var aiServicesNameCu = 'aisa-${solutionSuffix}-cu' +module cognitiveServicesCu 'br/public:avm/res/cognitive-services/account:0.14.1' = { name: take('avm.res.cognitive-services.account.${aiFoundryAiServicesCUResourceName}', 64) params: { - name: aiServicesName_cu + name: aiServicesNameCu location: contentUnderstandingLocation tags: tags enableTelemetry: enableTelemetry @@ -724,8 +699,8 @@ module cognitiveServicesCu 'br/public:avm/res/cognitive-services/account:0.10.1' ipRules: [] } managedIdentities: { userAssignedResourceIds: [userAssignedIdentity!.outputs.resourceId] } //To create accounts or projects, you must enable a managed identity on your resource - disableLocalAuth: false //Added this in order to retrieve the keys. Evaluate alternatives - customSubDomainName: aiServicesName_cu + disableLocalAuth: true + customSubDomainName: aiServicesNameCu apiProperties: { // staticsEnabled: false } @@ -768,25 +743,18 @@ module cognitiveServicesCu 'br/public:avm/res/cognitive-services/account:0.10.1' // ========== AVM WAF ========== // // ========== AI Foundry: AI Search ========== // var aiSearchName = 'srch-${solutionSuffix}' -module searchSearchServices 'br/public:avm/res/search/search-service:0.11.1' = { +module searchSearchServices 'br/public:avm/res/search/search-service:0.12.0' = { name: take('avm.res.search.search-service.${aiSearchName}', 64) params: { // Required parameters name: aiSearchName - authOptions: { - aadOrApiKey: { - aadAuthFailureMode: 'http401WithBearerChallenge' + diagnosticSettings: enableMonitoring ? [ + { + workspaceResourceId: logAnalyticsWorkspaceResourceId } - } - diagnosticSettings: enableMonitoring - ? [ - { - workspaceResourceId: logAnalyticsWorkspaceResourceId - } - ] - : null - disableLocalAuth: false - hostingMode: 'default' + ] : null + disableLocalAuth: true + hostingMode: 'Default' managedIdentities: { systemAssigned: true } @@ -822,16 +790,12 @@ module searchSearchServices 'br/public:avm/res/search/search-service:0.11.1' = { } { roleDefinitionIdOrName: '1407120a-92aa-4202-b7e9-c0e197c71c8f' // Search Index Data Reader - principalId: !useExistingAiFoundryAiProject - ? aiFoundryAiServices.outputs.aiProjectInfo.aiprojectSystemAssignedMIPrincipalId - : existingAiFoundryAiServicesProject!.identity.principalId + principalId: !useExistingAiFoundryAiProject ? aiFoundryAiServices.outputs.aiProjectInfo.aiprojectSystemAssignedMIPrincipalId : existingAiFoundryAiServicesProject!.identity.principalId principalType: 'ServicePrincipal' } { roleDefinitionIdOrName: '7ca78c08-252a-4471-8644-bb5ff32d4ba0' // Search Service Contributor - principalId: !useExistingAiFoundryAiProject - ? aiFoundryAiServices.outputs.aiProjectInfo.aiprojectSystemAssignedMIPrincipalId - : existingAiFoundryAiServicesProject!.identity.principalId + principalId: !useExistingAiFoundryAiProject ? aiFoundryAiServices.outputs.aiProjectInfo.aiprojectSystemAssignedMIPrincipalId : existingAiFoundryAiServicesProject!.identity.principalId principalType: 'ServicePrincipal' } ] @@ -843,20 +807,20 @@ module searchSearchServices 'br/public:avm/res/search/search-service:0.11.1' = { tags: tags publicNetworkAccess: 'Enabled' //enablePrivateNetworking ? 'Disabled' : 'Enabled' privateEndpoints: false //enablePrivateNetworking - ? [ - { - name: 'pep-${aiSearchName}' - customNetworkInterfaceName: 'nic-${aiSearchName}' - privateDnsZoneGroup: { - privateDnsZoneGroupConfigs: [ - { privateDnsZoneResourceId: avmPrivateDnsZones[dnsZoneIndex.search]!.outputs.resourceId } - ] - } - service: 'searchService' - subnetResourceId: virtualNetwork!.outputs.pepsSubnetResourceId + ? [ + { + name: 'pep-${aiSearchName}' + customNetworkInterfaceName: 'nic-${aiSearchName}' + privateDnsZoneGroup: { + privateDnsZoneGroupConfigs: [ + { privateDnsZoneResourceId: avmPrivateDnsZones[dnsZoneIndex.search]!.outputs.resourceId } + ] } - ] - : [] + service: 'searchService' + subnetResourceId: virtualNetwork!.outputs.pepsSubnetResourceId + } + ] + : [] } } @@ -864,16 +828,14 @@ module searchSearchServices 'br/public:avm/res/search/search-service:0.11.1' = { resource searchServiceToAiServicesRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (!useExistingAiFoundryAiProject) { name: guid(aiSearchName, '5e0bd9bd-7b93-4f28-af87-19fc36ad61bd', aiFoundryAiServicesResourceName) properties: { - roleDefinitionId: subscriptionResourceId( - 'Microsoft.Authorization/roleDefinitions', - '5e0bd9bd-7b93-4f28-af87-19fc36ad61bd' - ) // Cognitive Services OpenAI User + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '5e0bd9bd-7b93-4f28-af87-19fc36ad61bd') // Cognitive Services OpenAI User principalId: searchSearchServices.outputs.systemAssignedMIPrincipalId! principalType: 'ServicePrincipal' } } -resource projectAISearchConnection 'Microsoft.CognitiveServices/accounts/projects/connections@2025-04-01-preview' = if (!useExistingAiFoundryAiProject) { +// Re-enabled - using disableLocalAuth: true avoids key validation +resource projectAISearchConnection 'Microsoft.CognitiveServices/accounts/projects/connections@2025-04-01-preview' = if (!useExistingAiFoundryAiProject){ name: '${aiFoundryAiServicesResourceName}/${aiFoundryAiServicesAiProjectResourceName}/${aiSearchName}' properties: { category: 'CognitiveSearch' @@ -886,6 +848,10 @@ resource projectAISearchConnection 'Microsoft.CognitiveServices/accounts/project location: searchSearchServices.outputs.location } } + dependsOn: [ + aiFoundryAiServices + searchSearchServices + ] } module existing_AIProject_SearchConnectionModule 'modules/deploy_aifp_aisearch_connection.bicep' = if (useExistingAiFoundryAiProject) { @@ -914,14 +880,14 @@ module searchServiceToExistingAiServicesRoleAssignment 'modules/role-assignment. // ========== Storage account module ========== // var storageAccountName = 'st${solutionSuffix}' -module storageAccount 'br/public:avm/res/storage/storage-account:0.20.0' = { +module storageAccount 'br/public:avm/res/storage/storage-account:0.31.0' = { name: take('avm.res.storage.storage-account.${storageAccountName}', 64) params: { name: storageAccountName location: location managedIdentities: { systemAssigned: true - userAssignedResourceIds: [userAssignedIdentity!.outputs.resourceId] + userAssignedResourceIds: [ userAssignedIdentity!.outputs.resourceId ] } minimumTlsVersion: 'TLS1_2' supportsHttpsTrafficOnly: true @@ -1017,9 +983,7 @@ module storageAccount 'br/public:avm/res/storage/storage-account:0.20.0' = { restorePolicyEnabled: false isVersioningEnabled: false containerDeleteRetentionPolicyEnabled: false - lastAccessTimeTrackingPolicy: { - enable: false - } + lastAccessTimeTrackingPolicyEnabled: false containers: [ { name: 'data' @@ -1033,7 +997,7 @@ module storageAccount 'br/public:avm/res/storage/storage-account:0.20.0' = { var cosmosDbResourceName = 'cosmos-${solutionSuffix}' var cosmosDbDatabaseName = 'db_conversation_history' var collectionName = 'conversations' -module cosmosDb 'br/public:avm/res/document-db/database-account:0.15.0' = { +module cosmosDb 'br/public:avm/res/document-db/database-account:0.18.0' = { name: take('avm.res.document-db.database-account.${cosmosDbResourceName}', 64) params: { // Required parameters @@ -1054,18 +1018,6 @@ module cosmosDb 'br/public:avm/res/document-db/database-account:0.15.0' = { ] } ] - dataPlaneRoleDefinitions: [ - { - // Cosmos DB Built-in Data Contributor: https://docs.azure.cn/en-us/cosmos-db/nosql/security/reference-data-plane-roles#cosmos-db-built-in-data-contributor - roleName: 'Custom Data Contributor-${uniqueString(cosmosDbResourceName, resourceGroup().id)}' - dataActions: [ - 'Microsoft.DocumentDB/databaseAccounts/readMetadata' - 'Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers/*' - 'Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers/items/*' - ] - assignments: [{ principalId: backendUserAssignedIdentity.outputs.principalId }] - } - ] // WAF aligned configuration for Monitoring diagnosticSettings: enableMonitoring ? [{ workspaceResourceId: logAnalyticsWorkspaceResourceId }] : null // WAF aligned configuration for Private Networking @@ -1091,7 +1043,7 @@ module cosmosDb 'br/public:avm/res/document-db/database-account:0.15.0' = { // WAF aligned configuration for Redundancy zoneRedundant: enableRedundancy ? true : false capabilitiesToAdd: enableRedundancy ? null : ['EnableServerless'] - automaticFailover: enableRedundancy ? true : false + enableAutomaticFailover: enableRedundancy ? true : false failoverLocations: enableRedundancy ? [ { @@ -1119,7 +1071,7 @@ module cosmosDb 'br/public:avm/res/document-db/database-account:0.15.0' = { //========== SQL Database module ========== // var sqlServerResourceName = 'sql-${solutionSuffix}' var sqlDbModuleName = 'sqldb-${solutionSuffix}' -module sqlDBModule 'br/public:avm/res/sql/server:0.20.1' = { +module sqlDBModule 'br/public:avm/res/sql/server:0.21.1' = { name: take('avm.res.sql.server.${sqlServerResourceName}', 64) params: { // Required parameters @@ -1137,7 +1089,9 @@ module sqlDBModule 'br/public:avm/res/sql/server:0.20.1' = { { availabilityZone: enableRedundancy ? 1 : -1 collation: 'SQL_Latin1_General_CP1_CI_AS' - diagnosticSettings: enableMonitoring ? [{ workspaceResourceId: logAnalyticsWorkspaceResourceId }] : null + diagnosticSettings: enableMonitoring + ? [{ workspaceResourceId: logAnalyticsWorkspaceResourceId }] + : null licenseType: 'LicenseIncluded' maxSizeBytes: 34359738368 name: sqlDbModuleName @@ -1148,6 +1102,7 @@ module sqlDBModule 'br/public:avm/res/sql/server:0.20.1' = { family: 'Gen5' capacity: 2 } + // Note: Zone redundancy is not supported for serverless SKUs (GP_S_Gen5) zoneRedundant: enableRedundancy ? true : false } ] @@ -1326,14 +1281,10 @@ module webSiteBackend 'modules/web-sites.bicep' = { PYTHONUNBUFFERED: '1' REACT_APP_LAYOUT_CONFIG: reactAppLayoutConfig AZURE_OPENAI_DEPLOYMENT_MODEL: gptModelName - AZURE_OPENAI_ENDPOINT: !empty(existingOpenAIEndpoint) - ? existingOpenAIEndpoint - : 'https://${aiFoundryAiServices.outputs.name}.openai.azure.com/' + AZURE_OPENAI_ENDPOINT: !empty(existingOpenAIEndpoint) ? existingOpenAIEndpoint : 'https://${aiFoundryAiServices.outputs.name}.openai.azure.com/' AZURE_OPENAI_API_VERSION: azureOpenAIApiVersion AZURE_OPENAI_RESOURCE: aiFoundryAiServices.outputs.name - AZURE_AI_AGENT_ENDPOINT: !empty(existingProjEndpoint) - ? existingProjEndpoint - : aiFoundryAiServices.outputs.aiProjectInfo.apiEndpoint + AZURE_AI_AGENT_ENDPOINT: !empty(existingProjEndpoint) ? existingProjEndpoint : aiFoundryAiServices.outputs.aiProjectInfo.apiEndpoint AZURE_AI_AGENT_API_VERSION: azureAiAgentApiVersion AZURE_AI_AGENT_MODEL_DEPLOYMENT_NAME: gptModelName USE_CHAT_HISTORY_ENABLED: 'True' @@ -1428,22 +1379,16 @@ output AZURE_CONTENT_UNDERSTANDING_LOCATION string = contentUnderstandingLocatio output APPINSIGHTS_INSTRUMENTATIONKEY string = enableMonitoring ? applicationInsights!.outputs.instrumentationKey : '' @description('Contains AI Project Connection String.') -output AZURE_AI_PROJECT_CONN_STRING string = !empty(existingProjEndpoint) - ? existingProjEndpoint - : aiFoundryAiServices.outputs.endpoint +output AZURE_AI_PROJECT_CONN_STRING string = !empty(existingProjEndpoint) ? existingProjEndpoint : aiFoundryAiServices.outputs.endpoint @description('Contains Azure AI Agent API Version.') output AZURE_AI_AGENT_API_VERSION string = azureAiAgentApiVersion @description('Contains Azure AI Foundry service name.') -output AZURE_AI_FOUNDRY_NAME string = !empty(existingAIServicesName) - ? existingAIServicesName - : aiFoundryAiServices.outputs.name +output AZURE_AI_FOUNDRY_NAME string = !empty(existingAIServicesName) ? existingAIServicesName : aiFoundryAiServices.outputs.name @description('Contains Azure AI Project name.') -output AZURE_AI_PROJECT_NAME string = !empty(existingAIProjectName) - ? existingAIProjectName - : aiFoundryAiServices.outputs.aiProjectInfo.name +output AZURE_AI_PROJECT_NAME string = !empty(existingAIProjectName) ? existingAIProjectName : aiFoundryAiServices.outputs.aiProjectInfo.name @description('Contains Azure AI Search service name.') output AZURE_AI_SEARCH_NAME string = aiSearchName @@ -1505,6 +1450,9 @@ output SQLDB_DATABASE string = 'sqldb-${solutionSuffix}' @description('Contains SQL server name.') output SQLDB_SERVER string = '${sqlDBModule.outputs.name }${environment().suffixes.sqlServerHostname}' +@description('Client ID of the user-assigned managed identity.') +output USER_MID string = userAssignedIdentity.outputs.clientId + @description('Display name of the backend API user-assigned managed identity (also used for SQL database access).') output BACKEND_USER_MID_NAME string = backendUserAssignedIdentity.outputs.name @@ -1521,9 +1469,7 @@ output USE_CHAT_HISTORY_ENABLED string = 'True' output DISPLAY_CHART_DEFAULT string = 'False' @description('Contains Azure AI Agent endpoint URL.') -output AZURE_AI_AGENT_ENDPOINT string = !empty(existingProjEndpoint) - ? existingProjEndpoint - : aiFoundryAiServices.outputs.aiProjectInfo.apiEndpoint +output AZURE_AI_AGENT_ENDPOINT string = !empty(existingProjEndpoint) ? existingProjEndpoint : aiFoundryAiServices.outputs.aiProjectInfo.apiEndpoint @description('Contains Azure AI Agent model deployment name.') output AZURE_AI_AGENT_MODEL_DEPLOYMENT_NAME string = gptModelName @@ -1538,9 +1484,7 @@ output AZURE_ENV_IMAGETAG string = backendContainerImageTag output AZURE_EXISTING_AI_PROJECT_RESOURCE_ID string = existingAiFoundryAiProjectResourceId @description('Contains Application Insights connection string.') -output APPLICATIONINSIGHTS_CONNECTION_STRING string = enableMonitoring - ? applicationInsights!.outputs.connectionString - : '' +output APPLICATIONINSIGHTS_CONNECTION_STRING string = enableMonitoring ? applicationInsights!.outputs.connectionString : '' @description('Contains API application URL.') output API_APP_URL string = 'https://api-${solutionSuffix}.azurewebsites.net' From c4d4896c73426d41124ac2694ebcd3d06ff6474b Mon Sep 17 00:00:00 2001 From: Harsh-Microsoft Date: Tue, 20 Jan 2026 12:52:11 +0530 Subject: [PATCH 11/19] refactor: update webapp packaging scripts and improve output messages --- azure_custom.yaml | 16 ++----- infra/main_custom.bicep | 74 ++++++++++++++++++-------------- infra/scripts/package_webapp.ps1 | 62 -------------------------- infra/scripts/package_webapp.sh | 65 ---------------------------- 4 files changed, 44 insertions(+), 173 deletions(-) delete mode 100644 infra/scripts/package_webapp.ps1 delete mode 100644 infra/scripts/package_webapp.sh diff --git a/azure_custom.yaml b/azure_custom.yaml index d21ccce39..95d88ac38 100644 --- a/azure_custom.yaml +++ b/azure_custom.yaml @@ -21,17 +21,6 @@ services: project: ./src/App language: js host: appservice - dist: ./build - hooks: - prepackage: - windows: - shell: pwsh - run: ../../infra/scripts/package_webapp.ps1 - continueOnError: false - posix: - shell: sh - run: bash ../../infra/scripts/package_webapp.sh - continueOnError: false hooks: postprovision: @@ -41,7 +30,7 @@ hooks: interactive: true run: | Write-Host "Web app URL: $env:WEB_APP_URL" -ForegroundColor Cyan - Write-Host "`nRun: bash ./infra/scripts/process_sample_data.sh" -ForegroundColor Yellow + Write-Host "`nRun the following command in bash, if sample data needs to be processed:`nbash ./infra/scripts/process_sample_data.sh" -ForegroundColor Yellow posix: shell: sh continueOnError: false @@ -49,4 +38,5 @@ hooks: run: | echo "Web app URL: $WEB_APP_URL" echo "" - echo "Run: bash ./infra/scripts/process_sample_data.sh" + echo "Run the following command in bash, if sample data needs to be processed:" + echo "bash ./infra/scripts/process_sample_data.sh" diff --git a/infra/main_custom.bicep b/infra/main_custom.bicep index 9ca64c680..12544b780 100644 --- a/infra/main_custom.bicep +++ b/infra/main_custom.bicep @@ -112,7 +112,7 @@ param backendContainerRegistryHostname string = 'kmcontainerreg.azurecr.io' param backendContainerImageName string = 'km-api' @description('Optional. The Container Image Tag to deploy on the backend.') -param backendContainerImageTag string = 'latest_waf_2025-09-18_898' +param backendContainerImageTag string = 'latest_waf_2025-12-02_1084' @description('Optional. The Container Registry hostname where the docker images for the frontend are located.') param frontendContainerRegistryHostname string = 'kmcontainerreg.azurecr.io' @@ -121,7 +121,7 @@ param frontendContainerRegistryHostname string = 'kmcontainerreg.azurecr.io' param frontendContainerImageName string = 'km-app' @description('Optional. The Container Image Tag to deploy on the frontend.') -param frontendContainerImageTag string = 'latest_waf_2025-09-18_898' +param frontendContainerImageTag string = 'latest_waf_2025-12-02_1084' @description('Optional. The tags to apply to all deployed Azure resources.') param tags resourceInput<'Microsoft.Resources/resourceGroups@2025-04-01'>.tags = {} @@ -217,25 +217,21 @@ var logAnalyticsWorkspaceResourceId = useExistingLogAnalytics resource resourceGroupTags 'Microsoft.Resources/tags@2025-04-01' = { name: 'default' properties: { - tags: union( - reference( - resourceGroup().id, - '2021-04-01', - 'Full' - ).tags ?? {}, - { - TemplateName: 'KM-Generic' - Type: enablePrivateNetworking ? 'WAF' : 'Non-WAF' - CreatedBy: createdBy - }, - tags - ) + tags:{ + ...resourceGroup().tags + TemplateName: 'KM-Generic' + Type: enablePrivateNetworking ? 'WAF' : 'Non-WAF' + CreatedBy: createdBy + DeploymentName: deployment().name + UseCase: usecase + ...tags + } } } #disable-next-line no-deployments-resources resource avmTelemetry 'Microsoft.Resources/deployments@2024-03-01' = if (enableTelemetry) { - name: '46d3xbcp.ptn.sa-multiagentcustauteng.${replace('-..--..-', '.', '-')}.${substring(uniqueString(deployment().name, location), 0, 4)}' + name: '46d3xbcp.ptn.sa-convknowledgemining.${replace('-..--..-', '.', '-')}.${substring(uniqueString(deployment().name, location), 0, 4)}' properties: { mode: 'Incremental' template: { @@ -392,9 +388,9 @@ module jumpboxVM 'br/public:avm/res/compute/virtual-machine:0.21.0' = if (enable tags: tags availabilityZone: -1 imageReference: { - offer: 'WindowsServer' - publisher: 'MicrosoftWindowsServer' - sku: '2019-datacenter' + publisher: 'microsoft-dsvm' + offer: 'dsvm-win-2022' + sku: 'winserver-2022' version: 'latest' } osType: 'Windows' @@ -748,6 +744,7 @@ module searchSearchServices 'br/public:avm/res/search/search-service:0.12.0' = { params: { // Required parameters name: aiSearchName + enableTelemetry: enableTelemetry diagnosticSettings: enableMonitoring ? [ { workspaceResourceId: logAnalyticsWorkspaceResourceId @@ -848,10 +845,6 @@ resource projectAISearchConnection 'Microsoft.CognitiveServices/accounts/project location: searchSearchServices.outputs.location } } - dependsOn: [ - aiFoundryAiServices - searchSearchServices - ] } module existing_AIProject_SearchConnectionModule 'modules/deploy_aifp_aisearch_connection.bicep' = if (useExistingAiFoundryAiProject) { @@ -914,11 +907,11 @@ module storageAccount 'br/public:avm/res/storage/storage-account:0.31.0' = { ] networkAcls: { bypass: 'AzureServices, Logging, Metrics' - defaultAction: 'Allow' + defaultAction: enablePrivateNetworking ? 'Deny' : 'Allow' virtualNetworkRules: [] } allowSharedKeyAccess: true - allowBlobPublicAccess: true + allowBlobPublicAccess: false publicNetworkAccess: enablePrivateNetworking ? 'Disabled' : 'Enabled' privateEndpoints: enablePrivateNetworking ? [ @@ -1018,6 +1011,18 @@ module cosmosDb 'br/public:avm/res/document-db/database-account:0.18.0' = { ] } ] + sqlRoleDefinitions: [ + { + // Cosmos DB Built-in Data Contributor: https://docs.azure.cn/en-us/cosmos-db/nosql/security/reference-data-plane-roles#cosmos-db-built-in-data-contributor + roleName: 'Cosmos DB SQL Data Contributor' + dataActions: [ + 'Microsoft.DocumentDB/databaseAccounts/readMetadata' + 'Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers/*' + 'Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers/items/*' + ] + assignments: [{ principalId: backendUserAssignedIdentity.outputs.principalId }] + } + ] // WAF aligned configuration for Monitoring diagnosticSettings: enableMonitoring ? [{ workspaceResourceId: logAnalyticsWorkspaceResourceId }] : null // WAF aligned configuration for Private Networking @@ -1041,9 +1046,9 @@ module cosmosDb 'br/public:avm/res/document-db/database-account:0.18.0' = { ] : [] // WAF aligned configuration for Redundancy - zoneRedundant: enableRedundancy ? true : false + zoneRedundant: enableRedundancy capabilitiesToAdd: enableRedundancy ? null : ['EnableServerless'] - enableAutomaticFailover: enableRedundancy ? true : false + enableAutomaticFailover: enableRedundancy failoverLocations: enableRedundancy ? [ { @@ -1076,6 +1081,7 @@ module sqlDBModule 'br/public:avm/res/sql/server:0.21.1' = { params: { // Required parameters name: sqlServerResourceName + enableTelemetry: enableTelemetry // Non-required parameters administrators: { azureADOnlyAuthentication: true @@ -1103,7 +1109,7 @@ module sqlDBModule 'br/public:avm/res/sql/server:0.21.1' = { capacity: 2 } // Note: Zone redundancy is not supported for serverless SKUs (GP_S_Gen5) - zoneRedundant: enableRedundancy ? true : false + zoneRedundant: enableRedundancy } ] location: secondaryLocation @@ -1270,7 +1276,7 @@ module webSiteBackend 'modules/web-sites.bicep' = { linuxFxVersion: 'PYTHON|3.11' minTlsVersion: '1.2' alwaysOn: true - appCommandLine: 'gunicorn --bind=0.0.0.0:8000 --timeout 600 --worker-class uvicorn.workers.UvicornWorker app:app' + appCommandLine: 'uvicorn app:app --host 0.0.0.0 --port 8000' } configs: [ { @@ -1335,17 +1341,19 @@ module webSiteFrontend 'modules/web-sites.bicep' = { kind: 'app,linux' serverFarmResourceId: webServerFarm.outputs.resourceId siteConfig: { - linuxFxVersion: 'NODE|18-lts' + linuxFxVersion: 'NODE|20-lts' minTlsVersion: '1.2' alwaysOn: true - appCommandLine: 'pm2 serve /home/site/wwwroot --no-daemon --spa' + appCommandLine: 'pm2 serve /home/site/wwwroot/build --no-daemon --spa' } configs: [ { name: 'appsettings' properties: { - SCM_DO_BUILD_DURING_DEPLOYMENT: 'false' - WEBSITE_NODE_DEFAULT_VERSION: '~18' + SCM_DO_BUILD_DURING_DEPLOYMENT: 'true' + ENABLE_ORYX_BUILD: 'true' + REACT_APP_API_BASE_URL: 'https://api-${solutionSuffix}.azurewebsites.net' + WEBSITE_NODE_DEFAULT_VERSION: '~20' APP_API_BASE_URL: 'https://api-${solutionSuffix}.azurewebsites.net' } applicationInsightResourceId: enableMonitoring ? applicationInsights!.outputs.resourceId : null diff --git a/infra/scripts/package_webapp.ps1 b/infra/scripts/package_webapp.ps1 deleted file mode 100644 index dd065ab2c..000000000 --- a/infra/scripts/package_webapp.ps1 +++ /dev/null @@ -1,62 +0,0 @@ -#!/usr/bin/env pwsh - -# Package React webapp for Azure App Service deployment -# This script builds the React frontend with dynamic API URL injection -# Run from workspace root OR from src/App (auto-detects location) - -Write-Host "=== React App Build Started ===" -ForegroundColor Cyan - -$ErrorActionPreference = "Stop" - -# Detect if we're in src/App or workspace root and navigate accordingly -if (Test-Path "package.json") { - # Already in src/App - Write-Host "Running from src/App directory" -ForegroundColor Gray -} elseif (Test-Path "src/App/package.json") { - # In workspace root, navigate to src/App - Write-Host "Navigating to src/App directory" -ForegroundColor Gray - Set-Location -Path "src/App" -} else { - Write-Error "Cannot find React app. Run from workspace root or src/App directory." - exit 1 -} - -# Clean old build folder to ensure fresh build -if (Test-Path "build") { - Write-Host "Cleaning old build folder..." -ForegroundColor Yellow - Remove-Item -Path "build" -Recurse -Force -} - -# Get the API URL from azd environment -Write-Host "Fetching API URL from azd environment..." -ForegroundColor Yellow -$apiUrl = azd env get-value API_APP_URL - -if (-not $apiUrl) { - Write-Error "API_APP_URL not found in azd environment. Run 'azd provision' first." - exit 1 -} - -Write-Host "API URL: $apiUrl" -ForegroundColor Green - -# Write API URL to .env.production.local (React reads this during build) -"REACT_APP_API_BASE_URL=$apiUrl" | Out-File -FilePath ".env.production.local" -Encoding utf8 -Write-Host "Created .env.production.local with API URL" -ForegroundColor Green - -# Install dependencies -Write-Host "`nInstalling npm dependencies..." -ForegroundColor Cyan -npm install -if ($LASTEXITCODE -ne 0) { - Write-Error "npm install failed" - exit 1 -} - -# Build React app -Write-Host "`nBuilding React application..." -ForegroundColor Cyan -npm run build -if ($LASTEXITCODE -ne 0) { - Write-Error "npm run build failed" - exit 1 -} - -Write-Host "`n=== React App Build Complete ===" -ForegroundColor Green -Write-Host "Built files are in ./build directory" -ForegroundColor Gray diff --git a/infra/scripts/package_webapp.sh b/infra/scripts/package_webapp.sh deleted file mode 100644 index 8c158692c..000000000 --- a/infra/scripts/package_webapp.sh +++ /dev/null @@ -1,65 +0,0 @@ -#!/bin/bash - -# Package React webapp for Azure App Service deployment -# This script builds the React frontend with dynamic API URL injection -# Run from workspace root OR from src/App (auto-detects location) - -set -e - -echo "=== React App Build Started ===" - -# Detect if we're in src/App or workspace root and navigate accordingly -if [ -f "package.json" ]; then - # Already in src/App - echo "Running from src/App directory" -elif [ -f "src/App/package.json" ]; then - # In workspace root, navigate to src/App - echo "Navigating to src/App directory" - cd src/App -else - echo "ERROR: Cannot find React app. Run from workspace root or src/App directory." - exit 1 -fi - -# Clean old build folder to ensure fresh build -if [ -d "build" ]; then - echo "Cleaning old build folder..." - rm -rf build -fi - -# Get the API URL from azd environment -echo "Fetching API URL from azd environment..." -apiUrl=$(azd env get-value API_APP_URL) - -if [ -z "$apiUrl" ]; then - echo "ERROR: API_APP_URL not found in azd environment. Run 'azd provision' first." - exit 1 -fi - -echo "API URL: $apiUrl" - -# Write API URL to .env.production.local (React reads this during build) -echo "REACT_APP_API_BASE_URL=$apiUrl" > .env.production.local -echo "Created .env.production.local with API URL" - -# Install dependencies -echo "" -echo "Installing npm dependencies..." -npm install -if [ $? -ne 0 ]; then - echo "ERROR: npm install failed" - exit 1 -fi - -# Build React app -echo "" -echo "Building React application..." -npm run build -if [ $? -ne 0 ]; then - echo "ERROR: npm run build failed" - exit 1 -fi - -echo "" -echo "=== React App Build Complete ===" -echo "Built files are in ./build directory" From 166a0f664485f8fc29d02929ba97562995f4e34b Mon Sep 17 00:00:00 2001 From: Harsh-Microsoft Date: Tue, 20 Jan 2026 09:54:22 +0000 Subject: [PATCH 12/19] add node in devcontainer configuration --- .devcontainer/devcontainer.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index a2696742d..9b012d9e5 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -8,7 +8,8 @@ "ghcr.io/devcontainers/features/docker-in-docker:2": {}, "ghcr.io/jlaundry/devcontainer-features/mssql-odbc-driver:1": { "version": "18" - } + }, + "ghcr.io/devcontainers/features/node:1": {} }, "customizations": { "vscode": { From 3f5ae2ea30b099ca0b7c480fda8c8cfc0c07bfbf Mon Sep 17 00:00:00 2001 From: Pavan-Microsoft Date: Wed, 21 Jan 2026 11:44:02 +0530 Subject: [PATCH 13/19] fix: silently ignore parse errors for incomplete JSON chunks during streaming --- src/App/src/components/Chat/Chat.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/App/src/components/Chat/Chat.tsx b/src/App/src/components/Chat/Chat.tsx index e1db0679b..d6f1b8489 100644 --- a/src/App/src/components/Chat/Chat.tsx +++ b/src/App/src/components/Chat/Chat.tsx @@ -139,7 +139,7 @@ const Chat: React.FC = ({ return toolMessage.citations; } catch { - console.log("ERROR WHIEL PARSING TOOL CONTENT"); + // Silently ignore parse errors for incomplete JSON chunks. This is expected during streaming } return []; }; @@ -255,7 +255,7 @@ const Chat: React.FC = ({ runningText = text; } } catch (e) { - console.error("error while parsing text before split", e); + // Silently ignore parse errors for incomplete JSON chunks. This is expected during streaming } } @@ -337,7 +337,7 @@ const Chat: React.FC = ({ scrollChatToBottom(); } } catch (e) { - console.log("Error while parsing charts response", e); + // Silently ignore parse errors for incomplete JSON chunks for chart response. This is expected during streaming } } } @@ -455,7 +455,7 @@ const Chat: React.FC = ({ runningText = text; } } catch (e) { - console.error("error while parsing text before split", e); + // Ignore - will process individual chunks after splitting } if (!isChartResponseReceived) { //text based streaming response @@ -510,7 +510,7 @@ const Chat: React.FC = ({ } } } catch (e) { - console.log("Error while parsing and appending content", e); + // Skip incomplete JSON chunks in stream } }); if (hasError) { From afadcc2157724cdeb6a31ffdcbcd48fa2a7e17c5 Mon Sep 17 00:00:00 2001 From: Pavan-Microsoft Date: Thu, 22 Jan 2026 19:59:07 +0530 Subject: [PATCH 14/19] refactor: enhance citation handling in conversation and chat plugins --- src/api/agents/conversation_agent_factory.py | 7 ++-- src/api/agents/search_agent_factory.py | 1 + src/api/plugins/chat_with_data_plugin.py | 34 +++++++++++++------- 3 files changed, 28 insertions(+), 14 deletions(-) diff --git a/src/api/agents/conversation_agent_factory.py b/src/api/agents/conversation_agent_factory.py index 4142d76e4..f3ae20419 100644 --- a/src/api/agents/conversation_agent_factory.py +++ b/src/api/agents/conversation_agent_factory.py @@ -34,11 +34,12 @@ async def create_agent(cls, config): agent_name = f"KM-ConversationKnowledgeAgent-{config.solution_name}" agent_instructions = '''You are a helpful assistant. - Always return the citations as is in final response. - Always return citation markers exactly as they appear in the source data, placed in the "answer" field at the correct location. Do not modify, convert, or simplify these markers. - Only include citation markers if their sources are present in the "citations" list. Only include sources in the "citations" list if they are used in the answer. Use the structure { "answer": "", "citations": [ {"url":"","title":""} ] }. + Always return citation markers exactly as they appear in the source data, placed in the "answer" field at the correct location. + Do not modify, convert, normalize, or simplify citation markers or the citations list; the plugin is solely responsible for citation formatting and content. + Only include citation markers if their sources are present in the "citations" list. Only include sources in the "citations" list if they are used in the answer. Use prior conversation history only for context or vague follow-up requests, and reuse it as a data source solely when the required values are explicitly listed, complete, and unambiguous; never reuse citation markers or sources from previous responses. + When using prior conversation history without calling tools/plugins, omit citation markers and the "citations" list; return only the "answer" field. If a request explicitly specifies metrics, entities, filters, or time ranges, or if the required data is not available in conversation history, treat it as a new data query and use the appropriate tools or plugins to retrieve the data before responding. If the question is unrelated to data but is conversational (e.g., greetings or follow-ups), respond appropriately using context. You MUST NOT generate a chart without numeric data. diff --git a/src/api/agents/search_agent_factory.py b/src/api/agents/search_agent_factory.py index 0fc4e61f3..8206e0a02 100644 --- a/src/api/agents/search_agent_factory.py +++ b/src/api/agents/search_agent_factory.py @@ -80,6 +80,7 @@ async def create_agent(cls, config): instructions="You are a helpful agent. Use the tools provided and always cite your sources.", tools=ai_search.definitions, tool_resources=ai_search.resources, + temperature=0.7 ) logger.info(f"Created new agent: {agent_name} (ID: {agent.id})") diff --git a/src/api/plugins/chat_with_data_plugin.py b/src/api/plugins/chat_with_data_plugin.py index cad732352..d809f32f6 100644 --- a/src/api/plugins/chat_with_data_plugin.py +++ b/src/api/plugins/chat_with_data_plugin.py @@ -134,16 +134,6 @@ async def get_call_insights( if run.status == "failed": print(f"Run failed: {run.last_error}") else: - def convert_citation_markers(text): - def replace_marker(match): - parts = match.group(1).split(":") - if len(parts) == 2 and parts[1].isdigit(): - new_index = int(parts[1]) + 1 - return f"[{new_index}]" - return match.group(0) - - return re.sub(r'【(\d+:\d+)†source】', replace_marker, text) - for run_step in project_client.agents.run_steps.list(thread_id=thread.id, run_id=run.id): if isinstance(run_step.step_details, RunStepToolCallDetails): for tool_call in run_step.step_details.tool_calls: @@ -157,10 +147,32 @@ def replace_marker(match): answer["citations"].append({"url": url, "title": title}) messages = project_client.agents.messages.list(thread_id=thread.id, order=ListSortOrder.ASCENDING) + + # Convert citation markers and extract used indices + citation_mapping: dict[int, int] = {} + + def replace_marker(match): + parts = match.group(1).split(":") + if len(parts) == 2 and parts[1].isdigit(): + original_index = int(parts[1]) + if original_index not in citation_mapping: + citation_mapping[original_index] = len(citation_mapping) + 1 + return f"[{citation_mapping[original_index]}]" + return match.group(0) + for msg in messages: if msg.role == MessageRole.AGENT and msg.text_messages: answer["answer"] = msg.text_messages[-1].text.value - answer["answer"] = convert_citation_markers(answer["answer"]) + answer["answer"] = re.sub(r'【(\d+:\d+)†source】', replace_marker, answer["answer"]) + + # Filter and reorder citations based on actual usage + if citation_mapping: + filtered_citations = [] + for original_idx in sorted(citation_mapping.keys()): + if original_idx < len(answer["citations"]): + filtered_citations.append(answer["citations"][original_idx]) + answer["citations"] = filtered_citations + break project_client.agents.threads.delete(thread_id=thread.id) except Exception: From 0122939baf510c78e0e4813a04ce5f62de32054b Mon Sep 17 00:00:00 2001 From: Pavan-Microsoft Date: Fri, 23 Jan 2026 12:08:29 +0530 Subject: [PATCH 15/19] fix: remove unnecessary whitespace and improve citation filtering logic --- src/api/plugins/chat_with_data_plugin.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/api/plugins/chat_with_data_plugin.py b/src/api/plugins/chat_with_data_plugin.py index d809f32f6..4dce28ec8 100644 --- a/src/api/plugins/chat_with_data_plugin.py +++ b/src/api/plugins/chat_with_data_plugin.py @@ -164,7 +164,6 @@ def replace_marker(match): if msg.role == MessageRole.AGENT and msg.text_messages: answer["answer"] = msg.text_messages[-1].text.value answer["answer"] = re.sub(r'【(\d+:\d+)†source】', replace_marker, answer["answer"]) - # Filter and reorder citations based on actual usage if citation_mapping: filtered_citations = [] @@ -172,7 +171,6 @@ def replace_marker(match): if original_idx < len(answer["citations"]): filtered_citations.append(answer["citations"][original_idx]) answer["citations"] = filtered_citations - break project_client.agents.threads.delete(thread_id=thread.id) except Exception: From 743ec53ddc7ddde658d9177e9eec9b27b1e0692a Mon Sep 17 00:00:00 2001 From: Pavan-Microsoft Date: Fri, 23 Jan 2026 14:46:15 +0530 Subject: [PATCH 16/19] fix: improve citation mapping logic to handle out-of-range markers and maintain citation order --- src/api/plugins/chat_with_data_plugin.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src/api/plugins/chat_with_data_plugin.py b/src/api/plugins/chat_with_data_plugin.py index 4dce28ec8..66c2f9ca1 100644 --- a/src/api/plugins/chat_with_data_plugin.py +++ b/src/api/plugins/chat_with_data_plugin.py @@ -155,10 +155,13 @@ def replace_marker(match): parts = match.group(1).split(":") if len(parts) == 2 and parts[1].isdigit(): original_index = int(parts[1]) - if original_index not in citation_mapping: - citation_mapping[original_index] = len(citation_mapping) + 1 - return f"[{citation_mapping[original_index]}]" - return match.group(0) + # Only map citations that exist in the citations array + if original_index < len(answer["citations"]): + if original_index not in citation_mapping: + citation_mapping[original_index] = len(citation_mapping) + 1 + return f"[{citation_mapping[original_index]}]" + # Return empty string for invalid/out-of-range markers + return "" for msg in messages: if msg.role == MessageRole.AGENT and msg.text_messages: @@ -166,8 +169,11 @@ def replace_marker(match): answer["answer"] = re.sub(r'【(\d+:\d+)†source】', replace_marker, answer["answer"]) # Filter and reorder citations based on actual usage if citation_mapping: + # Create reverse mapping to maintain citation order matching markers + reverse_mapping = {v: k for k, v in citation_mapping.items()} filtered_citations = [] - for original_idx in sorted(citation_mapping.keys()): + for new_idx in sorted(reverse_mapping.keys()): + original_idx = reverse_mapping[new_idx] if original_idx < len(answer["citations"]): filtered_citations.append(answer["citations"][original_idx]) answer["citations"] = filtered_citations From bb1d95fd7620a933a97c07daa431471c9d1ad50f Mon Sep 17 00:00:00 2001 From: Pavan-Microsoft Date: Fri, 23 Jan 2026 15:21:37 +0530 Subject: [PATCH 17/19] test: add unit tests for citation mapping with out-of-order and out-of-range markers --- src/api/plugins/chat_with_data_plugin.py | 3 + .../api/plugins/test_chat_with_data_plugin.py | 207 +++++++++++++++++- 2 files changed, 209 insertions(+), 1 deletion(-) diff --git a/src/api/plugins/chat_with_data_plugin.py b/src/api/plugins/chat_with_data_plugin.py index 66c2f9ca1..b927b2ef0 100644 --- a/src/api/plugins/chat_with_data_plugin.py +++ b/src/api/plugins/chat_with_data_plugin.py @@ -177,6 +177,9 @@ def replace_marker(match): if original_idx < len(answer["citations"]): filtered_citations.append(answer["citations"][original_idx]) answer["citations"] = filtered_citations + else: + # No valid citations found, clear the list to avoid mismatch + answer["citations"] = [] break project_client.agents.threads.delete(thread_id=thread.id) except Exception: diff --git a/src/tests/api/plugins/test_chat_with_data_plugin.py b/src/tests/api/plugins/test_chat_with_data_plugin.py index 2f6fa0094..5ef5d94b5 100644 --- a/src/tests/api/plugins/test_chat_with_data_plugin.py +++ b/src/tests/api/plugins/test_chat_with_data_plugin.py @@ -280,4 +280,209 @@ async def test_generate_chart_data_empty_response(self, mock_get_agent, chat_plu # Assert - should return empty string when no agent messages found assert result == "" - mock_client.agents.threads.delete.assert_called_once_with(thread_id="thread-id") \ No newline at end of file + mock_client.agents.threads.delete.assert_called_once_with(thread_id="thread-id") + + @pytest.mark.asyncio + @patch("plugins.chat_with_data_plugin.SearchAgentFactory.get_agent", new_callable=AsyncMock) + async def test_chat_with_multiple_citations_out_of_order(self, mock_get_agent, chat_plugin): + """Test citation mapping with multiple citations appearing out-of-order in the answer""" + # Mock agent and client setup + mock_agent = MagicMock() + mock_agent.id = "search-agent-id" + mock_client = MagicMock() + mock_get_agent.return_value = {"agent": mock_agent, "client": mock_client} + + # Mock thread creation + mock_thread = MagicMock() + mock_thread.id = "thread-id" + mock_client.agents.threads.create.return_value = mock_thread + + # Mock run creation and success status + mock_run = MagicMock() + mock_run.status = "succeeded" + mock_client.agents.runs.create_and_process.return_value = mock_run + + # Mock run steps with multiple citations (indices 0, 1, 2, 3) + mock_step = MagicMock() + mock_step.type = "tool_calls" + mock_step.step_details = MagicMock(spec=RunStepToolCallDetails) + + # Create 4 citations + mock_tool_call = { + 'azure_ai_search': { + 'output': str({ + "metadata": { + "get_urls": [ + "https://example.com/doc0", + "https://example.com/doc1", + "https://example.com/doc2", + "https://example.com/doc3" + ], + "titles": ["Doc 0", "Doc 1", "Doc 2", "Doc 3"] + } + }) + } + } + mock_step.step_details.tool_calls = [mock_tool_call] + mock_client.agents.run_steps.list.return_value = [mock_step] + + # Mock messages with out-of-order citation markers: [2], [0], [3], [1] + mock_message = MagicMock() + mock_message.role = MessageRole.AGENT + mock_text = MagicMock() + mock_text.text = MagicMock() + mock_text.text.value = "First point【0:2†source】, second【0:0†source】, third【0:3†source】, fourth【0:1†source】." + mock_message.text_messages = [mock_text] + mock_client.agents.messages.list.return_value = [mock_message] + + # Mock thread deletion + mock_client.agents.threads.delete.return_value = None + + # Call the method + result = await chat_plugin.get_call_insights("Test query") + + # Assert - markers should be renumbered to [1], [2], [3], [4] in order of appearance + # and citations should be reordered to match + assert result["answer"] == "First point[1], second[2], third[3], fourth[4]." + assert len(result["citations"]) == 4 + # Citations should be reordered: [2, 0, 3, 1] → positions in result + assert result["citations"][0]["url"] == "https://example.com/doc2" + assert result["citations"][0]["title"] == "Doc 2" + assert result["citations"][1]["url"] == "https://example.com/doc0" + assert result["citations"][1]["title"] == "Doc 0" + assert result["citations"][2]["url"] == "https://example.com/doc3" + assert result["citations"][2]["title"] == "Doc 3" + assert result["citations"][3]["url"] == "https://example.com/doc1" + assert result["citations"][3]["title"] == "Doc 1" + + @pytest.mark.asyncio + @patch("plugins.chat_with_data_plugin.SearchAgentFactory.get_agent", new_callable=AsyncMock) + async def test_chat_with_out_of_range_citation_markers(self, mock_get_agent, chat_plugin): + """Test citation mapping with gaps and out-of-range marker indices""" + # Mock agent and client setup + mock_agent = MagicMock() + mock_agent.id = "search-agent-id" + mock_client = MagicMock() + mock_get_agent.return_value = {"agent": mock_agent, "client": mock_client} + + # Mock thread creation + mock_thread = MagicMock() + mock_thread.id = "thread-id" + mock_client.agents.threads.create.return_value = mock_thread + + # Mock run creation and success status + mock_run = MagicMock() + mock_run.status = "succeeded" + mock_client.agents.runs.create_and_process.return_value = mock_run + + # Mock run steps with only 2 citations (indices 0, 1) + mock_step = MagicMock() + mock_step.type = "tool_calls" + mock_step.step_details = MagicMock(spec=RunStepToolCallDetails) + + mock_tool_call = { + 'azure_ai_search': { + 'output': str({ + "metadata": { + "get_urls": [ + "https://example.com/valid1", + "https://example.com/valid2" + ], + "titles": ["Valid Doc 1", "Valid Doc 2"] + } + }) + } + } + mock_step.step_details.tool_calls = [mock_tool_call] + mock_client.agents.run_steps.list.return_value = [mock_step] + + # Mock messages with valid and out-of-range markers: [1] (valid), [5] (invalid), [0] (valid), [10] (invalid) + mock_message = MagicMock() + mock_message.role = MessageRole.AGENT + mock_text = MagicMock() + mock_text.text = MagicMock() + mock_text.text.value = "Valid【0:1†source】, invalid【0:5†source】, another valid【0:0†source】, invalid again【0:10†source】." + mock_message.text_messages = [mock_text] + mock_client.agents.messages.list.return_value = [mock_message] + + # Mock thread deletion + mock_client.agents.threads.delete.return_value = None + + # Call the method + result = await chat_plugin.get_call_insights("Test query") + + # Assert - out-of-range markers should be removed, valid ones renumbered + assert result["answer"] == "Valid[1], invalid, another valid[2], invalid again." + assert len(result["citations"]) == 2 + # Only valid citations should be included, in order of appearance: [1, 0] + assert result["citations"][0]["url"] == "https://example.com/valid2" + assert result["citations"][0]["title"] == "Valid Doc 2" + assert result["citations"][1]["url"] == "https://example.com/valid1" + assert result["citations"][1]["title"] == "Valid Doc 1" + + @pytest.mark.asyncio + @patch("plugins.chat_with_data_plugin.SearchAgentFactory.get_agent", new_callable=AsyncMock) + async def test_chat_with_unused_citations(self, mock_get_agent, chat_plugin): + """Test that unused citations are filtered out""" + # Mock agent and client setup + mock_agent = MagicMock() + mock_agent.id = "search-agent-id" + mock_client = MagicMock() + mock_get_agent.return_value = {"agent": mock_agent, "client": mock_client} + + # Mock thread creation + mock_thread = MagicMock() + mock_thread.id = "thread-id" + mock_client.agents.threads.create.return_value = mock_thread + + # Mock run creation and success status + mock_run = MagicMock() + mock_run.status = "succeeded" + mock_client.agents.runs.create_and_process.return_value = mock_run + + # Mock run steps with 5 citations but only 2 will be used + mock_step = MagicMock() + mock_step.type = "tool_calls" + mock_step.step_details = MagicMock(spec=RunStepToolCallDetails) + + mock_tool_call = { + 'azure_ai_search': { + 'output': str({ + "metadata": { + "get_urls": [ + "https://example.com/doc0", + "https://example.com/doc1", + "https://example.com/doc2", # unused + "https://example.com/doc3", + "https://example.com/doc4" # unused + ], + "titles": ["Doc 0", "Doc 1", "Doc 2", "Doc 3", "Doc 4"] + } + }) + } + } + mock_step.step_details.tool_calls = [mock_tool_call] + mock_client.agents.run_steps.list.return_value = [mock_step] + + # Mock messages with only citations to indices 1, 3, 0 + mock_message = MagicMock() + mock_message.role = MessageRole.AGENT + mock_text = MagicMock() + mock_text.text = MagicMock() + mock_text.text.value = "First【0:1†source】, second【0:3†source】, third【0:0†source】." + mock_message.text_messages = [mock_text] + mock_client.agents.messages.list.return_value = [mock_message] + + # Mock thread deletion + mock_client.agents.threads.delete.return_value = None + + # Call the method + result = await chat_plugin.get_call_insights("Test query") + + # Assert - only cited documents should be in citations list + assert result["answer"] == "First[1], second[2], third[3]." + assert len(result["citations"]) == 3 + # Citations should only include the used ones: [1, 3, 0] + assert result["citations"][0]["url"] == "https://example.com/doc1" + assert result["citations"][1]["url"] == "https://example.com/doc3" + assert result["citations"][2]["url"] == "https://example.com/doc0" \ No newline at end of file From 5cd6b97ae04a2a96f2b3537bab741920db80e86d Mon Sep 17 00:00:00 2001 From: Pavan-Microsoft Date: Fri, 23 Jan 2026 15:22:16 +0530 Subject: [PATCH 18/19] test: add unit tests for handling out-of-range and repeated citation markers --- .../api/plugins/test_chat_with_data_plugin.py | 132 +++++++++++++++++- 1 file changed, 131 insertions(+), 1 deletion(-) diff --git a/src/tests/api/plugins/test_chat_with_data_plugin.py b/src/tests/api/plugins/test_chat_with_data_plugin.py index 5ef5d94b5..1c42d2150 100644 --- a/src/tests/api/plugins/test_chat_with_data_plugin.py +++ b/src/tests/api/plugins/test_chat_with_data_plugin.py @@ -485,4 +485,134 @@ async def test_chat_with_unused_citations(self, mock_get_agent, chat_plugin): # Citations should only include the used ones: [1, 3, 0] assert result["citations"][0]["url"] == "https://example.com/doc1" assert result["citations"][1]["url"] == "https://example.com/doc3" - assert result["citations"][2]["url"] == "https://example.com/doc0" \ No newline at end of file + assert result["citations"][2]["url"] == "https://example.com/doc0" + + @pytest.mark.asyncio + @patch("plugins.chat_with_data_plugin.SearchAgentFactory.get_agent", new_callable=AsyncMock) + async def test_chat_with_all_out_of_range_citations_clears_list(self, mock_get_agent, chat_plugin): + """Test that citations list is cleared when all markers are out-of-range""" + # Mock agent and client setup + mock_agent = MagicMock() + mock_agent.id = "search-agent-id" + mock_client = MagicMock() + mock_get_agent.return_value = {"agent": mock_agent, "client": mock_client} + + # Mock thread creation + mock_thread = MagicMock() + mock_thread.id = "thread-id" + mock_client.agents.threads.create.return_value = mock_thread + + # Mock run creation and success status + mock_run = MagicMock() + mock_run.status = "succeeded" + mock_client.agents.runs.create_and_process.return_value = mock_run + + # Mock run steps with 2 citations + mock_step = MagicMock() + mock_step.type = "tool_calls" + mock_step.step_details = MagicMock(spec=RunStepToolCallDetails) + + mock_tool_call = { + 'azure_ai_search': { + 'output': str({ + "metadata": { + "get_urls": [ + "https://example.com/doc0", + "https://example.com/doc1" + ], + "titles": ["Doc 0", "Doc 1"] + } + }) + } + } + mock_step.step_details.tool_calls = [mock_tool_call] + mock_client.agents.run_steps.list.return_value = [mock_step] + + # Mock messages with only out-of-range markers + mock_message = MagicMock() + mock_message.role = MessageRole.AGENT + mock_text = MagicMock() + mock_text.text = MagicMock() + mock_text.text.value = "Invalid【0:5†source】 and another【0:10†source】." + mock_message.text_messages = [mock_text] + mock_client.agents.messages.list.return_value = [mock_message] + + # Mock thread deletion + mock_client.agents.threads.delete.return_value = None + + # Call the method + result = await chat_plugin.get_call_insights("Test query") + + # Assert - all markers removed and citations list should be empty + assert result["answer"] == "Invalid and another." + assert len(result["citations"]) == 0 + + @pytest.mark.asyncio + @patch("plugins.chat_with_data_plugin.SearchAgentFactory.get_agent", new_callable=AsyncMock) + async def test_chat_with_repeated_citation_markers(self, mock_get_agent, chat_plugin): + """Test that repeated/duplicate markers map to the same new index""" + # Mock agent and client setup + mock_agent = MagicMock() + mock_agent.id = "search-agent-id" + mock_client = MagicMock() + mock_get_agent.return_value = {"agent": mock_agent, "client": mock_client} + + # Mock thread creation + mock_thread = MagicMock() + mock_thread.id = "thread-id" + mock_client.agents.threads.create.return_value = mock_thread + + # Mock run creation and success status + mock_run = MagicMock() + mock_run.status = "succeeded" + mock_client.agents.runs.create_and_process.return_value = mock_run + + # Mock run steps with 3 citations + mock_step = MagicMock() + mock_step.type = "tool_calls" + mock_step.step_details = MagicMock(spec=RunStepToolCallDetails) + + mock_tool_call = { + 'azure_ai_search': { + 'output': str({ + "metadata": { + "get_urls": [ + "https://example.com/doc0", + "https://example.com/doc1", + "https://example.com/doc2" + ], + "titles": ["Doc 0", "Doc 1", "Doc 2"] + } + }) + } + } + mock_step.step_details.tool_calls = [mock_tool_call] + mock_client.agents.run_steps.list.return_value = [mock_step] + + # Mock messages with repeated markers: [1], [2], [1] again, [0], [1] again + mock_message = MagicMock() + mock_message.role = MessageRole.AGENT + mock_text = MagicMock() + mock_text.text = MagicMock() + mock_text.text.value = "First【0:1†source】, second【0:2†source】, repeat first【0:1†source】, third【0:0†source】, and again【0:1†source】." + mock_message.text_messages = [mock_text] + mock_client.agents.messages.list.return_value = [mock_message] + + # Mock thread deletion + mock_client.agents.threads.delete.return_value = None + + # Call the method + result = await chat_plugin.get_call_insights("Test query") + + # Assert - repeated markers should use the same new index + # Order of first appearance: [1], [2], [0] → renumbered to [1], [2], [3] + # All references to original [1] should become [1] + assert result["answer"] == "First[1], second[2], repeat first[1], third[3], and again[1]." + assert len(result["citations"]) == 3 + # Citations should be ordered by first appearance: [1, 2, 0] + assert result["citations"][0]["url"] == "https://example.com/doc1" + assert result["citations"][0]["title"] == "Doc 1" + assert result["citations"][1]["url"] == "https://example.com/doc2" + assert result["citations"][1]["title"] == "Doc 2" + assert result["citations"][2]["url"] == "https://example.com/doc0" + assert result["citations"][2]["title"] == "Doc 0" \ No newline at end of file From cb40a589453175aa8bdcd953a7c0549c71fa7600 Mon Sep 17 00:00:00 2001 From: Pavan-Microsoft Date: Fri, 23 Jan 2026 15:33:43 +0530 Subject: [PATCH 19/19] fix: streamline citation handling in test cases by using RunStepToolCallDetails directly --- .../api/plugins/test_chat_with_data_plugin.py | 165 ++++++++---------- 1 file changed, 77 insertions(+), 88 deletions(-) diff --git a/src/tests/api/plugins/test_chat_with_data_plugin.py b/src/tests/api/plugins/test_chat_with_data_plugin.py index 1c42d2150..eb73dbe34 100644 --- a/src/tests/api/plugins/test_chat_with_data_plugin.py +++ b/src/tests/api/plugins/test_chat_with_data_plugin.py @@ -304,26 +304,23 @@ async def test_chat_with_multiple_citations_out_of_order(self, mock_get_agent, c # Mock run steps with multiple citations (indices 0, 1, 2, 3) mock_step = MagicMock() - mock_step.type = "tool_calls" - mock_step.step_details = MagicMock(spec=RunStepToolCallDetails) - - # Create 4 citations - mock_tool_call = { - 'azure_ai_search': { - 'output': str({ - "metadata": { - "get_urls": [ - "https://example.com/doc0", - "https://example.com/doc1", - "https://example.com/doc2", - "https://example.com/doc3" - ], - "titles": ["Doc 0", "Doc 1", "Doc 2", "Doc 3"] - } - }) + mock_step.step_details = RunStepToolCallDetails(tool_calls=[ + { + 'azure_ai_search': { + 'output': str({ + "metadata": { + "get_urls": [ + "https://example.com/doc0", + "https://example.com/doc1", + "https://example.com/doc2", + "https://example.com/doc3" + ], + "titles": ["Doc 0", "Doc 1", "Doc 2", "Doc 3"] + } + }) + } } - } - mock_step.step_details.tool_calls = [mock_tool_call] + ]) mock_client.agents.run_steps.list.return_value = [mock_step] # Mock messages with out-of-order citation markers: [2], [0], [3], [1] @@ -377,23 +374,21 @@ async def test_chat_with_out_of_range_citation_markers(self, mock_get_agent, cha # Mock run steps with only 2 citations (indices 0, 1) mock_step = MagicMock() - mock_step.type = "tool_calls" - mock_step.step_details = MagicMock(spec=RunStepToolCallDetails) - - mock_tool_call = { - 'azure_ai_search': { - 'output': str({ - "metadata": { - "get_urls": [ - "https://example.com/valid1", - "https://example.com/valid2" - ], - "titles": ["Valid Doc 1", "Valid Doc 2"] - } - }) + mock_step.step_details = RunStepToolCallDetails(tool_calls=[ + { + 'azure_ai_search': { + 'output': str({ + "metadata": { + "get_urls": [ + "https://example.com/valid1", + "https://example.com/valid2" + ], + "titles": ["Valid Doc 1", "Valid Doc 2"] + } + }) + } } - } - mock_step.step_details.tool_calls = [mock_tool_call] + ]) mock_client.agents.run_steps.list.return_value = [mock_step] # Mock messages with valid and out-of-range markers: [1] (valid), [5] (invalid), [0] (valid), [10] (invalid) @@ -440,28 +435,26 @@ async def test_chat_with_unused_citations(self, mock_get_agent, chat_plugin): mock_run.status = "succeeded" mock_client.agents.runs.create_and_process.return_value = mock_run - # Mock run steps with 5 citations but only 2 will be used + # Mock run steps with 5 citations but only 3 will be used mock_step = MagicMock() - mock_step.type = "tool_calls" - mock_step.step_details = MagicMock(spec=RunStepToolCallDetails) - - mock_tool_call = { - 'azure_ai_search': { - 'output': str({ - "metadata": { - "get_urls": [ - "https://example.com/doc0", - "https://example.com/doc1", - "https://example.com/doc2", # unused - "https://example.com/doc3", - "https://example.com/doc4" # unused - ], - "titles": ["Doc 0", "Doc 1", "Doc 2", "Doc 3", "Doc 4"] - } - }) + mock_step.step_details = RunStepToolCallDetails(tool_calls=[ + { + 'azure_ai_search': { + 'output': str({ + "metadata": { + "get_urls": [ + "https://example.com/doc0", + "https://example.com/doc1", + "https://example.com/doc2", # unused + "https://example.com/doc3", + "https://example.com/doc4" # unused + ], + "titles": ["Doc 0", "Doc 1", "Doc 2", "Doc 3", "Doc 4"] + } + }) + } } - } - mock_step.step_details.tool_calls = [mock_tool_call] + ]) mock_client.agents.run_steps.list.return_value = [mock_step] # Mock messages with only citations to indices 1, 3, 0 @@ -509,23 +502,21 @@ async def test_chat_with_all_out_of_range_citations_clears_list(self, mock_get_a # Mock run steps with 2 citations mock_step = MagicMock() - mock_step.type = "tool_calls" - mock_step.step_details = MagicMock(spec=RunStepToolCallDetails) - - mock_tool_call = { - 'azure_ai_search': { - 'output': str({ - "metadata": { - "get_urls": [ - "https://example.com/doc0", - "https://example.com/doc1" - ], - "titles": ["Doc 0", "Doc 1"] - } - }) + mock_step.step_details = RunStepToolCallDetails(tool_calls=[ + { + 'azure_ai_search': { + 'output': str({ + "metadata": { + "get_urls": [ + "https://example.com/doc0", + "https://example.com/doc1" + ], + "titles": ["Doc 0", "Doc 1"] + } + }) + } } - } - mock_step.step_details.tool_calls = [mock_tool_call] + ]) mock_client.agents.run_steps.list.return_value = [mock_step] # Mock messages with only out-of-range markers @@ -569,24 +560,22 @@ async def test_chat_with_repeated_citation_markers(self, mock_get_agent, chat_pl # Mock run steps with 3 citations mock_step = MagicMock() - mock_step.type = "tool_calls" - mock_step.step_details = MagicMock(spec=RunStepToolCallDetails) - - mock_tool_call = { - 'azure_ai_search': { - 'output': str({ - "metadata": { - "get_urls": [ - "https://example.com/doc0", - "https://example.com/doc1", - "https://example.com/doc2" - ], - "titles": ["Doc 0", "Doc 1", "Doc 2"] - } - }) + mock_step.step_details = RunStepToolCallDetails(tool_calls=[ + { + 'azure_ai_search': { + 'output': str({ + "metadata": { + "get_urls": [ + "https://example.com/doc0", + "https://example.com/doc1", + "https://example.com/doc2" + ], + "titles": ["Doc 0", "Doc 1", "Doc 2"] + } + }) + } } - } - mock_step.step_details.tool_calls = [mock_tool_call] + ]) mock_client.agents.run_steps.list.return_value = [mock_step] # Mock messages with repeated markers: [1], [2], [1] again, [0], [1] again