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": { diff --git a/azure_custom.yaml b/azure_custom.yaml new file mode 100644 index 000000000..95d88ac38 --- /dev/null +++ b/azure_custom.yaml @@ -0,0 +1,42 @@ +name: conversation-knowledge-mining + +requiredVersions: + azd: ">= 1.18.0" + +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 + webapp: + project: ./src/App + language: js + host: appservice + +hooks: + postprovision: + windows: + shell: pwsh + continueOnError: false + interactive: true + run: | + Write-Host "Web app URL: $env:WEB_APP_URL" -ForegroundColor Cyan + 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 + interactive: true + run: | + echo "Web app URL: $WEB_APP_URL" + echo "" + 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 new file mode 100644 index 000000000..12544b780 --- /dev/null +++ b/infra/main_custom.bicep @@ -0,0 +1,1519 @@ +// ========== main.bicep ========== // +targetScope = 'resourceGroup' + +@minLength(3) +@maxLength(16) +@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' } }) +@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 Azure OpenAI API.') +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-12-02_1084' + +@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-12-02_1084' + +@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@2025-04-01' = { + name: 'default' + properties: { + 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-convknowledgemining.${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.14.2' = 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.7.1' = 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.8.2' = 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}' + } + } +} + +// Jumpbox Virtual Machine +var jumpboxVmName = take('vm-jumpbox-${solutionSuffix}', 15) +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 + vmSize: vmSize ?? 'Standard_DS2_v2' + location: location + adminUsername: vmAdminUsername ?? 'JumpboxAdminUser' + adminPassword: vmAdminPassword ?? 'JumpboxAdminP@ssw0rd1234!' + tags: tags + availabilityZone: -1 + imageReference: { + publisher: 'microsoft-dsvm' + offer: 'dsvm-win-2022' + sku: 'winserver-2022' + 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.8.0' = [ + 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 + } + ] + } + } +] + +// 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.3' = { + 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.3' = { + 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 +} + +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: [ + for aiModelDeployment in aiModelDeployments: { + name: aiModelDeployment.name + model: { + format: aiModelDeployment.format + name: aiModelDeployment.model + version: aiModelDeployment.version + } + raiPolicyName: aiModelDeployment.raiPolicyName + sku: { + name: aiModelDeployment.sku.name + capacity: aiModelDeployment.sku.capacity + } + } + ] + } +} + +// AI Foundry: AI Services Content Understanding +var aiFoundryAiServicesCUResourceName = 'aif-${solutionSuffix}-cu' +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: aiServicesNameCu + 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: true + customSubDomainName: aiServicesNameCu + 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.12.0' = { + name: take('avm.res.search.search-service.${aiSearchName}', 64) + params: { + // Required parameters + name: aiSearchName + enableTelemetry: enableTelemetry + diagnosticSettings: enableMonitoring ? [ + { + workspaceResourceId: logAnalyticsWorkspaceResourceId + } + ] : null + disableLocalAuth: true + 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' + } +} + +// 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' + 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 + } +} + +// ========== Storage account module ========== // +var storageAccountName = 'st${solutionSuffix}' +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 ] + } + 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: enablePrivateNetworking ? 'Deny' : 'Allow' + virtualNetworkRules: [] + } + allowSharedKeyAccess: true + allowBlobPublicAccess: false + 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 + lastAccessTimeTrackingPolicyEnabled: false + containers: [ + { + name: 'data' + } + ] + } + } +} + +//========== 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.18.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' + ] + } + ] + } + ] + 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 + 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 + capabilitiesToAdd: enableRedundancy ? null : ['EnableServerless'] + enableAutomaticFailover: enableRedundancy + failoverLocations: enableRedundancy + ? [ + { + failoverPriority: 0 + isZoneRedundant: true + locationName: location + } + { + failoverPriority: 1 + isZoneRedundant: true + locationName: cosmosDbHaLocation + } + ] + : [ + { + locationName: location + failoverPriority: 0 + isZoneRedundant: false + } + ] + } + dependsOn: [storageAccount] +} + +//========== SQL Database module ========== // +var sqlServerResourceName = 'sql-${solutionSuffix}' +var sqlDbModuleName = 'sqldb-${solutionSuffix}' +module sqlDBModule 'br/public:avm/res/sql/server:0.21.1' = { + name: take('avm.res.sql.server.${sqlServerResourceName}', 64) + params: { + // Required parameters + name: sqlServerResourceName + enableTelemetry: enableTelemetry + // 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 + } + // Note: Zone redundancy is not supported for serverless SKUs (GP_S_Gen5) + zoneRedundant: enableRedundancy + } + ] + 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 } + } + ] +}''' + +// ========== Web App module ========== // +var backendWebSiteResourceName = 'api-${solutionSuffix}' +module webSiteBackend 'modules/web-sites.bicep' = { + name: take('module.web-sites.${backendWebSiteResourceName}', 64) + params: { + name: backendWebSiteResourceName + tags: union(tags, { 'azd-service-name': 'api' }) + location: location + kind: 'app,linux' + serverFarmResourceId: webServerFarm.?outputs.resourceId + managedIdentities: { + systemAssigned: true + userAssignedResourceIds: [ + backendUserAssignedIdentity.outputs.resourceId + ] + } + siteConfig: { + linuxFxVersion: 'PYTHON|3.11' + minTlsVersion: '1.2' + alwaysOn: true + appCommandLine: 'uvicorn app:app --host 0.0.0.0 --port 8000' + } + 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/' + 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: union(tags, { 'azd-service-name': 'webapp' }) + location: location + kind: 'app,linux' + serverFarmResourceId: webServerFarm.outputs.resourceId + siteConfig: { + linuxFxVersion: 'NODE|20-lts' + minTlsVersion: '1.2' + alwaysOn: true + appCommandLine: 'pm2 serve /home/site/wwwroot/build --no-daemon --spa' + } + configs: [ + { + name: 'appsettings' + properties: { + 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 + } + ] + vnetRouteAllEnabled: enablePrivateNetworking ? true : false + vnetImagePullEnabled: enablePrivateNetworking ? true : false + virtualNetworkSubnetId: enablePrivateNetworking ? virtualNetwork!.outputs.webSubnetResourceId : null + diagnosticSettings: enableMonitoring ? [{ workspaceResourceId: logAnalyticsWorkspaceResourceId }] : null + publicNetworkAccess: 'Enabled' + } +} + +// ========== Outputs ========== // +@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('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 + +@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 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) { 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..b927b2ef0 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,39 @@ 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]) + # 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: 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: + # Create reverse mapping to maintain citation order matching markers + reverse_mapping = {v: k for k, v in citation_mapping.items()} + filtered_citations = [] + 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 + 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..eb73dbe34 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,328 @@ 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.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_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.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_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 3 will be used + mock_step = MagicMock() + 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_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" + + @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.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_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.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_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