diff --git a/code/backend/batch/local.settings.json.sample b/code/backend/batch/local.settings.json.sample index 95a22ee00..aa17a6712 100644 --- a/code/backend/batch/local.settings.json.sample +++ b/code/backend/batch/local.settings.json.sample @@ -2,7 +2,7 @@ "IsEncrypted": false, "Values": { "FUNCTIONS_WORKER_RUNTIME": "python", - "AzureWebJobsStorage": "", + "AzureWebJobsStorage__accountName": "", "MyBindingConnection": "", "AzureWebJobs.HttpExample.Disabled": "true" }, @@ -11,4 +11,4 @@ "CORS": "*", "CORSCredentials": false } - } \ No newline at end of file + } diff --git a/code/create_app.py b/code/create_app.py index 5fffb3308..2b40da5ae 100644 --- a/code/create_app.py +++ b/code/create_app.py @@ -9,12 +9,13 @@ from os import path import sys import re +from urllib.parse import quote + import requests from openai import AzureOpenAI, Stream, APIStatusError from openai.types.chat import ChatCompletionChunk from flask import Flask, Response, request, Request, jsonify from dotenv import load_dotenv -from urllib.parse import quote, urlparse from backend.batch.utilities.helpers.env_helper import EnvHelper from backend.batch.utilities.helpers.azure_search_helper import AzureSearchHelper from backend.batch.utilities.helpers.orchestrator_helper import Orchestrator diff --git a/code/tests/chat_history/test_postgresdbservice.py b/code/tests/chat_history/test_postgresdbservice.py index 7710f423e..b7c9d5222 100644 --- a/code/tests/chat_history/test_postgresdbservice.py +++ b/code/tests/chat_history/test_postgresdbservice.py @@ -39,7 +39,7 @@ async def test_connect(mock_credential, mock_connect, postgres_client, mock_conn database="test_db", password="mock_token", port=5432, - ssl="require", + ssl=True, ) assert postgres_client.conn == mock_connection diff --git a/code/tests/functional/tests/functions/integrated_vectorization/test_integrated_vectorization_resource_creation.py b/code/tests/functional/tests/functions/integrated_vectorization/test_integrated_vectorization_resource_creation.py index 99f0c42e4..7484d6290 100644 --- a/code/tests/functional/tests/functions/integrated_vectorization/test_integrated_vectorization_resource_creation.py +++ b/code/tests/functional/tests/functions/integrated_vectorization/test_integrated_vectorization_resource_creation.py @@ -102,6 +102,10 @@ def test_integrated_vectorization_datasouce_created( "container": { "name": f"{app_config.get_from_json('AZURE_BLOB_STORAGE_INFO','containerName')}" }, + "identity": { + "@odata.type": "#Microsoft.Azure.Search.DataUserAssignedIdentity", + "userAssignedIdentity": "" + }, "dataDeletionDetectionPolicy": { "@odata.type": "#Microsoft.Azure.Search.NativeBlobSoftDeleteDeletionDetectionPolicy" }, @@ -367,6 +371,10 @@ def test_integrated_vectorization_skillset_created( "resourceUri": f"https://localhost:{httpserver.port}/", "deploymentId": f"{app_config.get_from_json('AZURE_OPENAI_EMBEDDING_MODEL_INFO','model')}", "apiKey": f"{app_config.get('AZURE_OPENAI_API_KEY')}", + "authIdentity": { + "@odata.type": "#Microsoft.Azure.Search.DataUserAssignedIdentity", + "userAssignedIdentity": "" + }, }, ], "indexProjections": { diff --git a/code/tests/test_app.py b/code/tests/test_app.py index 1e2fe6240..ac05a129d 100644 --- a/code/tests/test_app.py +++ b/code/tests/test_app.py @@ -4,6 +4,7 @@ from unittest.mock import AsyncMock, MagicMock, Mock, patch +from azure.core.exceptions import ClientAuthenticationError, ResourceNotFoundError, ServiceRequestError from openai import RateLimitError, BadRequestError, InternalServerError import pytest from flask.testing import FlaskClient @@ -923,3 +924,228 @@ def test_conversation_azure_byod_returns_correct_response_when_streaming_without data == '{"id": "response.id", "model": "mock-openai-model", "created": 0, "object": "response.object", "choices": [{"messages": [{"role": "assistant", "content": "mock content"}]}]}\n' ) + + +class TestGetFile: + """Test the get_file endpoint for downloading files from blob storage.""" + + @patch("create_app.AzureBlobStorageClient") + def test_get_file_success(self, mock_blob_client_class, client): + """Test successful file download with proper headers.""" + # given + filename = "test_document.pdf" + file_content = b"Mock file content for PDF document" + + mock_blob_client = MagicMock() + mock_blob_client_class.return_value = mock_blob_client + mock_blob_client.file_exists.return_value = True + mock_blob_client.download_file.return_value = file_content + + # when + response = client.get(f"/api/files/{filename}") + + # then + assert response.status_code == 200 + assert response.data == file_content + assert response.headers["Content-Type"] == "application/pdf" + assert response.headers["Content-Disposition"] == f'inline; filename="{filename}"' + assert response.headers["Content-Length"] == str(len(file_content)) + assert response.headers["Cache-Control"] == "public, max-age=3600" + assert response.headers["X-Content-Type-Options"] == "nosniff" + assert response.headers["X-Frame-Options"] == "DENY" + assert response.headers["Content-Security-Policy"] == "default-src 'none'" + + # Verify blob client was initialized with correct container + mock_blob_client_class.assert_called_once_with(container_name="documents") + mock_blob_client.file_exists.assert_called_once_with(filename) + mock_blob_client.download_file.assert_called_once_with(filename) + + @patch("create_app.AzureBlobStorageClient") + def test_get_file_with_unknown_mime_type(self, mock_blob_client_class, client): + """Test file download with unknown file extension.""" + # given + filename = "test_file.unknownext" + file_content = b"Mock file content" + + mock_blob_client = MagicMock() + mock_blob_client_class.return_value = mock_blob_client + mock_blob_client.file_exists.return_value = True + mock_blob_client.download_file.return_value = file_content + + # when + response = client.get(f"/api/files/{filename}") + + # then + assert response.status_code == 200 + assert response.headers["Content-Type"] == "application/octet-stream" + + @patch("create_app.AzureBlobStorageClient") + def test_get_file_large_file_warning(self, mock_blob_client_class, client): + """Test that large files are handled properly with logging.""" + # given + filename = "large_document.pdf" + file_content = b"x" * (11 * 1024 * 1024) # 11MB file + + mock_blob_client = MagicMock() + mock_blob_client_class.return_value = mock_blob_client + mock_blob_client.file_exists.return_value = True + mock_blob_client.download_file.return_value = file_content + + # when + response = client.get(f"/api/files/{filename}") + + # then + assert response.status_code == 200 + assert len(response.data) == len(file_content) + + def test_get_file_empty_filename(self, client): + """Test error response when filename is empty.""" + # when + response = client.get("/api/files/") + + # then + # This should result in a 404 as the route won't match + assert response.status_code == 404 + + def test_get_file_invalid_filename_too_long(self, client): + """Test error response for filenames that are too long.""" + # given + filename = "a" * 256 # 256 characters, exceeds 255 limit + + # when + response = client.get(f"/api/files/{filename}") + + # then + assert response.status_code == 400 + assert response.json == {"error": "Filename too long"} + + @patch("create_app.AzureBlobStorageClient") + def test_get_file_not_exists_in_storage(self, mock_blob_client_class, client): + """Test error response when file doesn't exist in blob storage.""" + # given + filename = "nonexistent.pdf" + + mock_blob_client = MagicMock() + mock_blob_client_class.return_value = mock_blob_client + mock_blob_client.file_exists.return_value = False + + # when + response = client.get(f"/api/files/{filename}") + + # then + assert response.status_code == 404 + assert response.json == {"error": "File not found"} + mock_blob_client.file_exists.assert_called_once_with(filename) + mock_blob_client.download_file.assert_not_called() + + @patch("create_app.AzureBlobStorageClient") + def test_get_file_client_authentication_error(self, mock_blob_client_class, client): + """Test handling of Azure ClientAuthenticationError.""" + # given + filename = "test.pdf" + + mock_blob_client = MagicMock() + mock_blob_client_class.return_value = mock_blob_client + mock_blob_client.file_exists.side_effect = ClientAuthenticationError("Auth failed") + + # when + response = client.get(f"/api/files/{filename}") + + # then + assert response.status_code == 401 + assert response.json == {"error": "Authentication failed"} + + @patch("create_app.AzureBlobStorageClient") + def test_get_file_resource_not_found_error(self, mock_blob_client_class, client): + """Test handling of Azure ResourceNotFoundError.""" + # given + filename = "test.pdf" + + mock_blob_client = MagicMock() + mock_blob_client_class.return_value = mock_blob_client + mock_blob_client.file_exists.side_effect = ResourceNotFoundError("Resource not found") + + # when + response = client.get(f"/api/files/{filename}") + + # then + assert response.status_code == 404 + assert response.json == {"error": "File not found"} + + @patch("create_app.AzureBlobStorageClient") + def test_get_file_service_request_error(self, mock_blob_client_class, client): + """Test handling of Azure ServiceRequestError.""" + # given + filename = "test.pdf" + + mock_blob_client = MagicMock() + mock_blob_client_class.return_value = mock_blob_client + mock_blob_client.file_exists.side_effect = ServiceRequestError("Service unavailable") + + # when + response = client.get(f"/api/files/{filename}") + + # then + assert response.status_code == 503 + assert response.json == {"error": "Storage service unavailable"} + + @patch("create_app.AzureBlobStorageClient") + def test_get_file_unexpected_exception(self, mock_blob_client_class, client): + """Test handling of unexpected exceptions.""" + # given + filename = "test.pdf" + + mock_blob_client = MagicMock() + mock_blob_client_class.return_value = mock_blob_client + mock_blob_client.file_exists.side_effect = Exception("Unexpected error") + + # when + response = client.get(f"/api/files/{filename}") + + # then + assert response.status_code == 500 + assert response.json == {"error": "Internal server error"} + + @patch("create_app.AzureBlobStorageClient") + def test_get_file_download_exception(self, mock_blob_client_class, client): + """Test handling of exceptions during file download.""" + # given + filename = "test.pdf" + + mock_blob_client = MagicMock() + mock_blob_client_class.return_value = mock_blob_client + mock_blob_client.file_exists.return_value = True + mock_blob_client.download_file.side_effect = Exception("Download failed") + + # when + response = client.get(f"/api/files/{filename}") + + # then + assert response.status_code == 500 + assert response.json == {"error": "Internal server error"} + + def test_get_file_valid_filenames(self, client): + """Test that valid filenames with allowed characters pass validation.""" + # Mock the blob client to avoid actual Azure calls + with patch("create_app.AzureBlobStorageClient") as mock_blob_client_class: + mock_blob_client = MagicMock() + mock_blob_client_class.return_value = mock_blob_client + mock_blob_client.file_exists.return_value = True + mock_blob_client.download_file.return_value = b"test content" + + valid_filenames = [ + "document.pdf", + "file_name.txt", + "file-name.docx", + "file name.xlsx", + "test123.json", + "a.b", + "very_long_but_valid_filename_with_underscores.pdf" + ] + + for filename in valid_filenames: + # when + response = client.get(f"/api/files/{filename}") + + # then + assert response.status_code == 200, f"Failed for filename: {filename}" diff --git a/code/tests/utilities/helpers/test_azure_postgres_helper.py b/code/tests/utilities/helpers/test_azure_postgres_helper.py index fb908acab..d5cdae6c5 100644 --- a/code/tests/utilities/helpers/test_azure_postgres_helper.py +++ b/code/tests/utilities/helpers/test_azure_postgres_helper.py @@ -30,7 +30,7 @@ def test_create_search_client_success(self, mock_connect, mock_credential): "https://ossrdbms-aad.database.windows.net/.default" ) mock_connect.assert_called_once_with( - "host=mock_host user=mock_user dbname=mock_database password=mock-access-token" + "host=mock_host user=mock_user dbname=mock_database password=mock-access-token sslmode=require" ) @patch("backend.batch.utilities.helpers.azure_postgres_helper.psycopg2.connect") @@ -92,7 +92,7 @@ def test_get_vector_store_success(self, mock_cursor, mock_connect, mock_credenti # Assert self.assertEqual(results, mock_results) mock_connect.assert_called_once_with( - "host=mock_host user=mock_user dbname=mock_database password=mock-access-token" + "host=mock_host user=mock_user dbname=mock_database password=mock-access-token sslmode=require" ) @patch("backend.batch.utilities.helpers.azure_postgres_helper.get_azure_credential") diff --git a/code/tests/utilities/helpers/test_azure_search_helper.py b/code/tests/utilities/helpers/test_azure_search_helper.py index 53e71db78..3b37ed2d7 100644 --- a/code/tests/utilities/helpers/test_azure_search_helper.py +++ b/code/tests/utilities/helpers/test_azure_search_helper.py @@ -90,6 +90,7 @@ def env_helper_mock(): env_helper.AZURE_SEARCH_CONVERSATIONS_LOG_INDEX = ( AZURE_SEARCH_CONVERSATIONS_LOG_INDEX ) + env_helper.MANAGED_IDENTITY_CLIENT_ID = "mock-client-id" env_helper.USE_ADVANCED_IMAGE_PROCESSING = USE_ADVANCED_IMAGE_PROCESSING env_helper.is_auth_type_keys.return_value = True @@ -156,7 +157,7 @@ def test_creates_search_clients_with_rabc( AzureSearchHelper() # then - default_azure_credential_mock.assert_called_once_with() + default_azure_credential_mock.assert_called_once_with("mock-client-id") search_client_mock.assert_called_once_with( endpoint=AZURE_SEARCH_SERVICE, index_name=AZURE_SEARCH_INDEX, diff --git a/code/tests/utilities/helpers/test_secret_helper.py b/code/tests/utilities/helpers/test_secret_helper.py index 635064e7f..25eebaaa6 100644 --- a/code/tests/utilities/helpers/test_secret_helper.py +++ b/code/tests/utilities/helpers/test_secret_helper.py @@ -25,6 +25,7 @@ def test_get_secret_returns_value_from_secret_client_when_use_key_vault_is_true( secret_name = "MY_SECRET" expected_value = "" monkeypatch.setenv("USE_KEY_VAULT", "true") + monkeypatch.setenv("AZURE_KEY_VAULT_ENDPOINT", "https://test-vault.vault.azure.net/") secret_client.return_value.get_secret.return_value.value = expected_value secret_helper = SecretHelper() diff --git a/code/tests/utilities/integrated_vectorization/test_azure_search_datasource.py b/code/tests/utilities/integrated_vectorization/test_azure_search_datasource.py index 2fb2cece3..cc14aeaf0 100644 --- a/code/tests/utilities/integrated_vectorization/test_azure_search_datasource.py +++ b/code/tests/utilities/integrated_vectorization/test_azure_search_datasource.py @@ -6,6 +6,9 @@ from azure.search.documents.indexes._generated.models import ( NativeBlobSoftDeleteDeletionDetectionPolicy, ) +from azure.search.documents.indexes.models import ( + SearchIndexerDataUserAssignedIdentity, +) AZURE_AUTH_TYPE = "keys" AZURE_SEARCH_KEY = "mock-key" @@ -18,6 +21,8 @@ AZURE_BLOB_ACCOUNT_KEY = "mock-key" AZURE_SUBSCRIPTION_ID = "mock-subscriptionid" AZURE_RESOURCE_GROUP = "mock-resource-group" +AZURE_BLOB_CONTAINER_NAME = "mock-container-name" +MANAGED_IDENTITY_RESOURCE_ID = "/subscriptions/mock-sub/resourceGroups/mock-rg/providers/Microsoft.ManagedIdentity/userAssignedIdentities/mock-identity" @pytest.fixture(autouse=True) @@ -33,6 +38,13 @@ def env_helper_mock(): env_helper.AZURE_OPENAI_ENDPOINT = AZURE_OPENAI_ENDPOINT env_helper.AZURE_OPENAI_EMBEDDING_MODEL = AZURE_OPENAI_EMBEDDING_MODEL env_helper.AZURE_SEARCH_DATASOURCE_NAME = AZURE_SEARCH_DATASOURCE_NAME + env_helper.AZURE_BLOB_ACCOUNT_NAME = AZURE_BLOB_ACCOUNT_NAME + env_helper.AZURE_BLOB_ACCOUNT_KEY = AZURE_BLOB_ACCOUNT_KEY + env_helper.AZURE_SUBSCRIPTION_ID = AZURE_SUBSCRIPTION_ID + env_helper.AZURE_RESOURCE_GROUP = AZURE_RESOURCE_GROUP + env_helper.AZURE_BLOB_CONTAINER_NAME = AZURE_BLOB_CONTAINER_NAME + env_helper.MANAGED_IDENTITY_RESOURCE_ID = MANAGED_IDENTITY_RESOURCE_ID + env_helper.APP_ENV = "prod" yield env_helper @@ -89,6 +101,9 @@ def test_create_or_update_datasource_keys( connection_string=keys_datasource_connection, container=search_indexer_data_container_mock.return_value, data_deletion_detection_policy=NativeBlobSoftDeleteDeletionDetectionPolicy(), + identity=SearchIndexerDataUserAssignedIdentity( + user_assigned_identity=env_helper_mock.MANAGED_IDENTITY_RESOURCE_ID + ), ) @@ -123,4 +138,43 @@ def test_create_or_update_datasource_rbac( connection_string=rbac_datasource_connection, container=search_indexer_data_container_mock.return_value, data_deletion_detection_policy=NativeBlobSoftDeleteDeletionDetectionPolicy(), + identity=SearchIndexerDataUserAssignedIdentity( + user_assigned_identity=env_helper_mock.MANAGED_IDENTITY_RESOURCE_ID + ), + ) + + +def test_create_or_update_datasource_dev_environment( + search_indexer_client_mock: MagicMock, + search_indexer_data_container_mock: MagicMock, + env_helper_mock: MagicMock, + search_indexer_datasource_connection_mock: MagicMock, +): + # given + env_helper_mock.is_auth_type_keys.return_value = False + env_helper_mock.AZURE_AUTH_TYPE = "rbac" + env_helper_mock.APP_ENV = "dev" # Override for dev environment + rbac_datasource_connection = f"ResourceId=/subscriptions/{env_helper_mock.AZURE_SUBSCRIPTION_ID}/resourceGroups/{env_helper_mock.AZURE_RESOURCE_GROUP}/providers/Microsoft.Storage/storageAccounts/{env_helper_mock.AZURE_BLOB_ACCOUNT_NAME}/;" + + azure_search_iv_datasource_helper = AzureSearchDatasource(env_helper_mock) + + # when + azure_search_iv_datasource_helper.create_or_update_datasource() + + # then + + assert ( + azure_search_iv_datasource_helper.indexer_client + == search_indexer_client_mock.return_value + ) + search_indexer_data_container_mock.assert_called_once_with( + name=env_helper_mock.AZURE_BLOB_CONTAINER_NAME + ) + search_indexer_datasource_connection_mock.assert_called_once_with( + name=env_helper_mock.AZURE_SEARCH_DATASOURCE_NAME, + type="azureblob", + connection_string=rbac_datasource_connection, + container=search_indexer_data_container_mock.return_value, + data_deletion_detection_policy=NativeBlobSoftDeleteDeletionDetectionPolicy(), + identity=None, ) diff --git a/docs/LOCAL_DEPLOYMENT.md b/docs/LOCAL_DEPLOYMENT.md index 51e05a861..342ba10a7 100644 --- a/docs/LOCAL_DEPLOYMENT.md +++ b/docs/LOCAL_DEPLOYMENT.md @@ -109,11 +109,14 @@ You need to assign the following roles to your `PRINCIPALID` (you can get your ' | Role | GUID | |----|----| -| Cognitive Services OpenAI Contributor | a001fd3d-188f-4b5d-821b-7da978bf7442 | -| Search Service Contributor | 7ca78c08-252a-4471-8644-bb5ff32d4ba0 | +| Cognitive Services OpenAI User | 5e0bd9bd-7b93-4f28-af87-19fc36ad61bd | +| Cognitive Services User | a97b65f3-24c7-4388-baec-2e87135dc908 | +| Cosmos DB SQL Data Contributor | 00000000-0000-0000-0000-000000000002 ([How to assign](https://learn.microsoft.com/en-us/azure/cosmos-db/how-to-setup-rbac#role-assignments)) | +| Key Vault Secrets User | 4633458b-17de-408a-b874-0445c86b69e6 | | Search Index Data Contributor | 8ebe5a00-799e-43f5-93ac-243d3dce84a7 | -| Storage Blob Data Reader | 2a2b9908-6ea1-4ae2-8e65-a410df84e7d1 | -| Reader | acdd72a7-3385-48ef-bd42-f606fba81ae7 | +| Search Service Contributor | 7ca78c08-252a-4471-8644-bb5ff32d4ba0 | +| Storage Blob Data Contributor | ba92f5b4-2d11-453d-a403-e96b0029c9fe | +| Storage Queue Data Contributor | 974c5e8b-45b9-4653-ba55-5f855dd0fb88 | ### Programatically assign roles You can also update the `principalId` value with your own principalId in the `main.bicep` file. @@ -204,7 +207,7 @@ poetry run func start Or use the [Azure Functions VS Code extension](https://marketplace.visualstudio.com/items?itemName=ms-azuretools.vscode-azurefunctions). #### Debugging the batch processing functions locally -Rename the file `local.settings.json.sample` in the `batch` folder to `local.settings.json` and update the `AzureWebJobsStorage` value with the storage account connection string. +Rename the file `local.settings.json.sample` in the `batch` folder to `local.settings.json` and update the `AzureWebJobsStorage__accountName` value with the storage account name. Copy the .env file from [previous section](#local-debugging) to the `batch` folder. @@ -284,7 +287,7 @@ Execute the above [shell command](#L81) to run the function locally. You may nee |AZURE_SPEECH_SERVICE_KEY | | The key of the Azure Speech service| |AZURE_SPEECH_SERVICE_NAME | | The name of the Azure Speech service| |AZURE_SPEECH_SERVICE_REGION | | The region (location) of the Azure Speech service| -|AzureWebJobsStorage||The connection string to the Azure Blob Storage for the Azure Functions Batch processing| +|AzureWebJobsStorage__accountName||The name of the Azure Blob Storage account for the Azure Functions Batch processing| |BACKEND_URL||The URL for the Backend Batch Azure Function. Use http://localhost:7071 for local execution| |CONVERSATION_FLOW | custom | Chat conversation type: custom or byod (Bring Your Own Data)| |DATABASE_TYPE | PostgreSQL | The type of database to deploy (cosmos or postgres)| diff --git a/infra/main.json b/infra/main.json index 433e477ad..ae7a2f327 100644 --- a/infra/main.json +++ b/infra/main.json @@ -6,7 +6,7 @@ "_generator": { "name": "bicep", "version": "0.37.4.10188", - "templateHash": "14519242492847010300" + "templateHash": "8602337238276383006" } }, "parameters": { @@ -55487,7 +55487,7 @@ "_generator": { "name": "bicep", "version": "0.37.4.10188", - "templateHash": "68114798306146084" + "templateHash": "15145535415197431472" } }, "parameters": { @@ -55526,7 +55526,7 @@ } }, "variables": { - "wookbookContents": "{\"version\":\"Notebook/1.0\",\"items\":[{\"type\":1,\"content\":\"@{json=# Chat With Your Data Monitoring}\",\"name\":\"Heading\"},{\"type\":9,\"content\":\"@{version=KqlParameterItem/1.0; crossComponentResources=System.Object[]; parameters=System.Object[]; style=pills; queryType=1; resourceType=microsoft.resourcegraph/resources}\",\"conditionalVisibility\":\"@{parameterName=never; comparison=isEqualTo; value=show}\",\"name\":\"Resource Parameters\"},{\"type\":9,\"content\":\"@{version=KqlParameterItem/1.0; crossComponentResources=System.Object[]; parameters=System.Object[]; style=pills; queryType=1; resourceType=microsoft.resourcegraph/resources}\",\"name\":\"Time Picker\"},{\"type\":11,\"content\":\"@{version=LinkItem/1.0; style=tabs; links=System.Object[]}\",\"name\":\"links - 4\"},{\"type\":12,\"content\":\"@{version=NotebookGroup/1.0; groupType=editable; items=System.Object[]}\",\"conditionalVisibility\":\"@{parameterName=selTab; comparison=isEqualTo; value=Operations}\",\"name\":\"Operations Group\"},{\"type\":12,\"content\":\"@{version=NotebookGroup/1.0; groupType=editable; items=System.Object[]}\",\"conditionalVisibility\":\"@{parameterName=selTab; comparison=isEqualTo; value=Resources}\",\"name\":\"Resources Group\"},{\"type\":12,\"content\":\"@{version=NotebookGroup/1.0; groupType=editable; items=System.Object[]}\",\"conditionalVisibility\":\"@{parameterName=selTab; comparison=isEqualTo; value=Open AI}\",\"name\":\"Open AI Group\"},{\"type\":12,\"content\":\"@{version=NotebookGroup/1.0; groupType=editable; items=System.Object[]}\",\"conditionalVisibility\":\"@{parameterName=selTab; comparison=isEqualTo; value=AI Search}\",\"name\":\"Search Group\"},{\"type\":12,\"content\":\"@{version=NotebookGroup/1.0; groupType=editable; items=System.Object[]}\",\"conditionalVisibility\":\"@{parameterName=selTab; comparison=isEqualTo; value=Storage}\",\"name\":\"Storage Group\"}],\"fallbackResourceIds\":[\"azure monitor\"],\"styleSettings\":{\"paddingStyle\":\"narrow\",\"spacingStyle\":\"narrow\"},\"$schema\":\"https://github.com/Microsoft/Application-Insights-Workbooks/blob/master/schema/workbook.json\"}\r\n", + "wookbookContents": "{\"version\":\"Notebook/1.0\",\"items\":[{\"type\":1,\"content\":\"@{json=# Chat With Your Data Monitoring}\",\"name\":\"Heading\"},{\"type\":9,\"content\":\"@{version=KqlParameterItem/1.0; crossComponentResources=System.Object[]; parameters=System.Object[]; style=pills; queryType=1; resourceType=microsoft.resourcegraph/resources}\",\"conditionalVisibility\":\"@{parameterName=never; comparison=isEqualTo; value=show}\",\"name\":\"Resource Parameters\"},{\"type\":9,\"content\":\"@{version=KqlParameterItem/1.0; crossComponentResources=System.Object[]; parameters=System.Object[]; style=pills; queryType=1; resourceType=microsoft.resourcegraph/resources}\",\"name\":\"Time Picker\"},{\"type\":11,\"content\":\"@{version=LinkItem/1.0; style=tabs; links=System.Object[]}\",\"name\":\"links - 4\"},{\"type\":12,\"content\":\"@{version=NotebookGroup/1.0; groupType=editable; items=System.Object[]}\",\"conditionalVisibility\":\"@{parameterName=selTab; comparison=isEqualTo; value=Operations}\",\"name\":\"Operations Group\"},{\"type\":12,\"content\":\"@{version=NotebookGroup/1.0; groupType=editable; items=System.Object[]}\",\"conditionalVisibility\":\"@{parameterName=selTab; comparison=isEqualTo; value=Resources}\",\"name\":\"Resources Group\"},{\"type\":12,\"content\":\"@{version=NotebookGroup/1.0; groupType=editable; items=System.Object[]}\",\"conditionalVisibility\":\"@{parameterName=selTab; comparison=isEqualTo; value=Open AI}\",\"name\":\"Open AI Group\"},{\"type\":12,\"content\":\"@{version=NotebookGroup/1.0; groupType=editable; items=System.Object[]}\",\"conditionalVisibility\":\"@{parameterName=selTab; comparison=isEqualTo; value=AI Search}\",\"name\":\"Search Group\"},{\"type\":12,\"content\":\"@{version=NotebookGroup/1.0; groupType=editable; items=System.Object[]}\",\"conditionalVisibility\":\"@{parameterName=selTab; comparison=isEqualTo; value=Storage}\",\"name\":\"Storage Group\"}],\"fallbackResourceIds\":[\"azure monitor\"],\"styleSettings\":{\"paddingStyle\":\"narrow\",\"spacingStyle\":\"narrow\"},\"$schema\":\"https://github.com/Microsoft/Application-Insights-Workbooks/blob/master/schema/workbook.json\"}\n", "wookbookContentsSubReplaced": "[replace(variables('wookbookContents'), '{subscription-id}', subscription().id)]", "wookbookContentsRGReplaced": "[replace(variables('wookbookContentsSubReplaced'), '{resource-group}', resourceGroup().name)]", "wookbookContentsAppServicePlanReplaced": "[replace(variables('wookbookContentsRGReplaced'), '{app-service-plan}', parameters('hostingPlanName'))]",