From 5bc6895b40dbd78751cf41d70b7aacc1556d635f Mon Sep 17 00:00:00 2001 From: Pamela Fox Date: Thu, 8 Jun 2023 18:22:54 +0000 Subject: [PATCH] Use data_utils --- .devcontainer/Dockerfile | 8 + .devcontainer/devcontainer.json | 33 + .gitignore | 4 +- README_azd.md | 70 ++ app.py | 10 +- azure.yaml | 34 + data/employee_handbook.pdf | Bin 0 -> 142977 bytes infra/abbreviations.json | 135 +++ infra/core/ai/cognitiveservices.bicep | 40 + infra/core/host/appservice.bicep | 100 ++ infra/core/host/appserviceplan.bicep | 21 + infra/core/search/search-services.bicep | 43 + infra/core/security/role.bicep | 20 + infra/core/storage/storage-account.bicep | 58 + infra/docprep.bicep | 111 ++ infra/main.bicep | 307 ++++++ infra/main.parameters.json | 48 + scripts/config.json | 13 - scripts/data_preparation.py | 328 ------ scripts/data_utils.py | 1278 +++++++++++----------- scripts/prepdocs.ps1 | 39 + scripts/prepdocs.py | 138 +++ scripts/prepdocs.sh | 21 + scripts/readme.md | 50 - scripts/requirements.txt | 19 +- start.sh | 39 + 26 files changed, 1925 insertions(+), 1042 deletions(-) create mode 100644 .devcontainer/Dockerfile create mode 100644 .devcontainer/devcontainer.json create mode 100644 README_azd.md create mode 100644 azure.yaml create mode 100644 data/employee_handbook.pdf create mode 100644 infra/abbreviations.json create mode 100644 infra/core/ai/cognitiveservices.bicep create mode 100644 infra/core/host/appservice.bicep create mode 100644 infra/core/host/appserviceplan.bicep create mode 100644 infra/core/search/search-services.bicep create mode 100644 infra/core/security/role.bicep create mode 100644 infra/core/storage/storage-account.bicep create mode 100644 infra/docprep.bicep create mode 100644 infra/main.bicep create mode 100644 infra/main.parameters.json delete mode 100644 scripts/config.json delete mode 100644 scripts/data_preparation.py create mode 100644 scripts/prepdocs.ps1 create mode 100644 scripts/prepdocs.py create mode 100755 scripts/prepdocs.sh delete mode 100644 scripts/readme.md create mode 100755 start.sh diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 0000000000..55b1852adb --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1,8 @@ +ARG VARIANT=bullseye +FROM mcr.microsoft.com/vscode/devcontainers/base:0-${VARIANT} +RUN export DEBIAN_FRONTEND=noninteractive \ + && apt-get update && apt-get install -y xdg-utils \ + && apt-get clean -y && rm -rf /var/lib/apt/lists/* +RUN mkdir -p /opt/microsoft/azd \ + && curl -L https://github.com/Azure/azure-dev/releases/download/azure-dev-cli_0.9.0-beta.2/azd-linux-arm64-beta.tar.gz | tar zxvf - -C /opt/microsoft/azd \ + && ln -s /opt/microsoft/azd/azd-linux-arm64 /usr/local/bin/azd \ No newline at end of file diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000000..ed2b84d7af --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,33 @@ +{ + "name": "Azure Developer CLI", + "build": { + "dockerfile": "Dockerfile", + "args": { + "VARIANT": "bullseye" + } + }, + "features": { + "ghcr.io/devcontainers/features/node:1": { + "version": "16", + "nodeGypDependencies": false + }, + "ghcr.io/devcontainers/features/azure-cli:1.0.8": {} + }, + "customizations": { + "vscode": { + "extensions": [ + "ms-azuretools.azure-dev", + "ms-azuretools.vscode-bicep", + "ms-python.python" + ] + } + }, + "forwardPorts": [ + 5000 + ], + "postCreateCommand": "", + "remoteUser": "vscode", + "hostRequirements": { + "memory": "8gb" + } +} \ No newline at end of file diff --git a/.gitignore b/.gitignore index bfa28926b6..b74a4cf685 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ .venv frontend/node_modules .env -static \ No newline at end of file +static +.azure/ +__pycache__/ diff --git a/README_azd.md b/README_azd.md new file mode 100644 index 0000000000..31d58680ac --- /dev/null +++ b/README_azd.md @@ -0,0 +1,70 @@ +# (Preview) Sample Chat App with AOAI + +## Deploying with the Azure Developer CLI + +> **IMPORTANT:** In order to deploy and run this example, you'll need an **Azure subscription with access enabled for the Azure OpenAI service**. You can request access [here](https://aka.ms/oaiapply). You can also visit [here](https://azure.microsoft.com/free/cognitive-search/) to get some free Azure credits to get you started. + +> **AZURE RESOURCE COSTS** by default this sample will create Azure App Service and Azure Cognitive Search resources that have a monthly cost, as well as Form Recognizer resource that has cost per document page. You can switch them to free versions of each of them if you want to avoid this cost by changing the parameters file under the infra folder (though there are some limits to consider; for example, you can have up to 1 free Cognitive Search resource per subscription, and the free Form Recognizer resource only analyzes the first 2 pages of each document.) + +### Prerequisites + +If you open this project in GitHub Codespaces or a local Dev Container, these will be available in the environment. +Otherwise, you need to install them locally. + +- [Azure Developer CLI](https://aka.ms/azure-dev/install) +- [Python 3+](https://www.python.org/downloads/) + - **Important**: Python and the pip package manager must be in the path in Windows for the setup scripts to work. +- [Node.js](https://nodejs.org/en/download/) +- [Git](https://git-scm.com/downloads) +- [Powershell 7+ (pwsh)](https://github.com/powershell/powershell) - For Windows users only. + - **Important**: Ensure you can run `pwsh.exe` from a PowerShell command. If this fails, you likely need to upgrade PowerShell. + +>NOTE: Your Azure Account must have `Microsoft.Authorization/roleAssignments/write` permissions, such as [User Access Administrator](https://learn.microsoft.com/azure/role-based-access-control/built-in-roles#user-access-administrator) or [Owner](https://learn.microsoft.com/azure/role-based-access-control/built-in-roles#owner). + +### Starting from scratch: + +If you don't have any pre-existing Azure services (i.e. OpenAI or Cognitive Search service), then you can provision +all resources from scratch by following these steps: + +1. Run `azd up` - This will provision Azure resources and deploy this sample to those resources, including building the search index based on the files found in the `./data` folder. +1. After the application has been successfully deployed you will see a URL printed to the console. Click that URL to interact with the application in your browser. + > NOTE: It may take a minute for the application to be fully deployed. If you see a "Python Developer" welcome screen, then wait a minute and refresh the page. + +### Use existing resources: + +If you have existing Azure resources that you want to reuse, then you must first set `azd` environment variables _before_ running `azd up`. + +Run the following commands based on what you want to customize: + +* `azd env set AZURE_OPENAI_RESOURCE {Name of existing OpenAI service}` +* `azd env set AZURE_OPENAI_RESOURCE_GROUP {Name of existing resource group that OpenAI service is provisioned to}` +* `azd env set AZURE_OPENAI_SKU_NAME {Name of OpenAI SKU}`. Defaults to 'S0'. +* `azd env set AZURE_SEARCH_SERVICE {Name of existing Cognitive Search service}` +* `azd env set AZURE_SEARCH_SERVICE_RESOURCE_GROUP {Name of existing resource group that Cognitive Search service is provisioned to}` +* `azd env set AZURE_SEARCH_SKU_NAME {Name of Cognitive Search SKY}`. Defaults to 'standard'. +* `azd env set AZURE_STORAGE_ACCOUNT {Name of existing Storage account}`. Used by prepdocs.py for uploading docs. +* `azd env set AZURE_STORAGE_ACCOUNT_RESOURCE_GROUP {Name of existing resource group that Storage account is provisioned to}`. +* `azd env set AZURE_FORMRECOGNIZER_SERVICE {Name of existing Form Recognizer service}`. Used by prepdocs.py for text extraction from docs. +* `azd env set AZURE_FORMRECOGNIZER_SERVICE_RESOURCE_GROUP {Name of existing resource group that Form Recognizer service is provisioned to}`. +* `azd env set AZURE_FORMRECOGNIZER_SKU_NAME {Name of Form Recognizer SKU}`. Defaults to 'S0'. + + +1. Run `azd up`. This will provision any missing Azure resources and deploy this sample to those resources, including building the search index based on the files found in the `./data` folder. +1. After the application has been successfully deployed you will see a URL printed to the console. Click that URL to interact with the application in your browser. + > NOTE: It may take a minute for the application to be fully deployed. If you see a "Python Developer" welcome screen, then wait a minute and refresh the page. + + +### Re-deploying changes + +If you make any changes to the app code (JS or Python), you can re-deploy the app code to App Service by running the `azd deploy` command. + +If you change any of the Bicep files in the infra folder, then you should re-run `azd up` to both provision resources and deploy code. + +### Running locally: + +1. Run `azd auth login` +3. Run `./start.cmd` or `./start.sh` to start the project locally. + +### Note + +>Note: The PDF documents used in this demo contain information generated using a language model (Azure OpenAI Service). The information contained in these documents is only for demonstration purposes and does not reflect the opinions or beliefs of Microsoft. Microsoft makes no representations or warranties of any kind, express or implied, about the completeness, accuracy, reliability, suitability or availability with respect to the information contained in this document. All rights reserved to Microsoft. diff --git a/app.py b/app.py index b1c48aab36..ec1f1ef3cd 100644 --- a/app.py +++ b/app.py @@ -10,6 +10,9 @@ app = Flask(__name__) +# setup basic logging +logging.basicConfig(level=logging.INFO) + @app.route("/", defaults={"path": "index.html"}) @app.route("/") def static_file(path): @@ -26,6 +29,7 @@ def static_file(path): AZURE_SEARCH_CONTENT_COLUMNS = os.environ.get("AZURE_SEARCH_CONTENT_COLUMNS") AZURE_SEARCH_FILENAME_COLUMN = os.environ.get("AZURE_SEARCH_FILENAME_COLUMN") AZURE_SEARCH_TITLE_COLUMN = os.environ.get("AZURE_SEARCH_TITLE_COLUMN") +print('title', AZURE_SEARCH_TITLE_COLUMN) AZURE_SEARCH_URL_COLUMN = os.environ.get("AZURE_SEARCH_URL_COLUMN") # AOAI Integration Settings @@ -44,7 +48,7 @@ def static_file(path): SHOULD_STREAM = True if AZURE_OPENAI_STREAM.lower() == "true" else False def is_chat_model(): - if 'gpt-4' in AZURE_OPENAI_MODEL_NAME.lower(): + if 'gpt-4' in AZURE_OPENAI_MODEL_NAME.lower() or 'gpt-35' in AZURE_OPENAI_MODEL_NAME.lower(): return True return False @@ -85,7 +89,7 @@ def prepare_body_headers_with_data(request): } ] } - + app.logger.info(body) chatgpt_url = f"https://{AZURE_OPENAI_RESOURCE}.openai.azure.com/openai/deployments/{AZURE_OPENAI_MODEL}" if is_chat_model(): chatgpt_url += "/chat/completions?api-version=2023-03-15-preview" @@ -138,7 +142,7 @@ def stream_with_data(body, headers, endpoint): deltaText = lineJson["choices"][0]["messages"][0]["delta"]["content"] if deltaText != "[DONE]": response["choices"][0]["messages"][1]["content"] += deltaText - + app.logger.info(response) yield json.dumps(response).replace("\n", "\\n") + "\n" except Exception as e: yield json.dumps({"error": str(e)}).replace("\n", "\\n") + "\n" diff --git a/azure.yaml b/azure.yaml new file mode 100644 index 0000000000..29ebf238d4 --- /dev/null +++ b/azure.yaml @@ -0,0 +1,34 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/Azure/azure-dev/main/schemas/v1.0/azure.yaml.json + +name: sample-app-aoai-chatgpt +metadata: + template: sample-app-aoai-chatgpt@0.0.1-beta +services: + backend: + project: . + language: py + host: appservice + hooks: + prepackage: + windows: + shell: pwsh + run: cd ./frontend;npm install;npm run build + interactive: true + continueOnError: false + posix: + shell: sh + run: cd ./frontend;npm install;npm run build + interactive: true + continueOnError: false +hooks: + postprovision: + windows: + shell: pwsh + run: $output = azd env get-values; Add-Content -Path .env -Value $output; + interactive: true + continueOnError: false + posix: + shell: sh + run: azd env get-values > .env + interactive: true + continueOnError: false \ No newline at end of file diff --git a/data/employee_handbook.pdf b/data/employee_handbook.pdf new file mode 100644 index 0000000000000000000000000000000000000000..878f36f7dd42f13540e6a35905cd45459dad8c49 GIT binary patch literal 142977 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

6B%J@a6P4kSn)+=2k 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 O+Ap}dpk4hZ?*9c*4?!#d literal 0 HcmV?d00001 diff --git a/infra/abbreviations.json b/infra/abbreviations.json new file mode 100644 index 0000000000..703e503867 --- /dev/null +++ b/infra/abbreviations.json @@ -0,0 +1,135 @@ +{ + "analysisServicesServers": "as", + "apiManagementService": "apim-", + "appConfigurationConfigurationStores": "appcs-", + "appManagedEnvironments": "cae-", + "appContainerApps": "ca-", + "authorizationPolicyDefinitions": "policy-", + "automationAutomationAccounts": "aa-", + "blueprintBlueprints": "bp-", + "blueprintBlueprintsArtifacts": "bpa-", + "cacheRedis": "redis-", + "cdnProfiles": "cdnp-", + "cdnProfilesEndpoints": "cdne-", + "cognitiveServicesAccounts": "cog-", + "cognitiveServicesFormRecognizer": "cog-fr-", + "cognitiveServicesTextAnalytics": "cog-ta-", + "computeAvailabilitySets": "avail-", + "computeCloudServices": "cld-", + "computeDiskEncryptionSets": "des", + "computeDisks": "disk", + "computeDisksOs": "osdisk", + "computeGalleries": "gal", + "computeSnapshots": "snap-", + "computeVirtualMachines": "vm", + "computeVirtualMachineScaleSets": "vmss-", + "containerInstanceContainerGroups": "ci", + "containerRegistryRegistries": "cr", + "containerServiceManagedClusters": "aks-", + "databricksWorkspaces": "dbw-", + "dataFactoryFactories": "adf-", + "dataLakeAnalyticsAccounts": "dla", + "dataLakeStoreAccounts": "dls", + "dataMigrationServices": "dms-", + "dBforMySQLServers": "mysql-", + "dBforPostgreSQLServers": "psql-", + "devicesIotHubs": "iot-", + "devicesProvisioningServices": "provs-", + "devicesProvisioningServicesCertificates": "pcert-", + "documentDBDatabaseAccounts": "cosmos-", + "eventGridDomains": "evgd-", + "eventGridDomainsTopics": "evgt-", + "eventGridEventSubscriptions": "evgs-", + "eventHubNamespaces": "evhns-", + "eventHubNamespacesEventHubs": "evh-", + "hdInsightClustersHadoop": "hadoop-", + "hdInsightClustersHbase": "hbase-", + "hdInsightClustersKafka": "kafka-", + "hdInsightClustersMl": "mls-", + "hdInsightClustersSpark": "spark-", + "hdInsightClustersStorm": "storm-", + "hybridComputeMachines": "arcs-", + "insightsActionGroups": "ag-", + "insightsComponents": "appi-", + "keyVaultVaults": "kv-", + "kubernetesConnectedClusters": "arck", + "kustoClusters": "dec", + "kustoClustersDatabases": "dedb", + "logicIntegrationAccounts": "ia-", + "logicWorkflows": "logic-", + "machineLearningServicesWorkspaces": "mlw-", + "managedIdentityUserAssignedIdentities": "id-", + "managementManagementGroups": "mg-", + "migrateAssessmentProjects": "migr-", + "networkApplicationGateways": "agw-", + "networkApplicationSecurityGroups": "asg-", + "networkAzureFirewalls": "afw-", + "networkBastionHosts": "bas-", + "networkConnections": "con-", + "networkDnsZones": "dnsz-", + "networkExpressRouteCircuits": "erc-", + "networkFirewallPolicies": "afwp-", + "networkFirewallPoliciesWebApplication": "waf", + "networkFirewallPoliciesRuleGroups": "wafrg", + "networkFrontDoors": "fd-", + "networkFrontdoorWebApplicationFirewallPolicies": "fdfp-", + "networkLoadBalancersExternal": "lbe-", + "networkLoadBalancersInternal": "lbi-", + "networkLoadBalancersInboundNatRules": "rule-", + "networkLocalNetworkGateways": "lgw-", + "networkNatGateways": "ng-", + "networkNetworkInterfaces": "nic-", + "networkNetworkSecurityGroups": "nsg-", + "networkNetworkSecurityGroupsSecurityRules": "nsgsr-", + "networkNetworkWatchers": "nw-", + "networkPrivateDnsZones": "pdnsz-", + "networkPrivateLinkServices": "pl-", + "networkPublicIPAddresses": "pip-", + "networkPublicIPPrefixes": "ippre-", + "networkRouteFilters": "rf-", + "networkRouteTables": "rt-", + "networkRouteTablesRoutes": "udr-", + "networkTrafficManagerProfiles": "traf-", + "networkVirtualNetworkGateways": "vgw-", + "networkVirtualNetworks": "vnet-", + "networkVirtualNetworksSubnets": "snet-", + "networkVirtualNetworksVirtualNetworkPeerings": "peer-", + "networkVirtualWans": "vwan-", + "networkVpnGateways": "vpng-", + "networkVpnGatewaysVpnConnections": "vcn-", + "networkVpnGatewaysVpnSites": "vst-", + "notificationHubsNamespaces": "ntfns-", + "notificationHubsNamespacesNotificationHubs": "ntf-", + "operationalInsightsWorkspaces": "log-", + "portalDashboards": "dash-", + "powerBIDedicatedCapacities": "pbi-", + "purviewAccounts": "pview-", + "recoveryServicesVaults": "rsv-", + "resourcesResourceGroups": "rg-", + "searchSearchServices": "srch-", + "serviceBusNamespaces": "sb-", + "serviceBusNamespacesQueues": "sbq-", + "serviceBusNamespacesTopics": "sbt-", + "serviceEndPointPolicies": "se-", + "serviceFabricClusters": "sf-", + "signalRServiceSignalR": "sigr", + "sqlManagedInstances": "sqlmi-", + "sqlServers": "sql-", + "sqlServersDataWarehouse": "sqldw-", + "sqlServersDatabases": "sqldb-", + "sqlServersDatabasesStretch": "sqlstrdb-", + "storageStorageAccounts": "st", + "storageStorageAccountsVm": "stvm", + "storSimpleManagers": "ssimp", + "streamAnalyticsCluster": "asa-", + "synapseWorkspaces": "syn", + "synapseWorkspacesAnalyticsWorkspaces": "synw", + "synapseWorkspacesSqlPoolsDedicated": "syndp", + "synapseWorkspacesSqlPoolsSpark": "synsp", + "timeSeriesInsightsEnvironments": "tsi-", + "webServerFarms": "plan-", + "webSitesAppService": "app-", + "webSitesAppServiceEnvironment": "ase-", + "webSitesFunctions": "func-", + "webStaticSites": "stapp-" +} diff --git a/infra/core/ai/cognitiveservices.bicep b/infra/core/ai/cognitiveservices.bicep new file mode 100644 index 0000000000..a9d0f49680 --- /dev/null +++ b/infra/core/ai/cognitiveservices.bicep @@ -0,0 +1,40 @@ +param name string +param location string = resourceGroup().location +param tags object = {} + +param customSubDomainName string = name +param deployments array = [] +param kind string = 'OpenAI' +param publicNetworkAccess string = 'Enabled' +param sku object = { + name: 'S0' +} + +resource account 'Microsoft.CognitiveServices/accounts@2022-10-01' = { + name: name + location: location + tags: tags + kind: kind + properties: { + customSubDomainName: customSubDomainName + publicNetworkAccess: publicNetworkAccess + } + sku: sku +} + +@batchSize(1) +resource deployment 'Microsoft.CognitiveServices/accounts/deployments@2022-10-01' = [for deployment in deployments: { + parent: account + name: deployment.name + properties: { + model: deployment.model + raiPolicyName: contains(deployment, 'raiPolicyName') ? deployment.raiPolicyName : null + scaleSettings: deployment.scaleSettings + } +}] + +output endpoint string = account.properties.endpoint +output id string = account.id +output name string = account.name +output skuName string = account.sku.name +output key string = account.listKeys().key1 diff --git a/infra/core/host/appservice.bicep b/infra/core/host/appservice.bicep new file mode 100644 index 0000000000..c90c2491a2 --- /dev/null +++ b/infra/core/host/appservice.bicep @@ -0,0 +1,100 @@ +param name string +param location string = resourceGroup().location +param tags object = {} + +// Reference Properties +param applicationInsightsName string = '' +param appServicePlanId string +param keyVaultName string = '' +param managedIdentity bool = !empty(keyVaultName) + +// Runtime Properties +@allowed([ + 'dotnet', 'dotnetcore', 'dotnet-isolated', 'node', 'python', 'java', 'powershell', 'custom' +]) +param runtimeName string +param runtimeNameAndVersion string = '${runtimeName}|${runtimeVersion}' +param runtimeVersion string + +// Microsoft.Web/sites Properties +param kind string = 'app,linux' + +// Microsoft.Web/sites/config +param allowedOrigins array = [] +param alwaysOn bool = true +param appCommandLine string = '' +param appSettings object = {} +param clientAffinityEnabled bool = false +param enableOryxBuild bool = contains(kind, 'linux') +param functionAppScaleLimit int = -1 +param linuxFxVersion string = runtimeNameAndVersion +param minimumElasticInstanceCount int = -1 +param numberOfWorkers int = -1 +param scmDoBuildDuringDeployment bool = false +param use32BitWorkerProcess bool = false +param ftpsState string = 'FtpsOnly' +param healthCheckPath string = '' + +resource appService 'Microsoft.Web/sites@2022-03-01' = { + name: name + location: location + tags: tags + kind: kind + properties: { + serverFarmId: appServicePlanId + siteConfig: { + linuxFxVersion: linuxFxVersion + alwaysOn: alwaysOn + ftpsState: ftpsState + appCommandLine: appCommandLine + numberOfWorkers: numberOfWorkers != -1 ? numberOfWorkers : null + minimumElasticInstanceCount: minimumElasticInstanceCount != -1 ? minimumElasticInstanceCount : null + use32BitWorkerProcess: use32BitWorkerProcess + functionAppScaleLimit: functionAppScaleLimit != -1 ? functionAppScaleLimit : null + healthCheckPath: healthCheckPath + cors: { + allowedOrigins: union([ 'https://portal.azure.com', 'https://ms.portal.azure.com' ], allowedOrigins) + } + } + clientAffinityEnabled: clientAffinityEnabled + httpsOnly: true + } + + identity: { type: managedIdentity ? 'SystemAssigned' : 'None' } + + resource configAppSettings 'config' = { + name: 'appsettings' + properties: union(appSettings, + { + SCM_DO_BUILD_DURING_DEPLOYMENT: string(scmDoBuildDuringDeployment) + ENABLE_ORYX_BUILD: string(enableOryxBuild) + }, + !empty(applicationInsightsName) ? { APPLICATIONINSIGHTS_CONNECTION_STRING: applicationInsights.properties.ConnectionString } : {}, + !empty(keyVaultName) ? { AZURE_KEY_VAULT_ENDPOINT: keyVault.properties.vaultUri } : {}) + } + + resource configLogs 'config' = { + name: 'logs' + properties: { + applicationLogs: { fileSystem: { level: 'Verbose' } } + detailedErrorMessages: { enabled: true } + failedRequestsTracing: { enabled: true } + httpLogs: { fileSystem: { enabled: true, retentionInDays: 1, retentionInMb: 35 } } + } + dependsOn: [ + configAppSettings + ] + } +} + +resource keyVault 'Microsoft.KeyVault/vaults@2022-07-01' existing = if (!(empty(keyVaultName))) { + name: keyVaultName +} + +resource applicationInsights 'Microsoft.Insights/components@2020-02-02' existing = if (!empty(applicationInsightsName)) { + name: applicationInsightsName +} + +output identityPrincipalId string = managedIdentity ? appService.identity.principalId : '' +output name string = appService.name +output uri string = 'https://${appService.properties.defaultHostName}' diff --git a/infra/core/host/appserviceplan.bicep b/infra/core/host/appserviceplan.bicep new file mode 100644 index 0000000000..c444f40651 --- /dev/null +++ b/infra/core/host/appserviceplan.bicep @@ -0,0 +1,21 @@ +param name string +param location string = resourceGroup().location +param tags object = {} + +param kind string = '' +param reserved bool = true +param sku object + +resource appServicePlan 'Microsoft.Web/serverfarms@2022-03-01' = { + name: name + location: location + tags: tags + sku: sku + kind: kind + properties: { + reserved: reserved + } +} + +output id string = appServicePlan.id +output name string = appServicePlan.name diff --git a/infra/core/search/search-services.bicep b/infra/core/search/search-services.bicep new file mode 100644 index 0000000000..0c6081b76d --- /dev/null +++ b/infra/core/search/search-services.bicep @@ -0,0 +1,43 @@ +param name string +param location string = resourceGroup().location +param tags object = {} + +param sku object = { + name: 'standard' +} + +param authOptions object = {} +param semanticSearch string = 'disabled' + +resource search 'Microsoft.Search/searchServices@2021-04-01-preview' = { + name: name + location: location + tags: tags + identity: { + type: 'SystemAssigned' + } + properties: { + authOptions: authOptions + disableLocalAuth: false + disabledDataExfiltrationOptions: [] + encryptionWithCmk: { + enforcement: 'Unspecified' + } + hostingMode: 'default' + networkRuleSet: { + bypass: 'None' + ipRules: [] + } + partitionCount: 1 + publicNetworkAccess: 'Enabled' + replicaCount: 1 + semanticSearch: semanticSearch + } + sku: sku +} + +output id string = search.id +output endpoint string = 'https://${name}.search.windows.net/' +output name string = search.name +output skuName string = sku.name +output adminKey string = search.listAdminKeys().primaryKey diff --git a/infra/core/security/role.bicep b/infra/core/security/role.bicep new file mode 100644 index 0000000000..dca01e1839 --- /dev/null +++ b/infra/core/security/role.bicep @@ -0,0 +1,20 @@ +param principalId string + +@allowed([ + 'Device' + 'ForeignGroup' + 'Group' + 'ServicePrincipal' + 'User' +]) +param principalType string = 'ServicePrincipal' +param roleDefinitionId string + +resource role 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(subscription().id, resourceGroup().id, principalId, roleDefinitionId) + properties: { + principalId: principalId + principalType: principalType + roleDefinitionId: resourceId('Microsoft.Authorization/roleDefinitions', roleDefinitionId) + } +} diff --git a/infra/core/storage/storage-account.bicep b/infra/core/storage/storage-account.bicep new file mode 100644 index 0000000000..b6dd989185 --- /dev/null +++ b/infra/core/storage/storage-account.bicep @@ -0,0 +1,58 @@ +param name string +param location string = resourceGroup().location +param tags object = {} + +@allowed([ 'Hot', 'Cool', 'Premium' ]) +param accessTier string = 'Hot' +param allowBlobPublicAccess bool = false +param allowCrossTenantReplication bool = true +param allowSharedKeyAccess bool = true +param defaultToOAuthAuthentication bool = false +param deleteRetentionPolicy object = {} +@allowed([ 'AzureDnsZone', 'Standard' ]) +param dnsEndpointType string = 'Standard' +param kind string = 'StorageV2' +param minimumTlsVersion string = 'TLS1_2' +@allowed([ 'Enabled', 'Disabled' ]) +param publicNetworkAccess string = 'Disabled' +param sku object = { name: 'Standard_LRS' } + +param containers array = [] + +resource storage 'Microsoft.Storage/storageAccounts@2022-05-01' = { + name: name + location: location + tags: tags + kind: kind + sku: sku + properties: { + accessTier: accessTier + allowBlobPublicAccess: allowBlobPublicAccess + allowCrossTenantReplication: allowCrossTenantReplication + allowSharedKeyAccess: allowSharedKeyAccess + defaultToOAuthAuthentication: defaultToOAuthAuthentication + dnsEndpointType: dnsEndpointType + minimumTlsVersion: minimumTlsVersion + networkAcls: { + bypass: 'AzureServices' + defaultAction: 'Allow' + } + publicNetworkAccess: publicNetworkAccess + } + + resource blobServices 'blobServices' = if (!empty(containers)) { + name: 'default' + properties: { + deleteRetentionPolicy: deleteRetentionPolicy + } + resource container 'containers' = [for container in containers: { + name: container.name + properties: { + publicAccess: contains(container, 'publicAccess') ? container.publicAccess : 'None' + } + }] + } +} + +output name string = storage.name +output primaryEndpoints object = storage.properties.primaryEndpoints diff --git a/infra/docprep.bicep b/infra/docprep.bicep new file mode 100644 index 0000000000..f31e62fa8f --- /dev/null +++ b/infra/docprep.bicep @@ -0,0 +1,111 @@ +targetScope = 'subscription' + +param resourceGroupName string +param location string +param tags object = {} +param principalId string +param resourceToken string + +// Storage and form recognizer: Used by document uploader / extractor +param storageAccountName string = '' +param storageResourceGroupName string = '' +param storageResourceGroupLocation string = location +param storageContainerName string = 'content' + +param formRecognizerServiceName string = '' +param formRecognizerResourceGroupName string = '' +param formRecognizerResourceGroupLocation string = location +param formRecognizerSkuName string = 'S0' + +var abbrs = loadJsonContent('abbreviations.json') + +resource resourceGroup 'Microsoft.Resources/resourceGroups@2021-04-01' existing = { + name: resourceGroupName +} + +resource storageResourceGroup 'Microsoft.Resources/resourceGroups@2021-04-01' existing = if (!empty(storageResourceGroupName)) { + name: !empty(storageResourceGroupName) ? storageResourceGroupName : resourceGroup.name +} + +resource formRecognizerResourceGroup 'Microsoft.Resources/resourceGroups@2021-04-01' existing = if (!empty(formRecognizerResourceGroupName)) { + name: !empty(formRecognizerResourceGroupName) ? formRecognizerResourceGroupName : resourceGroup.name +} + +module storage 'core/storage/storage-account.bicep' = { + name: 'storage' + scope: storageResourceGroup + params: { + name: !empty(storageAccountName) ? storageAccountName : '${abbrs.storageStorageAccounts}${resourceToken}' + location: storageResourceGroupLocation + tags: tags + publicNetworkAccess: 'Enabled' + sku: { + name: 'Standard_ZRS' + } + deleteRetentionPolicy: { + enabled: true + days: 2 + } + containers: [ + { + name: storageContainerName + publicAccess: 'None' + } + ] + } +} + +module formRecognizer 'core/ai/cognitiveservices.bicep' = { + name: 'formrecognizer' + scope: formRecognizerResourceGroup + params: { + name: !empty(formRecognizerServiceName) ? formRecognizerServiceName : '${abbrs.cognitiveServicesFormRecognizer}${resourceToken}' + kind: 'FormRecognizer' + location: formRecognizerResourceGroupLocation + tags: tags + sku: { + name: formRecognizerSkuName + } + } +} + +module storageRoleUser 'core/security/role.bicep' = { + scope: storageResourceGroup + name: 'storage-role-user' + params: { + principalId: principalId + roleDefinitionId: '2a2b9908-6ea1-4ae2-8e65-a410df84e7d1' + principalType: 'User' + } +} + +module storageContribRoleUser 'core/security/role.bicep' = { + scope: storageResourceGroup + name: 'storage-contribrole-user' + params: { + principalId: principalId + roleDefinitionId: 'ba92f5b4-2d11-453d-a403-e96b0029c9fe' + principalType: 'User' + } +} + +module formRecognizerRoleUser 'core/security/role.bicep' = { + scope: formRecognizerResourceGroup + name: 'formrecognizer-role-user' + params: { + principalId: principalId + roleDefinitionId: 'a97b65f3-24c7-4388-baec-2e87135dc908' + principalType: 'User' + } +} + +// Used by prepdocs +// Form recognizer +output AZURE_FORMRECOGNIZER_SERVICE string = formRecognizer.outputs.name +output AZURE_FORMRECOGNIZER_RESOURCE_GROUP string = formRecognizerResourceGroup.name +output AZURE_FORMRECOGNIZER_SKU_NAME string = formRecognizerSkuName + +// Storage +output AZURE_STORAGE_ACCOUNT string = storage.outputs.name +output AZURE_STORAGE_CONTAINER string = storageContainerName +output AZURE_STORAGE_RESOURCE_GROUP string = storageResourceGroup.name diff --git a/infra/main.bicep b/infra/main.bicep new file mode 100644 index 0000000000..27f005f4a4 --- /dev/null +++ b/infra/main.bicep @@ -0,0 +1,307 @@ +targetScope = 'subscription' + +@minLength(1) +@maxLength(64) +@description('Name of the the environment which is used to generate a short unique hash used in all resources.') +param environmentName string + +@minLength(1) +@description('Primary location for all resources') +param location string + +param appServicePlanName string = '' +param backendServiceName string = '' +param resourceGroupName string = '' + +param searchServiceName string = '' +param searchServiceResourceGroupName string = '' +param searchServiceResourceGroupLocation string = location +param searchServiceSkuName string = '' +param searchIndexName string = 'gptkbindex' +param searchUseSemanticSearch bool = false +param searchSemanticSearchConfig string = 'default' +param searchTopK int = 5 +param searchEnableInDomain bool = true +param searchContentColumns string = 'content' +param searchFilenameColumn string = 'filepath' +param searchTitleColumn string = 'title' +param searchUrlColumn string = 'url' + +param openAiResourceName string = '' +param openAiResourceGroupName string = '' +param openAiResourceGroupLocation string = location +param openAiSkuName string = '' +param openAIModel string = 'chat' +param openAIModelName string = 'gpt-35-turbo' +param openAITemperature int = 0 +param openAITopP int = 1 +param openAIMaxTokens int = 1000 +param openAIStopSequence string = '\n' +param openAISystemMessage string = 'You are an AI assistant that helps people find information.' +param openAIApiVersion string = '2023-06-01-preview' +param openAIStream bool = true + +// Used by prepdocs.py: Storage and form recognizer +param storageAccountName string = '' +param storageResourceGroupName string = '' +param storageResourceGroupLocation string = location +param storageContainerName string = 'content' +param formRecognizerServiceName string = '' +param formRecognizerResourceGroupName string = '' +param formRecognizerResourceGroupLocation string = location +param formRecognizerSkuName string = '' + +@description('Id of the user or app to assign application roles') +param principalId string = '' + +var abbrs = loadJsonContent('abbreviations.json') +var resourceToken = toLower(uniqueString(subscription().id, environmentName, location)) +var tags = { 'azd-env-name': environmentName } + +// Organize resources in a resource group +resource resourceGroup 'Microsoft.Resources/resourceGroups@2021-04-01' = { + name: !empty(resourceGroupName) ? resourceGroupName : '${abbrs.resourcesResourceGroups}${environmentName}' + location: location + tags: tags +} + +resource openAiResourceGroup 'Microsoft.Resources/resourceGroups@2021-04-01' existing = if (!empty(openAiResourceGroupName)) { + name: !empty(openAiResourceGroupName) ? openAiResourceGroupName : resourceGroup.name +} + +resource searchServiceResourceGroup 'Microsoft.Resources/resourceGroups@2021-04-01' existing = if (!empty(searchServiceResourceGroupName)) { + name: !empty(searchServiceResourceGroupName) ? searchServiceResourceGroupName : resourceGroup.name +} + + +// Create an App Service Plan to group applications under the same payment plan and SKU +module appServicePlan 'core/host/appserviceplan.bicep' = { + name: 'appserviceplan' + scope: resourceGroup + params: { + name: !empty(appServicePlanName) ? appServicePlanName : '${abbrs.webServerFarms}${resourceToken}' + location: location + tags: tags + sku: { + name: 'B1' + capacity: 1 + } + kind: 'linux' + } +} + +// The application frontend +module backend 'core/host/appservice.bicep' = { + name: 'web' + scope: resourceGroup + params: { + name: !empty(backendServiceName) ? backendServiceName : '${abbrs.webSitesAppService}backend-${resourceToken}' + location: location + tags: union(tags, { 'azd-service-name': 'backend' }) + appServicePlanId: appServicePlan.outputs.id + runtimeName: 'python' + runtimeVersion: '3.10' + scmDoBuildDuringDeployment: true + managedIdentity: true + appSettings: { + // search + AZURE_SEARCH_INDEX: searchIndexName + AZURE_SEARCH_SERVICE: searchService.outputs.name + AZURE_SEARCH_KEY: searchService.outputs.adminKey + AZURE_SEARCH_USE_SEMANTIC_SEARCH: searchUseSemanticSearch + AZURE_SEARCH_SEMANTIC_SEARCH_CONFIG: searchSemanticSearchConfig + AZURE_SEARCH_TOP_K: searchTopK + AZURE_SEARCH_ENABLE_IN_DOMAIN: searchEnableInDomain + AZURE_SEARCH_CONTENT_COLUMNS: searchContentColumns + AZURE_SEARCH_FILENAME_COLUMN: searchFilenameColumn + AZURE_SEARCH_TITLE_COLUMN: searchTitleColumn + AZURE_SEARCH_URL_COLUMN: searchUrlColumn + // openai + AZURE_OPENAI_RESOURCE: openAi.outputs.name + AZURE_OPENAI_MODEL: openAIModel + AZURE_OPENAI_MODEL_NAME: openAIModelName + AZURE_OPENAI_KEY: openAi.outputs.key + AZURE_OPENAI_TEMPERATURE: openAITemperature + AZURE_OPENAI_TOP_P: openAITopP + AZURE_OPENAI_MAX_TOKENS: openAIMaxTokens + AZURE_OPENAI_STOP_SEQUENCE: openAIStopSequence + AZURE_OPENAI_SYSTEM_MESSAGE: openAISystemMessage + AZURE_OPENAI_PREVIEW_API_VERSION: openAIApiVersion + AZURE_OPENAI_STREAM: openAIStream + } + } +} + + +module openAi 'core/ai/cognitiveservices.bicep' = { + name: 'openai' + scope: openAiResourceGroup + params: { + name: !empty(openAiResourceName) ? openAiResourceName : '${abbrs.cognitiveServicesAccounts}${resourceToken}' + location: openAiResourceGroupLocation + tags: tags + sku: { + name: !empty(openAiSkuName) ? openAiSkuName : 'S0' + } + deployments: [ + { + name: openAIModel + model: { + format: 'OpenAI' + name: openAIModelName + version: '0301' + } + scaleSettings: { + scaleType: 'Standard' + } + } + ] + } +} + +module searchService 'core/search/search-services.bicep' = { + name: 'search-service' + scope: searchServiceResourceGroup + params: { + name: !empty(searchServiceName) ? searchServiceName : 'gptkb-${resourceToken}' + location: searchServiceResourceGroupLocation + tags: tags + authOptions: { + aadOrApiKey: { + aadAuthFailureMode: 'http401WithBearerChallenge' + } + } + sku: { + name: !empty(searchServiceSkuName) ? searchServiceSkuName : 'standard' + } + semanticSearch: 'free' + } +} + + + +// USER ROLES +module openAiRoleUser 'core/security/role.bicep' = { + scope: openAiResourceGroup + name: 'openai-role-user' + params: { + principalId: principalId + roleDefinitionId: '5e0bd9bd-7b93-4f28-af87-19fc36ad61bd' + principalType: 'User' + } +} + +module searchRoleUser 'core/security/role.bicep' = { + scope: searchServiceResourceGroup + name: 'search-role-user' + params: { + principalId: principalId + roleDefinitionId: '1407120a-92aa-4202-b7e9-c0e197c71c8f' + principalType: 'User' + } +} + +module searchIndexDataContribRoleUser 'core/security/role.bicep' = { + scope: searchServiceResourceGroup + name: 'search-index-data-contrib-role-user' + params: { + principalId: principalId + roleDefinitionId: '8ebe5a00-799e-43f5-93ac-243d3dce84a7' + principalType: 'User' + } +} + +module searchServiceContribRoleUser 'core/security/role.bicep' = { + scope: searchServiceResourceGroup + name: 'search-service-contrib-role-user' + params: { + principalId: principalId + roleDefinitionId: '7ca78c08-252a-4471-8644-bb5ff32d4ba0' + principalType: 'User' + } +} + +// SYSTEM IDENTITIES +module openAiRoleBackend 'core/security/role.bicep' = { + scope: openAiResourceGroup + name: 'openai-role-backend' + params: { + principalId: backend.outputs.identityPrincipalId + roleDefinitionId: '5e0bd9bd-7b93-4f28-af87-19fc36ad61bd' + principalType: 'ServicePrincipal' + } +} + +module searchRoleBackend 'core/security/role.bicep' = { + scope: searchServiceResourceGroup + name: 'search-role-backend' + params: { + principalId: backend.outputs.identityPrincipalId + roleDefinitionId: '1407120a-92aa-4202-b7e9-c0e197c71c8f' + principalType: 'ServicePrincipal' + } +} + +// For doc prep + +module docPrepResources 'docprep.bicep' = { + name: 'docprep-resources' + params: { + location: location + resourceToken: resourceToken + tags: tags + principalId: principalId + resourceGroupName: resourceGroup.name + storageAccountName: storageAccountName + storageResourceGroupName: storageResourceGroupName + storageResourceGroupLocation: storageResourceGroupLocation + storageContainerName: storageContainerName + formRecognizerServiceName: formRecognizerServiceName + formRecognizerResourceGroupName: formRecognizerResourceGroupName + formRecognizerResourceGroupLocation: formRecognizerResourceGroupLocation + formRecognizerSkuName: !empty(formRecognizerSkuName) ? formRecognizerSkuName : 'S0' + } +} +output AZURE_LOCATION string = location +output AZURE_TENANT_ID string = tenant().tenantId +output AZURE_RESOURCE_GROUP string = resourceGroup.name + +output BACKEND_URI string = backend.outputs.uri + +// search +output AZURE_SEARCH_INDEX string = searchIndexName +output AZURE_SEARCH_SERVICE string = searchService.outputs.name +output AZURE_SEARCH_SERVICE_RESOURCE_GROUP string = searchServiceResourceGroup.name +output AZURE_SEARCH_SKU_NAME string = searchService.outputs.skuName +output AZURE_SEARCH_KEY string = searchService.outputs.adminKey +output AZURE_SEARCH_USE_SEMANTIC_SEARCH bool = searchUseSemanticSearch +output AZURE_SEARCH_SEMANTIC_SEARCH_CONFIG string = searchSemanticSearchConfig +output AZURE_SEARCH_TOP_K int = searchTopK +output AZURE_SEARCH_ENABLE_IN_DOMAIN bool = searchEnableInDomain +output AZURE_SEARCH_CONTENT_COLUMNS string = searchContentColumns +output AZURE_SEARCH_FILENAME_COLUMN string = searchFilenameColumn +output AZURE_SEARCH_TITLE_COLUMN string = searchTitleColumn +output AZURE_SEARCH_URL_COLUMN string = searchUrlColumn + +// openai +output AZURE_OPENAI_RESOURCE string = openAi.outputs.name +output AZURE_OPENAI_RESOURCE_GROUP string = openAiResourceGroup.name +output AZURE_OPENAI_MODEL string = openAIModel +output AZURE_OPENAI_MODEL_NAME string = openAIModelName +output AZURE_OPENAI_SKU_NAME string = openAi.outputs.skuName +output AZURE_OPENAI_KEY string = openAi.outputs.key +output AZURE_OPENAI_TEMPERATURE int = openAITemperature +output AZURE_OPENAI_TOP_P int = openAITopP +output AZURE_OPENAI_MAX_TOKENS int = openAIMaxTokens +output AZURE_OPENAI_STOP_SEQUENCE string = openAIStopSequence +output AZURE_OPENAI_SYSTEM_MESSAGE string = openAISystemMessage +output AZURE_OPENAI_PREVIEW_API_VERSION string = openAIApiVersion +output AZURE_OPENAI_STREAM bool = openAIStream + +// Used by prepdocs.py: +output AZURE_FORMRECOGNIZER_SERVICE string = docPrepResources.outputs.AZURE_FORMRECOGNIZER_SERVICE +output AZURE_FORMRECOGNIZER_RESOURCE_GROUP string = docPrepResources.outputs.AZURE_FORMRECOGNIZER_RESOURCE_GROUP +output AZURE_FORMRECOGNIZER_SKU_NAME string = docPrepResources.outputs.AZURE_FORMRECOGNIZER_SKU_NAME +output AZURE_STORAGE_ACCOUNT string = docPrepResources.outputs.AZURE_STORAGE_ACCOUNT +output AZURE_STORAGE_CONTAINER string = docPrepResources.outputs.AZURE_STORAGE_CONTAINER +output AZURE_STORAGE_RESOURCE_GROUP string = docPrepResources.outputs.AZURE_STORAGE_RESOURCE_GROUP diff --git a/infra/main.parameters.json b/infra/main.parameters.json new file mode 100644 index 0000000000..8b5ab944e4 --- /dev/null +++ b/infra/main.parameters.json @@ -0,0 +1,48 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "environmentName": { + "value": "${AZURE_ENV_NAME}" + }, + "location": { + "value": "${AZURE_LOCATION}" + }, + "principalId": { + "value": "${AZURE_PRINCIPAL_ID}" + }, + "openAiResourceName": { + "value": "${AZURE_OPENAI_RESOURCE}" + }, + "openAiResourceGroupName": { + "value": "${AZURE_OPENAI_RESOURCE_GROUP}" + }, + "openAiSkuName": { + "value": "${AZURE_OPENAI_SKU_NAME}" + }, + "searchServiceName": { + "value": "${AZURE_SEARCH_SERVICE}" + }, + "searchServiceResourceGroupName": { + "value": "${AZURE_SEARCH_SERVICE_RESOURCE_GROUP}" + }, + "searchServiceSkuName": { + "value": "${AZURE_SEARCH_SKU_NAME}" + }, + "storageAccountName": { + "value": "${AZURE_STORAGE_ACCOUNT}" + }, + "storageResourceGroupName": { + "value": "${AZURE_STORAGE_RESOURCE_GROUP}" + }, + "formRecognizerServiceName": { + "value": "${AZURE_FORMRECOGNIZER_SERVICE}" + }, + "formRecognizerResourceGroupName": { + "value": "${AZURE_FORMRECOGNIZER_RESOURCE_GROUP}" + }, + "formRecognizerSkuName": { + "value": "${AZURE_FORMRECOGNIZER_SKU_NAME}" + } + } +} diff --git a/scripts/config.json b/scripts/config.json deleted file mode 100644 index bef31a51c6..0000000000 --- a/scripts/config.json +++ /dev/null @@ -1,13 +0,0 @@ -[ - { - "data_path": "", - "location": "", - "subscription_id": "", - "resource_group": "", - "search_service_name": "", - "index_name": "", - "chunk_size": 1024, - "token_overlap": 128, - "semantic_config_name": "default" - } -] \ No newline at end of file diff --git a/scripts/data_preparation.py b/scripts/data_preparation.py deleted file mode 100644 index 8be06381a3..0000000000 --- a/scripts/data_preparation.py +++ /dev/null @@ -1,328 +0,0 @@ -"""Data Preparation Script for an Azure Cognitive Search Index.""" -import argparse -import json -import time -import requests -import subprocess -import dataclasses -from tqdm import tqdm -from azure.core.credentials import AzureKeyCredential -from azure.identity import AzureCliCredential -from data_utils import chunk_directory -from azure.search.documents import SearchClient -from azure.ai.formrecognizer import DocumentAnalysisClient - -def check_if_search_service_exists(search_service_name: str, - subscription_id: str, - resource_group: str, - credential = None): - """_summary_ - - Args: - search_service_name (str): _description_ - subscription_id (str): _description_ - resource_group (str): _description_ - credential: Azure credential to use for getting acs instance - """ - if credential is None: - raise ValueError("credential cannot be None") - url = ( - f"https://management.azure.com/subscriptions/{subscription_id}" - f"/resourceGroups/{resource_group}/providers/Microsoft.Search/searchServices" - f"/{search_service_name}?api-version=2021-04-01-preview" - ) - - headers = { - "Content-Type": "application/json", - "Authorization": f"Bearer {credential.get_token('https://management.azure.com/.default').token}", - } - - response = requests.get(url, headers=headers) - return response.status_code == 200 - - -def create_search_service( - search_service_name: str, - subscription_id: str, - resource_group: str, - location: str, - sku: str = "standard", - credential = None, -): - """_summary_ - - Args: - search_service_name (str): _description_ - subscription_id (str): _description_ - resource_group (str): _description_ - location (str): _description_ - credential: Azure credential to use for creating acs instance - - Raises: - Exception: _description_ - """ - if credential is None: - raise ValueError("credential cannot be None") - url = ( - f"https://management.azure.com/subscriptions/{subscription_id}" - f"/resourceGroups/{resource_group}/providers/Microsoft.Search/searchServices" - f"/{search_service_name}?api-version=2021-04-01-preview" - ) - - payload = { - "location": f"{location}", - "sku": {"name": sku}, - "properties": { - "replicaCount": 1, - "partitionCount": 1, - "hostingMode": "default", - "semanticSearch": "free", - }, - } - - headers = { - "Content-Type": "application/json", - "Authorization": f"Bearer {credential.get_token('https://management.azure.com/.default').token}", - } - - response = requests.put(url, json=payload, headers=headers) - if response.status_code != 201: - raise Exception( - f"Failed to create search service. Error: {response.text}") - -def create_or_update_search_index(service_name, subscription_id, resource_group, index_name, semantic_config_name, credential): - if credential is None: - raise ValueError("credential cannot be None") - admin_key = json.loads( - subprocess.run( - f"az search admin-key show --subscription {subscription_id} --resource-group {resource_group} --service-name {service_name}", - shell=True, - capture_output=True, - ).stdout - )["primaryKey"] - - url = f"https://{service_name}.search.windows.net/indexes/{index_name}?api-version=2021-04-30-Preview" - headers = { - "Content-Type": "application/json", - "api-key": admin_key, - } - - body = { - "fields": [ - { - "name": "id", - "type": "Edm.String", - "searchable": True, - "analyzer": "en.lucene", - "key": True, - }, - { - "name": "content", - "type": "Edm.String", - "searchable": True, - "sortable": False, - "facetable": False, - "filterable": False, - "analyzer": "en.lucene", - }, - { - "name": "title", - "type": "Edm.String", - "searchable": True, - "sortable": False, - "facetable": False, - "filterable": False, - "analyzer": "en.lucene", - }, - { - "name": "filepath", - "type": "Edm.String", - "searchable": True, - "sortable": False, - "facetable": False, - "filterable": False, - }, - { - "name": "url", - "type": "Edm.String", - "searchable": True, - }, - { - "name": "metadata", - "type": "Edm.String", - "searchable": True, - }, - ], - "suggesters": [], - "scoringProfiles": [], - "semantic": { - "configurations": [ - { - "name": semantic_config_name, - "prioritizedFields": { - "titleField": {"fieldName": "title"}, - "prioritizedContentFields": [{"fieldName": "content"}], - "prioritizedKeywordsFields": [], - }, - } - ] - }, - } - - response = requests.put(url, json=body, headers=headers) - if response.status_code == 201: - print(f"Created search index {index_name}") - elif response.status_code == 204: - print(f"Updated existing search index {index_name}") - else: - raise Exception(f"Failed to create search index. Error: {response.text}") - - return True - -def upload_documents_to_index(service_name, subscription_id, resource_group, index_name, docs, credential, upload_batch_size = 50): - if credential is None: - raise ValueError("credential cannot be None") - - to_upload_dicts = [] - - id = 0 - for document in docs: - d = dataclasses.asdict(document) - # add id to documents - d.update({"@search.action": "upload", "id": str(id)}) - to_upload_dicts.append(d) - id += 1 - - endpoint = "https://{}.search.windows.net/".format(service_name) - admin_key = json.loads( - subprocess.run( - f"az search admin-key show --subscription {subscription_id} --resource-group {resource_group} --service-name {service_name}", - shell=True, - capture_output=True, - ).stdout - )["primaryKey"] - - search_client = SearchClient( - endpoint=endpoint, - index_name=index_name, - credential=AzureKeyCredential(admin_key), - ) - # Upload the documents in batches of upload_batch_size - for i in tqdm(range(0, len(to_upload_dicts), upload_batch_size), desc="Indexing Chunks..."): - batch = to_upload_dicts[i: i + upload_batch_size] - results = search_client.upload_documents(documents=batch) - num_failures = 0 - errors = set() - for result in results: - if not result.succeeded: - print(f"Indexing Failed for {result.key} with ERROR: {result.error_message}") - num_failures += 1 - errors.add(result.error_message) - if num_failures > 0: - raise Exception(f"INDEXING FAILED for {num_failures} documents. Please recreate the index." - f"To Debug: PLEASE CHECK chunk_size and upload_batch_size. \n Error Messages: {list(errors)}") - -def validate_index(service_name, subscription_id, resource_group, index_name): - api_version = "2021-04-30-Preview" - admin_key = json.loads( - subprocess.run( - f"az search admin-key show --subscription {subscription_id} --resource-group {resource_group} --service-name {service_name}", - shell=True, - capture_output=True, - ).stdout - )["primaryKey"] - - headers = { - "Content-Type": "application/json", - "api-key": admin_key} - params = {"api-version": api_version} - url = f"https://{service_name}.search.windows.net/indexes/{index_name}/stats" - for retry_count in range(5): - response = requests.get(url, headers=headers, params=params) - - if response.status_code == 200: - response = response.json() - num_chunks = response['documentCount'] - if num_chunks==0 and retry_count < 4: - print("Index is empty. Waiting 60 seconds to check again...") - time.sleep(60) - elif num_chunks==0 and retry_count == 4: - print("Index is empty. Please investigate and re-index.") - else: - print(f"The index contains {num_chunks} chunks.") - average_chunk_size = response['storageSize']/num_chunks - print(f"The average chunk size of the index is {average_chunk_size} bytes.") - break - else: - if response.status_code==404: - print(f"The index does not seem to exist. Please make sure the index was created correctly, and that you are using the correct service and index names") - elif response.status_code==403: - print(f"Authentication Failure: Make sure you are using the correct key") - else: - print(f"Request failed. Please investigate. Status code: {response.status_code}") - break - -def create_index(config, credential, form_recognizer_client=None, use_layout=False): - service_name = config["search_service_name"] - subscription_id = config["subscription_id"] - resource_group = config["resource_group"] - location = config["location"] - index_name = config["index_name"] - - # check if search service exists, create if not - if check_if_search_service_exists(service_name, subscription_id, resource_group, credential): - print(f"Using existing search service {service_name}") - else: - print(f"Creating search service {service_name}") - create_search_service(service_name, subscription_id, resource_group, location, credential=credential) - - # create or update search index with compatible schema - if not create_or_update_search_index(service_name, subscription_id, resource_group, index_name, config["semantic_config_name"], credential): - raise Exception(f"Failed to create or update index {index_name}") - - # chunk directory - print("Chunking directory...") - result = chunk_directory(config["data_path"], num_tokens=config["chunk_size"], token_overlap=config.get("token_overlap",0), form_recognizer_client=form_recognizer_client, use_layout=use_layout) - - if len(result.chunks) == 0: - raise Exception("No chunks found. Please check the data path and chunk size.") - - print(f"Processed {result.total_files} files") - print(f"Unsupported formats: {result.num_unsupported_format_files} files") - print(f"Files with errors: {result.num_files_with_errors} files") - print(f"Found {len(result.chunks)} chunks") - - # upload documents to index - print("Uploading documents to index...") - upload_documents_to_index(service_name, subscription_id, resource_group, index_name, result.chunks, credential) - - # check if index is ready/validate index - print("Validating index...") - validate_index(service_name, subscription_id, resource_group, index_name) - print("Index validation completed") - -if __name__ == "__main__": - parser = argparse.ArgumentParser() - parser.add_argument("--config", type=str, help="Path to config file containing settings for data preparation") - parser.add_argument("--form-rec-resource", type=str, help="Name of your Form Recognizer resource to use for PDF cracking.") - parser.add_argument("--form-rec-key", type=str, help="Key for your Form Recognizer resource to use for PDF cracking.") - parser.add_argument("--form-rec-use-layout", default=False, action='store_true', help="Whether to use Layout model for PDF cracking, if False will use Read model.") - args = parser.parse_args() - - with open(args.config) as f: - config = json.load(f) - - credential = AzureCliCredential() - form_recognizer_client = None - - print("Data preparation script started") - if args.form_rec_resource and args.form_rec_key: - form_recognizer_client = DocumentAnalysisClient(endpoint=f"https://{args.form_rec_resource}.cognitiveservices.azure.com/", credential=AzureKeyCredential(args.form_rec_key)) - print(f"Using Form Recognizer resource {args.form_rec_resource} for PDF cracking, with the {'Layout' if args.form_rec_use_layout else 'Read'} model.") - - for index_config in config: - print("Preparing data for index:", index_config["index_name"]) - create_index(index_config, credential, form_recognizer_client, use_layout=args.form_rec_use_layout) - print("Data preparation for index", index_config["index_name"], "completed") - - print(f"Data preparation script completed. {len(config)} indexes updated.") \ No newline at end of file diff --git a/scripts/data_utils.py b/scripts/data_utils.py index f3526989d3..2c996b5afe 100644 --- a/scripts/data_utils.py +++ b/scripts/data_utils.py @@ -1,638 +1,640 @@ -"""Data utilities for index preparation.""" -import os -import ast -import markdown -import re -import tiktoken -import html -import json - -from tqdm import tqdm -from abc import ABC, abstractmethod -from bs4 import BeautifulSoup, Tag, NavigableString -from dataclasses import dataclass - -from typing import List, Dict, Optional, Generator, Tuple, Union -from langchain.text_splitter import MarkdownTextSplitter, RecursiveCharacterTextSplitter, PythonCodeTextSplitter - -FILE_FORMAT_DICT = { - "md": "markdown", - "txt": "text", - "html": "html", - "shtml": "html", - "htm": "html", - "py": "python", - "pdf": "pdf" - } - -SENTENCE_ENDINGS = [".", "!", "?"] -WORDS_BREAKS = list(reversed([",", ";", ":", " ", "(", ")", "[", "]", "{", "}", "\t", "\n"])) - -@dataclass -class Document(object): - """A data class for storing documents - - Attributes: - content (str): The content of the document. - id (Optional[str]): The id of the document. - title (Optional[str]): The title of the document. - filepath (Optional[str]): The filepath of the document. - url (Optional[str]): The url of the document. - metadata (Optional[Dict]): The metadata of the document. - """ - - content: str - id: Optional[str] = None - title: Optional[str] = None - filepath: Optional[str] = None - url: Optional[str] = None - metadata: Optional[Dict] = None - -def cleanup_content(content: str) -> str: - """Cleans up the given content using regexes - Args: - content (str): The content to clean up. - Returns: - str: The cleaned up content. - """ - output = re.sub(r"\n{2,}", "\n", content) - output = re.sub(r"[^\S\n]{2,}", " ", output) - output = re.sub(r"-{2,}", "--", output) - - return output.strip() - -class BaseParser(ABC): - """A parser parses content to produce a document.""" - - @abstractmethod - def parse(self, content: str, file_name: Optional[str] = None) -> Document: - """Parses the given content. - Args: - content (str): The content to parse. - file_name (str): The file name associated with the content. - Returns: - Document: The parsed document. - """ - pass - - def parse_file(self, file_path: str) -> Document: - """Parses the given file. - Args: - file_path (str): The file to parse. - Returns: - Document: The parsed document. - """ - with open(file_path, "r") as f: - return self.parse(f.read(), os.path.basename(file_path)) - - def parse_directory(self, directory_path: str) -> List[Document]: - """Parses the given directory. - Args: - directory_path (str): The directory to parse. - Returns: - List[Document]: List of parsed documents. - """ - documents = [] - for file_name in os.listdir(directory_path): - file_path = os.path.join(directory_path, file_name) - if os.path.isfile(file_path): - documents.append(self.parse_file(file_path)) - return documents - -class MarkdownParser(BaseParser): - """Parses Markdown content.""" - - def __init__(self) -> None: - super().__init__() - self._html_parser = HTMLParser() - - def parse(self, content: str, file_name: Optional[str] = None) -> Document: - """Parses the given content. - Args: - content (str): The content to parse. - file_name (str): The file name associated with the content. - Returns: - Document: The parsed document. - """ - html_content = markdown.markdown(content, extensions=['fenced_code', 'toc', 'tables', 'sane_lists']) - - return self._html_parser.parse(html_content, file_name) - - -class HTMLParser(BaseParser): - """Parses HTML content.""" - TITLE_MAX_TOKENS = 128 - NEWLINE_TEMPL = "" - - def __init__(self) -> None: - super().__init__() - self.token_estimator = TokenEstimator() - - def parse(self, content: str, file_name: Optional[str] = None) -> Document: - """Parses the given content. - Args: - content (str): The content to parse. - file_name (str): The file name associated with the content. - Returns: - Document: The parsed document. - """ - soup = BeautifulSoup(content, 'html.parser') - - # Extract the title - title = '' - if soup.title and soup.title.string: - title = soup.title.string - else: - # Try to find the first

tag - h1_tag = soup.find('h1') - if h1_tag: - title = h1_tag.get_text(strip=True) - else: - h2_tag = soup.find('h2') - if h2_tag: - title = h2_tag.get_text(strip=True) - if title is None or title == '': - # if title is still not found, guess using the next string - try: - title = next(soup.stripped_strings) - title = self.token_estimator.construct_tokens_with_size(title, self.TITLE_MAX_TOKENS) - - except StopIteration: - title = file_name - - # Helper function to process text nodes - def process_text(text): - return text.strip() - - # Helper function to process anchor tags - def process_anchor_tag(tag): - href = tag.get('href', '') - text = tag.get_text(strip=True) - return f'{text} ({href})' - - # Collect all text nodes and anchor tags in a list - elements = [] - - for elem in soup.descendants: - if isinstance(elem, (Tag, NavigableString)): - page_element: Union[Tag, NavigableString] = elem - if page_element.name in ['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'li', 'code']: - if elements and not elements[-1].endswith('\n'): - elements.append(self.NEWLINE_TEMPL) - if isinstance(page_element, str): - elements.append(process_text(page_element)) - elif page_element.name == 'a': - elements.append(process_anchor_tag(page_element)) - - - # Join the list into a single string and return but ensure that either of newlines or space are used. - result = '' - is_prev_newline = False - for elem in elements: - if elem: - if elem == self.NEWLINE_TEMPL: - result += "\n" - is_prev_newline = True - else: - if not is_prev_newline: - result += " " - else: - is_prev_newline = False - result += f"{elem}" - - if title is None: - title = '' # ensure no 'None' type title - return Document(content=cleanup_content(result), title=str(title)) - -class TextParser(BaseParser): - """Parses text content.""" - - def __init__(self) -> None: - super().__init__() - - def _get_first_alphanum_line(self, content: str) -> Optional[str]: - title = None - for line in content.splitlines(): - if any([c.isalnum() for c in line]): - title = line.strip() - break - return title - - def _get_first_line_with_property( - self, content: str, property: str = "title: " - ) -> Optional[str]: - title = None - for line in content.splitlines(): - if line.startswith(property): - title = line[len(property) :].strip() - break - return title - - def parse(self, content: str, file_name: Optional[str] = None) -> Document: - """Parses the given content. - Args: - content (str): The content to parse. - file_name (str): The file name associated with the content. - Returns: - Document: The parsed document. - """ - title = self._get_first_line_with_property( - content - ) or self._get_first_alphanum_line(content) - - return Document(content=cleanup_content(content), title=title or file_name) - - -class PythonParser(BaseParser): - def _get_topdocstring(self, text): - tree = ast.parse(text) - docstring = ast.get_docstring(tree) # returns top docstring - return docstring - - def parse(self, content: str, file_name: Optional[str] = None) -> Document: - """Parses the given content. - Args: - content (str): The content to parse. - file_name (str): The file name associated with the content. - Returns: - Document: The parsed document. - """ - docstring = self._get_topdocstring(content) - if docstring: - title = f"{file_name}: {docstring}" - else: - title = file_name - return Document(content=content, title=title) - - def __init__(self) -> None: - super().__init__() - -class ParserFactory: - def __init__(self): - self._parsers = { - "html": HTMLParser(), - "text": TextParser(), - "markdown": MarkdownParser(), - "python": PythonParser() - } - - @property - def supported_formats(self) -> List[str]: - "Returns a list of supported formats" - return list(self._parsers.keys()) - - def __call__(self, file_format: str) -> BaseParser: - parser = self._parsers.get(file_format, None) - if parser is None: - raise UnsupportedFormatError(f"{file_format} is not supported") - - return parser - -class TokenEstimator(object): - GPT2_TOKENIZER = tiktoken.get_encoding("gpt2") - - def estimate_tokens(self, text: str) -> int: - return len(self.GPT2_TOKENIZER.encode(text)) - - def construct_tokens_with_size(self, tokens: str, numofTokens: int) -> str: - newTokens = self.GPT2_TOKENIZER.decode( - self.GPT2_TOKENIZER.encode(tokens)[:numofTokens] - ) - return newTokens - -parser_factory = ParserFactory() -TOKEN_ESTIMATOR = TokenEstimator() - -class UnsupportedFormatError(Exception): - """Exception raised when a format is not supported by a parser.""" - - pass - -@dataclass -class ChunkingResult: - """Data model for chunking result - - Attributes: - chunks (List[Document]): List of chunks. - total_files (int): Total number of files. - num_unsupported_format_files (int): Number of files with unsupported format. - num_files_with_errors (int): Number of files with errors. - skipped_chunks (int): Number of chunks skipped. - """ - chunks: List[Document] - total_files: int - num_unsupported_format_files: int = 0 - num_files_with_errors: int = 0 - # some chunks might be skipped to small number of tokens - skipped_chunks: int = 0 - -def get_files_recursively(directory_path: str) -> List[str]: - """Gets all files in the given directory recursively. - Args: - directory_path (str): The directory to get files from. - Returns: - List[str]: List of file paths. - """ - file_paths = [] - for dirpath, _, files in os.walk(directory_path): - for file_name in files: - file_path = os.path.join(dirpath, file_name) - file_paths.append(file_path) - return file_paths - -def convert_escaped_to_posix(escaped_path): - windows_path = escaped_path.replace("\\\\", "\\") - posix_path = windows_path.replace("\\", "/") - return posix_path - -def _get_file_format(file_name: str, extensions_to_process: List[str]) -> Optional[str]: - """Gets the file format from the file name. - Returns None if the file format is not supported. - Args: - file_name (str): The file name. - extensions_to_process (List[str]): List of extensions to process. - Returns: - str: The file format. - """ - - # in case the caller gives us a file path - file_name = os.path.basename(file_name) - file_extension = file_name.split(".")[-1] - if file_extension not in extensions_to_process: - return None - return FILE_FORMAT_DICT.get(file_extension, None) - -def table_to_html(table): - table_html = "" - rows = [sorted([cell for cell in table.cells if cell.row_index == i], key=lambda cell: cell.column_index) for i in range(table.row_count)] - for row_cells in rows: - table_html += "" - for cell in row_cells: - tag = "th" if (cell.kind == "columnHeader" or cell.kind == "rowHeader") else "td" - cell_spans = "" - if cell.column_span > 1: cell_spans += f" colSpan={cell.column_span}" - if cell.row_span > 1: cell_spans += f" rowSpan={cell.row_span}" - table_html += f"<{tag}{cell_spans}>{html.escape(cell.content)}" - table_html +="" - table_html += "
" - return table_html - -def extract_pdf_content(file_path, form_recognizer_client, use_layout=False): - offset = 0 - page_map = [] - model = "prebuilt-layout" if use_layout else "prebuilt-read" - with open(file_path, "rb") as f: - poller = form_recognizer_client.begin_analyze_document(model, document = f) - form_recognizer_results = poller.result() - - for page_num, page in enumerate(form_recognizer_results.pages): - tables_on_page = [table for table in form_recognizer_results.tables if table.bounding_regions[0].page_number == page_num + 1] - - # (if using layout) mark all positions of the table spans in the page - page_offset = page.spans[0].offset - page_length = page.spans[0].length - table_chars = [-1]*page_length - for table_id, table in enumerate(tables_on_page): - for span in table.spans: - # replace all table spans with "table_id" in table_chars array - for i in range(span.length): - idx = span.offset - page_offset + i - if idx >=0 and idx < page_length: - table_chars[idx] = table_id - - # build page text by replacing charcters in table spans with table html if using layout - page_text = "" - added_tables = set() - for idx, table_id in enumerate(table_chars): - if table_id == -1: - page_text += form_recognizer_results.content[page_offset + idx] - elif not table_id in added_tables: - page_text += table_to_html(tables_on_page[table_id]) - added_tables.add(table_id) - - page_text += " " - page_map.append((page_num, offset, page_text)) - offset += len(page_text) - - full_text = "".join([page_text for _, _, page_text in page_map]) - return full_text - -def chunk_content_helper( - content: str, file_format: str, file_name: Optional[str], - token_overlap: int, - num_tokens: int = 256 -) -> Generator[Tuple[str, int, Document], None, None]: - parser = parser_factory(file_format) - doc = parser.parse(content, file_name=file_name) - if num_tokens == None: - num_tokens = 1000000000 - - if file_format == "markdown": - splitter = MarkdownTextSplitter.from_tiktoken_encoder(chunk_size=num_tokens, chunk_overlap=token_overlap) - elif file_format == "python": - splitter = PythonCodeTextSplitter.from_tiktoken_encoder(chunk_size=num_tokens, chunk_overlap=token_overlap) - else: - splitter = RecursiveCharacterTextSplitter.from_tiktoken_encoder( - separators=SENTENCE_ENDINGS + WORDS_BREAKS, - chunk_size=num_tokens, chunk_overlap=token_overlap) - chunked_content_list = splitter.split_text(doc.content) - for chunked_content in chunked_content_list: - chunk_size = TOKEN_ESTIMATOR.estimate_tokens(chunked_content) - yield chunked_content, chunk_size, doc - -def chunk_content( - content: str, - file_name: Optional[str] = None, - url: Optional[str] = None, - ignore_errors: bool = True, - num_tokens: int = 256, - min_chunk_size: int = 10, - token_overlap: int = 0, - extensions_to_process = FILE_FORMAT_DICT.keys(), - cracked_pdf = False -) -> ChunkingResult: - """Chunks the given content. If ignore_errors is true, returns None - in case of an error - Args: - content (str): The content to chunk. - file_name (str): The file name. used for title, file format detection. - url (str): The url. used for title. - ignore_errors (bool): If true, ignores errors and returns None. - num_tokens (int): The number of tokens in each chunk. - min_chunk_size (int): The minimum chunk size below which chunks will be filtered. - token_overlap (int): The number of tokens to overlap between chunks. - Returns: - List[Document]: List of chunked documents. - """ - - try: - if file_name is None or cracked_pdf: - file_format = "text" - else: - file_format = _get_file_format(file_name, extensions_to_process) - if file_format is None: - raise Exception( - f"{file_name} is not supported") - - chunked_context = chunk_content_helper( - content=content, - file_name=file_name, - file_format=file_format, - num_tokens=num_tokens, - token_overlap=token_overlap - ) - chunks = [] - skipped_chunks = 0 - for chunk, chunk_size, doc in chunked_context: - if chunk_size >= min_chunk_size: - chunks.append( - Document( - content=chunk, - title=doc.title, - url=url, - ) - ) - else: - skipped_chunks += 1 - - except UnsupportedFormatError as e: - if ignore_errors: - return ChunkingResult( - chunks=[], total_files=1, num_unsupported_format_files=1 - ) - else: - raise e - except Exception as e: - if ignore_errors: - return ChunkingResult(chunks=[], total_files=1, num_files_with_errors=1) - else: - raise e - return ChunkingResult( - chunks=chunks, - total_files=1, - skipped_chunks=skipped_chunks, - ) - -def chunk_file( - file_path: str, - ignore_errors: bool = True, - num_tokens=256, - min_chunk_size=10, - url = None, - token_overlap: int = 0, - extensions_to_process = FILE_FORMAT_DICT.keys(), - form_recognizer_client = None, - use_layout = False -) -> ChunkingResult: - """Chunks the given file. - Args: - file_path (str): The file to chunk. - Returns: - List[Document]: List of chunked documents. - """ - file_name = os.path.basename(file_path) - file_format = _get_file_format(file_name, extensions_to_process) - if not file_format: - if ignore_errors: - return ChunkingResult( - chunks=[], total_files=1, num_unsupported_format_files=1 - ) - else: - raise UnsupportedFormatError(f"{file_name} is not supported") - - cracked_pdf = False - if file_format == "pdf": - if form_recognizer_client is None: - raise UnsupportedFormatError("form_recognizer_client is required for pdf files") - content = extract_pdf_content(file_path, form_recognizer_client, use_layout=use_layout) - cracked_pdf = True - else: - with open(file_path, "r", encoding="utf8") as f: - content = f.read() - return chunk_content( - content=content, - file_name=file_name, - ignore_errors=ignore_errors, - num_tokens=num_tokens, - min_chunk_size=min_chunk_size, - url=url, - token_overlap=max(0, token_overlap), - extensions_to_process=extensions_to_process, - cracked_pdf=cracked_pdf - ) - -def chunk_directory( - directory_path: str, - ignore_errors: bool = True, - num_tokens: int = 1024, - min_chunk_size: int = 10, - url_prefix = None, - token_overlap: int = 0, - extensions_to_process: List[str] = FILE_FORMAT_DICT.keys(), - form_recognizer_client = None, - use_layout = False -): - """ - Chunks the given directory recursively - Args: - directory_path (str): The directory to chunk. - ignore_errors (bool): If true, ignores errors and returns None. - num_tokens (int): The number of tokens to use for chunking. - min_chunk_size (int): The minimum chunk size. - url_prefix (str): The url prefix to use for the files. If None, the url will be None. If not None, the url will be url_prefix + relpath. - For example, if the directory path is /home/user/data and the url_prefix is https://example.com/data, - then the url for the file /home/user/data/file1.txt will be https://example.com/data/file1.txt - token_overlap (int): The number of tokens to overlap between chunks. - extensions_to_process (List[str]): The list of extensions to process. - form_recognizer_client: Optional form recognizer client to use for pdf files. - use_layout (bool): If true, uses Layout model for pdf files. Otherwise, uses Read. - - Returns: - List[Document]: List of chunked documents. - """ - chunks = [] - total_files = 0 - num_unsupported_format_files = 0 - num_files_with_errors = 0 - skipped_chunks = 0 - for file_path in tqdm(get_files_recursively(directory_path)): - if os.path.isfile(file_path): - # get relpath - url_path = None - rel_file_path = os.path.relpath(file_path, directory_path) - if url_prefix: - url_path = url_prefix + rel_file_path - url_path = convert_escaped_to_posix(url_path) - try: - result = chunk_file( - file_path, - ignore_errors=ignore_errors, - num_tokens=num_tokens, - min_chunk_size=min_chunk_size, - url=url_path, - token_overlap=token_overlap, - extensions_to_process=extensions_to_process, - form_recognizer_client=form_recognizer_client, - use_layout=use_layout - ) - for chunk_idx, chunk_doc in enumerate(result.chunks): - chunk_doc.filepath = rel_file_path - chunk_doc.metadata = json.dumps({"chunk_id": str(chunk_idx)}) - chunks.extend(result.chunks) - num_unsupported_format_files += result.num_unsupported_format_files - num_files_with_errors += result.num_files_with_errors - skipped_chunks += result.skipped_chunks - except Exception as e: - if not ignore_errors: - raise - print(f"File ({file_path}) failed with ", e) - num_files_with_errors += 1 - total_files += 1 - - return ChunkingResult( - chunks=chunks, - total_files=total_files, - num_unsupported_format_files=num_unsupported_format_files, - num_files_with_errors=num_files_with_errors, - skipped_chunks=skipped_chunks, - ) +"""Data utilities for index preparation.""" +import os +import ast +import markdown +import re +import tiktoken +import html +import json + +from tqdm import tqdm +from abc import ABC, abstractmethod +from bs4 import BeautifulSoup, Tag, NavigableString +from dataclasses import dataclass + +from typing import List, Dict, Optional, Generator, Tuple, Union +from langchain.text_splitter import MarkdownTextSplitter, RecursiveCharacterTextSplitter, PythonCodeTextSplitter + +FILE_FORMAT_DICT = { + "md": "markdown", + "txt": "text", + "html": "html", + "shtml": "html", + "htm": "html", + "py": "python", + "pdf": "pdf" + } + +SENTENCE_ENDINGS = [".", "!", "?"] +WORDS_BREAKS = list(reversed([",", ";", ":", " ", "(", ")", "[", "]", "{", "}", "\t", "\n"])) + +@dataclass +class Document(object): + """A data class for storing documents + + Attributes: + content (str): The content of the document. + id (Optional[str]): The id of the document. + title (Optional[str]): The title of the document. + filepath (Optional[str]): The filepath of the document. + url (Optional[str]): The url of the document. + metadata (Optional[Dict]): The metadata of the document. + """ + + content: str + id: Optional[str] = None + title: Optional[str] = None + filepath: Optional[str] = None + url: Optional[str] = None + metadata: Optional[Dict] = None + +def cleanup_content(content: str) -> str: + """Cleans up the given content using regexes + Args: + content (str): The content to clean up. + Returns: + str: The cleaned up content. + """ + output = re.sub(r"\n{2,}", "\n", content) + output = re.sub(r"[^\S\n]{2,}", " ", output) + output = re.sub(r"-{2,}", "--", output) + + return output.strip() + +class BaseParser(ABC): + """A parser parses content to produce a document.""" + + @abstractmethod + def parse(self, content: str, file_name: Optional[str] = None) -> Document: + """Parses the given content. + Args: + content (str): The content to parse. + file_name (str): The file name associated with the content. + Returns: + Document: The parsed document. + """ + pass + + def parse_file(self, file_path: str) -> Document: + """Parses the given file. + Args: + file_path (str): The file to parse. + Returns: + Document: The parsed document. + """ + with open(file_path, "r") as f: + return self.parse(f.read(), os.path.basename(file_path)) + + def parse_directory(self, directory_path: str) -> List[Document]: + """Parses the given directory. + Args: + directory_path (str): The directory to parse. + Returns: + List[Document]: List of parsed documents. + """ + documents = [] + for file_name in os.listdir(directory_path): + file_path = os.path.join(directory_path, file_name) + if os.path.isfile(file_path): + documents.append(self.parse_file(file_path)) + return documents + +class MarkdownParser(BaseParser): + """Parses Markdown content.""" + + def __init__(self) -> None: + super().__init__() + self._html_parser = HTMLParser() + + def parse(self, content: str, file_name: Optional[str] = None) -> Document: + """Parses the given content. + Args: + content (str): The content to parse. + file_name (str): The file name associated with the content. + Returns: + Document: The parsed document. + """ + html_content = markdown.markdown(content, extensions=['fenced_code', 'toc', 'tables', 'sane_lists']) + + return self._html_parser.parse(html_content, file_name) + + +class HTMLParser(BaseParser): + """Parses HTML content.""" + TITLE_MAX_TOKENS = 128 + NEWLINE_TEMPL = "" + + def __init__(self) -> None: + super().__init__() + self.token_estimator = TokenEstimator() + + def parse(self, content: str, file_name: Optional[str] = None) -> Document: + """Parses the given content. + Args: + content (str): The content to parse. + file_name (str): The file name associated with the content. + Returns: + Document: The parsed document. + """ + soup = BeautifulSoup(content, 'html.parser') + + # Extract the title + title = '' + if soup.title and soup.title.string: + title = soup.title.string + else: + # Try to find the first

tag + h1_tag = soup.find('h1') + if h1_tag: + title = h1_tag.get_text(strip=True) + else: + h2_tag = soup.find('h2') + if h2_tag: + title = h2_tag.get_text(strip=True) + if title is None or title == '': + # if title is still not found, guess using the next string + try: + title = next(soup.stripped_strings) + title = self.token_estimator.construct_tokens_with_size(title, self.TITLE_MAX_TOKENS) + + except StopIteration: + title = file_name + + # Helper function to process text nodes + def process_text(text): + return text.strip() + + # Helper function to process anchor tags + def process_anchor_tag(tag): + href = tag.get('href', '') + text = tag.get_text(strip=True) + return f'{text} ({href})' + + # Collect all text nodes and anchor tags in a list + elements = [] + + for elem in soup.descendants: + if isinstance(elem, (Tag, NavigableString)): + page_element: Union[Tag, NavigableString] = elem + if page_element.name in ['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'li', 'code']: + if elements and not elements[-1].endswith('\n'): + elements.append(self.NEWLINE_TEMPL) + if isinstance(page_element, str): + elements.append(process_text(page_element)) + elif page_element.name == 'a': + elements.append(process_anchor_tag(page_element)) + + + # Join the list into a single string and return but ensure that either of newlines or space are used. + result = '' + is_prev_newline = False + for elem in elements: + if elem: + if elem == self.NEWLINE_TEMPL: + result += "\n" + is_prev_newline = True + else: + if not is_prev_newline: + result += " " + else: + is_prev_newline = False + result += f"{elem}" + + if title is None: + title = '' # ensure no 'None' type title + return Document(content=cleanup_content(result), title=str(title)) + +class TextParser(BaseParser): + """Parses text content.""" + + def __init__(self) -> None: + super().__init__() + + def _get_first_alphanum_line(self, content: str) -> Optional[str]: + title = None + for line in content.splitlines(): + if any([c.isalnum() for c in line]): + title = line.strip() + break + return title + + def _get_first_line_with_property( + self, content: str, property: str = "title: " + ) -> Optional[str]: + title = None + for line in content.splitlines(): + if line.startswith(property): + title = line[len(property) :].strip() + break + return title + + def parse(self, content: str, file_name: Optional[str] = None) -> Document: + """Parses the given content. + Args: + content (str): The content to parse. + file_name (str): The file name associated with the content. + Returns: + Document: The parsed document. + """ + title = self._get_first_line_with_property( + content + ) or self._get_first_alphanum_line(content) + + return Document(content=cleanup_content(content), title=title or file_name) + + +class PythonParser(BaseParser): + def _get_topdocstring(self, text): + tree = ast.parse(text) + docstring = ast.get_docstring(tree) # returns top docstring + return docstring + + def parse(self, content: str, file_name: Optional[str] = None) -> Document: + """Parses the given content. + Args: + content (str): The content to parse. + file_name (str): The file name associated with the content. + Returns: + Document: The parsed document. + """ + docstring = self._get_topdocstring(content) + if docstring: + title = f"{file_name}: {docstring}" + else: + title = file_name + return Document(content=content, title=title) + + def __init__(self) -> None: + super().__init__() + +class ParserFactory: + def __init__(self): + self._parsers = { + "html": HTMLParser(), + "text": TextParser(), + "markdown": MarkdownParser(), + "python": PythonParser() + } + + @property + def supported_formats(self) -> List[str]: + "Returns a list of supported formats" + return list(self._parsers.keys()) + + def __call__(self, file_format: str) -> BaseParser: + parser = self._parsers.get(file_format, None) + if parser is None: + raise UnsupportedFormatError(f"{file_format} is not supported") + + return parser + +class TokenEstimator(object): + GPT2_TOKENIZER = tiktoken.get_encoding("gpt2") + + def estimate_tokens(self, text: str) -> int: + return len(self.GPT2_TOKENIZER.encode(text)) + + def construct_tokens_with_size(self, tokens: str, numofTokens: int) -> str: + newTokens = self.GPT2_TOKENIZER.decode( + self.GPT2_TOKENIZER.encode(tokens)[:numofTokens] + ) + return newTokens + +parser_factory = ParserFactory() +TOKEN_ESTIMATOR = TokenEstimator() + +class UnsupportedFormatError(Exception): + """Exception raised when a format is not supported by a parser.""" + + pass + +@dataclass +class ChunkingResult: + """Data model for chunking result + + Attributes: + chunks (List[Document]): List of chunks. + total_files (int): Total number of files. + num_unsupported_format_files (int): Number of files with unsupported format. + num_files_with_errors (int): Number of files with errors. + skipped_chunks (int): Number of chunks skipped. + """ + chunks: List[Document] + total_files: int + num_unsupported_format_files: int = 0 + num_files_with_errors: int = 0 + # some chunks might be skipped to small number of tokens + skipped_chunks: int = 0 + +def get_files_recursively(directory_path: str) -> List[str]: + """Gets all files in the given directory recursively. + Args: + directory_path (str): The directory to get files from. + Returns: + List[str]: List of file paths. + """ + file_paths = [] + for dirpath, _, files in os.walk(directory_path): + for file_name in files: + file_path = os.path.join(dirpath, file_name) + file_paths.append(file_path) + return file_paths + +def convert_escaped_to_posix(escaped_path): + windows_path = escaped_path.replace("\\\\", "\\") + posix_path = windows_path.replace("\\", "/") + return posix_path + +def _get_file_format(file_name: str, extensions_to_process: List[str]) -> Optional[str]: + """Gets the file format from the file name. + Returns None if the file format is not supported. + Args: + file_name (str): The file name. + extensions_to_process (List[str]): List of extensions to process. + Returns: + str: The file format. + """ + + # in case the caller gives us a file path + file_name = os.path.basename(file_name) + file_extension = file_name.split(".")[-1] + if file_extension not in extensions_to_process: + return None + return FILE_FORMAT_DICT.get(file_extension, None) + +def table_to_html(table): + table_html = "" + rows = [sorted([cell for cell in table.cells if cell.row_index == i], key=lambda cell: cell.column_index) for i in range(table.row_count)] + for row_cells in rows: + table_html += "" + for cell in row_cells: + tag = "th" if (cell.kind == "columnHeader" or cell.kind == "rowHeader") else "td" + cell_spans = "" + if cell.column_span > 1: cell_spans += f" colSpan={cell.column_span}" + if cell.row_span > 1: cell_spans += f" rowSpan={cell.row_span}" + table_html += f"<{tag}{cell_spans}>{html.escape(cell.content)}" + table_html +="" + table_html += "
" + return table_html + +def extract_pdf_content(file_path, form_recognizer_client, use_layout=False): + offset = 0 + page_map = [] + model = "prebuilt-layout" if use_layout else "prebuilt-read" + with open(file_path, "rb") as f: + poller = form_recognizer_client.begin_analyze_document(model, document = f) + form_recognizer_results = poller.result() + title = next((p.content for p in form_recognizer_results.paragraphs if p.role == "title"), None) + + for page_num, page in enumerate(form_recognizer_results.pages): + tables_on_page = [table for table in form_recognizer_results.tables if table.bounding_regions[0].page_number == page_num + 1] + + # (if using layout) mark all positions of the table spans in the page + page_offset = page.spans[0].offset + page_length = page.spans[0].length + table_chars = [-1]*page_length + for table_id, table in enumerate(tables_on_page): + for span in table.spans: + # replace all table spans with "table_id" in table_chars array + for i in range(span.length): + idx = span.offset - page_offset + i + if idx >=0 and idx < page_length: + table_chars[idx] = table_id + + # build page text by replacing charcters in table spans with table html if using layout + page_text = "" + added_tables = set() + for idx, table_id in enumerate(table_chars): + if table_id == -1: + page_text += form_recognizer_results.content[page_offset + idx] + elif not table_id in added_tables: + page_text += table_to_html(tables_on_page[table_id]) + added_tables.add(table_id) + + page_text += " " + page_map.append((page_num, offset, page_text)) + offset += len(page_text) + + full_text = "".join([page_text for _, _, page_text in page_map]) + return full_text + +def chunk_content_helper( + content: str, file_format: str, file_name: Optional[str], + token_overlap: int, + num_tokens: int = 256 +) -> Generator[Tuple[str, int, Document], None, None]: + parser = parser_factory(file_format) + doc = parser.parse(content, file_name=file_name) + if num_tokens == None: + num_tokens = 1000000000 + + if file_format == "markdown": + splitter = MarkdownTextSplitter.from_tiktoken_encoder(chunk_size=num_tokens, chunk_overlap=token_overlap) + elif file_format == "python": + splitter = PythonCodeTextSplitter.from_tiktoken_encoder(chunk_size=num_tokens, chunk_overlap=token_overlap) + else: + splitter = RecursiveCharacterTextSplitter.from_tiktoken_encoder( + separators=SENTENCE_ENDINGS + WORDS_BREAKS, + chunk_size=num_tokens, chunk_overlap=token_overlap) + chunked_content_list = splitter.split_text(doc.content) + for chunked_content in chunked_content_list: + chunk_size = TOKEN_ESTIMATOR.estimate_tokens(chunked_content) + yield chunked_content, chunk_size, doc + +def chunk_content( + content: str, + file_name: Optional[str] = None, + url: Optional[str] = None, + ignore_errors: bool = True, + num_tokens: int = 256, + min_chunk_size: int = 10, + token_overlap: int = 0, + extensions_to_process = FILE_FORMAT_DICT.keys(), + cracked_pdf = False +) -> ChunkingResult: + """Chunks the given content. If ignore_errors is true, returns None + in case of an error + Args: + content (str): The content to chunk. + file_name (str): The file name. used for title, file format detection. + url (str): The url. used for title. + ignore_errors (bool): If true, ignores errors and returns None. + num_tokens (int): The number of tokens in each chunk. + min_chunk_size (int): The minimum chunk size below which chunks will be filtered. + token_overlap (int): The number of tokens to overlap between chunks. + Returns: + List[Document]: List of chunked documents. + """ + + try: + if file_name is None or cracked_pdf: + file_format = "text" + else: + file_format = _get_file_format(file_name, extensions_to_process) + if file_format is None: + raise Exception( + f"{file_name} is not supported") + + chunked_context = chunk_content_helper( + content=content, + file_name=file_name, + file_format=file_format, + num_tokens=num_tokens, + token_overlap=token_overlap + ) + chunks = [] + skipped_chunks = 0 + for chunk, chunk_size, doc in chunked_context: + if chunk_size >= min_chunk_size: + chunks.append( + Document( + content=chunk, + title=doc.title, + url=url, + ) + ) + else: + skipped_chunks += 1 + + except UnsupportedFormatError as e: + if ignore_errors: + return ChunkingResult( + chunks=[], total_files=1, num_unsupported_format_files=1 + ) + else: + raise e + except Exception as e: + if ignore_errors: + return ChunkingResult(chunks=[], total_files=1, num_files_with_errors=1) + else: + raise e + return ChunkingResult( + chunks=chunks, + total_files=1, + skipped_chunks=skipped_chunks, + ) + +def chunk_file( + file_path: str, + ignore_errors: bool = False, + num_tokens=256, + min_chunk_size=10, + url = None, + token_overlap: int = 0, + extensions_to_process = FILE_FORMAT_DICT.keys(), + form_recognizer_client = None, + use_layout = False +) -> ChunkingResult: + """Chunks the given file. + Args: + file_path (str): The file to chunk. + Returns: + List[Document]: List of chunked documents. + """ + file_name = os.path.basename(file_path) + file_format = _get_file_format(file_name, extensions_to_process) + if not file_format: + if ignore_errors: + return ChunkingResult( + chunks=[], total_files=1, num_unsupported_format_files=1 + ) + else: + raise UnsupportedFormatError(f"{file_name} is not supported") + + cracked_pdf = False + if file_format == "pdf": + if form_recognizer_client is None: + raise UnsupportedFormatError("form_recognizer_client is required for pdf files") + content = extract_pdf_content(file_path, form_recognizer_client, use_layout=use_layout) + cracked_pdf = True + else: + with open(file_path, "r", encoding="utf8") as f: + content = f.read() + return chunk_content( + content=content, + file_name=file_name, + ignore_errors=ignore_errors, + num_tokens=num_tokens, + min_chunk_size=min_chunk_size, + url=url, + token_overlap=max(0, token_overlap), + extensions_to_process=extensions_to_process, + cracked_pdf=cracked_pdf + ) + +def chunk_directory( + directory_path: str, + ignore_errors: bool = False, + num_tokens: int = 1024, + min_chunk_size: int = 10, + url_prefix = None, + token_overlap: int = 0, + extensions_to_process: List[str] = FILE_FORMAT_DICT.keys(), + form_recognizer_client = None, + use_layout = False +): + """ + Chunks the given directory recursively + Args: + directory_path (str): The directory to chunk. + ignore_errors (bool): If true, ignores errors and returns None. + num_tokens (int): The number of tokens to use for chunking. + min_chunk_size (int): The minimum chunk size. + url_prefix (str): The url prefix to use for the files. If None, the url will be None. If not None, the url will be url_prefix + relpath. + For example, if the directory path is /home/user/data and the url_prefix is https://example.com/data, + then the url for the file /home/user/data/file1.txt will be https://example.com/data/file1.txt + token_overlap (int): The number of tokens to overlap between chunks. + extensions_to_process (List[str]): The list of extensions to process. + form_recognizer_client: Optional form recognizer client to use for pdf files. + use_layout (bool): If true, uses Layout model for pdf files. Otherwise, uses Read. + + Returns: + List[Document]: List of chunked documents. + """ + chunks = [] + total_files = 0 + num_unsupported_format_files = 0 + num_files_with_errors = 0 + skipped_chunks = 0 + for file_path in tqdm(get_files_recursively(directory_path)): + print(file_path) + if os.path.isfile(file_path): + # get relpath + url_path = None + rel_file_path = os.path.relpath(file_path, directory_path) + if url_prefix: + url_path = url_prefix + rel_file_path + url_path = convert_escaped_to_posix(url_path) + try: + result = chunk_file( + file_path, + ignore_errors=ignore_errors, + num_tokens=num_tokens, + min_chunk_size=min_chunk_size, + url=url_path, + token_overlap=token_overlap, + extensions_to_process=extensions_to_process, + form_recognizer_client=form_recognizer_client, + use_layout=use_layout + ) + for chunk_idx, chunk_doc in enumerate(result.chunks): + chunk_doc.filepath = rel_file_path + chunk_doc.metadata = json.dumps({"chunk_id": str(chunk_idx)}) + chunks.extend(result.chunks) + num_unsupported_format_files += result.num_unsupported_format_files + num_files_with_errors += result.num_files_with_errors + skipped_chunks += result.skipped_chunks + except Exception as e: + if not ignore_errors: + raise + print(f"File ({file_path}) failed with ", e) + num_files_with_errors += 1 + total_files += 1 + + return ChunkingResult( + chunks=chunks, + total_files=total_files, + num_unsupported_format_files=num_unsupported_format_files, + num_files_with_errors=num_files_with_errors, + skipped_chunks=skipped_chunks, + ) \ No newline at end of file diff --git a/scripts/prepdocs.ps1 b/scripts/prepdocs.ps1 new file mode 100644 index 0000000000..e691293180 --- /dev/null +++ b/scripts/prepdocs.ps1 @@ -0,0 +1,39 @@ +Write-Host "" +Write-Host "Loading azd .env file from current environment" +Write-Host "" + +$output = azd env get-values + +foreach ($line in $output) { + if (!$line.Contains('=')) { + continue + } + + $name, $value = $line.Split("=") + $value = $value -replace '^\"|\"$' + [Environment]::SetEnvironmentVariable($name, $value) +} + +Write-Host "Environment variables set." + +$pythonCmd = Get-Command python -ErrorAction SilentlyContinue +if (-not $pythonCmd) { + # fallback to python3 if python not found + $pythonCmd = Get-Command python3 -ErrorAction SilentlyContinue +} + +Write-Host 'Creating python virtual environment "scripts/.venv"' +Start-Process -FilePath ($pythonCmd).Source -ArgumentList "-m venv ./scripts/.venv" -Wait -NoNewWindow + +$venvPythonPath = "./scripts/.venv/scripts/python.exe" +if (Test-Path -Path "/usr") { + # fallback to Linux venv path + $venvPythonPath = "./scripts/.venv/bin/python" +} + +Write-Host 'Installing dependencies from "requirements.txt" into virtual environment' +Start-Process -FilePath $venvPythonPath -ArgumentList "-m pip install -r ./scripts/requirements.txt" -Wait -NoNewWindow + +Write-Host 'Running "prepdocs.py"' +$cwd = (Get-Location) +Start-Process -FilePath $venvPythonPath -ArgumentList "./scripts/prepdocs.py $cwd/data/* --storageaccount $env:AZURE_STORAGE_ACCOUNT --container $env:AZURE_STORAGE_CONTAINER --searchservice $env:AZURE_SEARCH_SERVICE --index $env:AZURE_SEARCH_INDEX --formrecognizerservice $env:AZURE_FORMRECOGNIZER_SERVICE --tenantid $env:AZURE_TENANT_ID -v" -Wait -NoNewWindow diff --git a/scripts/prepdocs.py b/scripts/prepdocs.py new file mode 100644 index 0000000000..fcb6e82f90 --- /dev/null +++ b/scripts/prepdocs.py @@ -0,0 +1,138 @@ +import argparse +import dataclasses + +from tqdm import tqdm +from azure.identity import AzureDeveloperCliCredential +from azure.core.credentials import AzureKeyCredential +from azure.storage.blob import BlobServiceClient +from azure.search.documents.indexes import SearchIndexClient +from azure.search.documents.indexes.models import ( + SearchableField, + SemanticField, + SemanticSettings, + SemanticConfiguration, + SearchIndex, + PrioritizedFields +) +from azure.search.documents import SearchClient +from azure.ai.formrecognizer import DocumentAnalysisClient + + +from data_utils import chunk_directory + + + +def create_search_index(index_name, index_client): + print(f"Ensuring search index {index_name} exists") + if index_name not in index_client.list_index_names(): + index = SearchIndex( + name=index_name, + fields=[ + SearchableField(name="id", type="Edm.String", key=True), + SearchableField(name="content", type="Edm.String", analyzer_name="en.lucene"), + SearchableField(name="title", type="Edm.String", analyzer_name="en.lucene"), + SearchableField(name="filepath", type="Edm.String"), + SearchableField(name="url", type="Edm.String"), + SearchableField(name="metadata", type="Edm.String") + ], + semantic_settings=SemanticSettings( + configurations=[SemanticConfiguration( + name='default', + prioritized_fields=PrioritizedFields( + title_field=SemanticField(field_name='title'), + prioritized_content_fields=[SemanticField(field_name='content')]))]) + ) + print(f"Creating {index_name} search index") + index_client.create_index(index) + else: + print(f"Search index {index_name} already exists") + +def upload_documents_to_index(docs, search_client, upload_batch_size = 50): + to_upload_dicts = [] + + id = 0 + for document in docs: + d = dataclasses.asdict(document) + # add id to documents + d.update({"@search.action": "upload", "id": str(id)}) + to_upload_dicts.append(d) + id += 1 + + + # Upload the documents in batches of upload_batch_size + for i in tqdm(range(0, len(to_upload_dicts), upload_batch_size), desc="Indexing Chunks..."): + batch = to_upload_dicts[i: i + upload_batch_size] + results = search_client.upload_documents(documents=batch) + num_failures = 0 + errors = set() + for result in results: + if not result.succeeded: + print(f"Indexing Failed for {result.key} with ERROR: {result.error_message}") + num_failures += 1 + errors.add(result.error_message) + if num_failures > 0: + raise Exception(f"INDEXING FAILED for {num_failures} documents. Please recreate the index." + f"To Debug: PLEASE CHECK chunk_size and upload_batch_size. \n Error Messages: {list(errors)}") + + +def create_and_populate_index(index_name, index_client, search_client, form_recognizer_client): + + # create or update search index with compatible schema + create_search_index(index_name, index_client) + + # chunk directory + print("Chunking directory...") + result = chunk_directory("./data", form_recognizer_client=form_recognizer_client, use_layout=True, ignore_errors=False) + + if len(result.chunks) == 0: + raise Exception("No chunks found. Please check the data path and chunk size.") + + print(f"Processed {result.total_files} files") + print(f"Unsupported formats: {result.num_unsupported_format_files} files") + print(f"Files with errors: {result.num_files_with_errors} files") + print(f"Found {len(result.chunks)} chunks") + + # upload documents to index + print("Uploading documents to index...") + upload_documents_to_index(result.chunks, search_client) + + # check if index is ready/validate index + # print("Validating index...") + # TODO: validate_index(index_name) - Port to Azure CLI + # print("Index validation completed") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser( + description="Prepare documents by extracting content from PDFs, splitting content into sections, uploading to blob storage, and indexing in a search index.", + epilog="Example: prepdocs.py '..\data\*' --storageaccount myaccount --container mycontainer --searchservice mysearch --index myindex -v" + ) + parser.add_argument("files", help="Files to be processed") + parser.add_argument("--storageaccount", help="Azure Blob Storage account name") + parser.add_argument("--container", help="Azure Blob Storage container name") + parser.add_argument("--storagekey", required=False, help="Optional. Use this Azure Blob Storage account key instead of the current user identity to login (use az login to set current user for Azure)") + parser.add_argument("--tenantid", required=False, help="Optional. Use this to define the Azure directory where to authenticate)") + parser.add_argument("--searchservice", help="Name of the Azure Cognitive Search service where content should be indexed (must exist already)") + parser.add_argument("--index", help="Name of the Azure Cognitive Search index where content should be indexed (will be created if it doesn't exist)") + parser.add_argument("--searchkey", required=False, help="Optional. Use this Azure Cognitive Search account key instead of the current user identity to login (use az login to set current user for Azure)") + parser.add_argument("--formrecognizerservice", required=False, help="Optional. Name of the Azure Form Recognizer service which will be used to extract text, tables and layout from the documents (must exist already)") + parser.add_argument("--formrecognizerkey", required=False, help="Optional. Use this Azure Form Recognizer account key instead of the current user identity to login (use az login to set current user for Azure)") + args = parser.parse_args() + + # Use the current user identity to connect to Azure services unless a key is explicitly set for any of them + azd_credential = AzureDeveloperCliCredential() if args.tenantid == None else AzureDeveloperCliCredential(tenant_id=args.tenantid, process_timeout=60) + default_creds = azd_credential if args.searchkey == None or args.storagekey == None else None + search_creds = default_creds if args.searchkey == None else AzureKeyCredential(args.searchkey) + formrecognizer_creds = default_creds if args.formrecognizerkey == None else AzureKeyCredential(args.formrecognizerkey) + + print("Data preparation script started") + print("Preparing data for index:", args.index) + search_endpoint = f"https://{args.searchservice}.search.windows.net/" + index_client = SearchIndexClient(endpoint=search_endpoint, credential=search_creds) + search_client = SearchClient(endpoint=search_endpoint, credential=search_creds, index_name=args.index) + form_recognizer_client = DocumentAnalysisClient( + endpoint=f"https://{args.formrecognizerservice}.cognitiveservices.azure.com/", + credential=formrecognizer_creds) + + create_and_populate_index(args.index, index_client, search_client, form_recognizer_client) + print("Data preparation for index", args.index, "completed") \ No newline at end of file diff --git a/scripts/prepdocs.sh b/scripts/prepdocs.sh new file mode 100755 index 0000000000..cc6b09f63b --- /dev/null +++ b/scripts/prepdocs.sh @@ -0,0 +1,21 @@ + #!/bin/sh + +echo "" +echo "Loading azd .env file from current environment" +echo "" + +while IFS='=' read -r key value; do + value=$(echo "$value" | sed 's/^"//' | sed 's/"$//') + export "$key=$value" +done <", - "location": "", - "subscription_id": "", - "resource_group": "", - "search_service_name": "", - "index_name": "", - "chunk_size": 1024, // set to null to disable chunking before ingestion - "token_overlap": 128 // number of tokens to overlap between chunks - "semantic_config_name": "default" - } -] -``` - -## Create Indexes and Ingest Data -Disclaimer: Make sure there are no duplicate pages in your data. That could impact the quality of the responses you get in a negative way. - -- Run the data preparation script, passing in your config file. - - `python data_preparation.py --config config.json` - -## Optional: Crack PDFs to Text -If your data is in PDF format, you'll first need to convert from PDF to .txt format. You can use your own script for this, or use the provided conversion code here. - -### Setup for PDF Cracking -- Create a [Form Recognizer](https://learn.microsoft.com/en-us/azure/applied-ai-services/form-recognizer/create-a-form-recognizer-resource?view=form-recog-3.0.0) resource in your subscription -- Make sure you have the Form Recognizer SDK: `pip install azure-ai-formrecognizer` -- Run the following command to get an access key for your Form Recognizer resource: - `az cognitiveservices account keys list --name "" --resource-group ""` - - Copy one of the keys returned by this command. - -### Create Indexes and Ingest Data from PDF with Form Recognizer -Pass in your Form Recognizer resource name and key when running the data preparation script: - -`python data_preparation.py --config config.json --form-rec-resource --form-rec-key ` - -This will use the Form Recognizer Read model by default. If your documents have a lot of tables and relevant layout information, you can use the Form Recognizer Layout model, which is more costly and slower to run but will preserve table information with better quality. To use the Layout model instead of the default Read model, pass in the argument `--form-rec-use-layout`. - -`python data_preparation.py --config config.json --form-rec-resource --form-rec-key --form-rec-use-layout` \ No newline at end of file diff --git a/scripts/requirements.txt b/scripts/requirements.txt index aa677b4bb6..f8fc32c618 100644 --- a/scripts/requirements.txt +++ b/scripts/requirements.txt @@ -1,9 +1,10 @@ -markdown -requests -tqdm -azure-identity -azure-search-documents -tiktoken -langchain -bs4 -azure-ai-formrecognizer \ No newline at end of file +azure-identity==1.13.0b4 +azure-search-documents==11.4.0b3 +azure-ai-formrecognizer==3.2.1 +azure-storage-blob==12.14.1 +markdown +requests +tqdm +tiktoken +langchain +bs4 \ No newline at end of file diff --git a/start.sh b/start.sh new file mode 100755 index 0000000000..94fd92bf35 --- /dev/null +++ b/start.sh @@ -0,0 +1,39 @@ +#!/bin/bash + +echo "" +echo "Restoring backend python packages" +echo "" +python3 -m pip install -r requirements.txt +if [ $? -ne 0 ]; then + echo "Failed to restore backend python packages" + exit $? +fi + +echo "" +echo "Restoring frontend npm packages" +echo "" +cd frontend +npm install +if [ $? -ne 0 ]; then + echo "Failed to restore frontend npm packages" + exit $? +fi + +echo "" +echo "Building frontend" +echo "" +npm run build +if [ $? -ne 0 ]; then + echo "Failed to build frontend" + exit $? +fi + +echo "" +echo "Starting backend" +echo "" +cd .. +python3 -m flask run --port=50505 --reload --debug +if [ $? -ne 0 ]; then + echo "Failed to start backend" + exit $? +fi \ No newline at end of file