diff --git a/.gitignore b/.gitignore index ae2acd4ad..b62a0055a 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,14 @@ # User-specific files (MonoDevelop/Xamarin Studio) *.userprefs +# Models + +models + +# Image tags + +image_tag.txt + # Mono auto generated files mono_crash.* @@ -374,4 +382,7 @@ app/backend/lib app/backend/lib64 app/backend/shared_code -packages-microsoft* \ No newline at end of file +packages-microsoft* + +# docker container build artifacts +app/enrichment/shared_code \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json index 073c67e95..090a3ccf1 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -4,15 +4,9 @@ // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 "version": "0.2.0", "configurations": [ + { - "name": "Python: Current File", - "type": "python", - "request": "launch", - "program": "${file}", - "console": "integratedTerminal" - }, - { - "name": "Python: Flask", + "name": "Python: WebApp backend", "type": "python", "request": "launch", "module": "flask", @@ -33,6 +27,34 @@ "envFile": "${workspaceFolder}/scripts/environments/infrastructure.debug.env", "preLaunchTask": "pip_install" }, + { + "name": "Python: Enrichment Webapp", + "type": "python", + "request": "launch", + "module": "uvicorn", + "args": [ + "app:app", + "--reload", + "--port", + "5001" + ], + "cwd": "${workspaceFolder}/app/enrichment", + "console": "integratedTerminal", + "justMyCode": true, + "envFile": "${workspaceFolder}/scripts/environments/infrastructure.debug.env", + "preLaunchTask": "pip_install_enrichment" + }, + { + "name": "Vite: Debug", + "type": "msedge", + "request": "launch", + "url": "http://localhost:5000", + "webRoot": "${workspaceFolder}/app/backend/static", + "sourceMapPathOverrides": { + "webpack:///src/*": "${webRoot}/*" + }, + "skipFiles": ["/**", "**/node_modules/**"] + }, { "name": "Frontend: watch", "type": "node", @@ -65,15 +87,44 @@ "preLaunchTask": "func host start" }, { - "name": "Vite: Debug", - "type": "msedge", + "name": "Debug functional tests", + "type": "python", "request": "launch", - "url": "http://localhost:5000", - "webRoot": "${workspaceFolder}/app/backend/static", - "sourceMapPathOverrides": { - "webpack:///src/*": "${webRoot}/*" + "program": "debug_tests.py", + "args": [ + "--storage_account_connection_str", + "${env:STORAGE_ACCOUNT_CONNECTION_STR},", + "--search_service_endpoint", + "${env:SEARCH_SERVICE_ENDPOINT}", + "--search_index", + "${env:SEARCH_INDEX}", + "--search_key", + "${env:SEARCH_KEY}", + "--wait_time_seconds", + "60" + ], + "env": { + "STORAGE_ACCOUNT_CONNECTION_STR": "${env:BLOB_CONNECTION_STRING}", + "SEARCH_SERVICE_ENDPOINT": "${env:AZURE_SEARCH_SERVICE_ENDPOINT}", + "SEARCH_INDEX": "${env:AZURE_SEARCH_INDEX}", + "SEARCH_KEY": "${env:AZURE_SEARCH_SERVICE_KEY}" }, - "skipFiles": ["/**", "**/node_modules/**"] + "cwd": "${workspaceFolder}/tests", + "envFile": "${workspaceFolder}/scripts/environments/infrastructure.debug.env", + "preLaunchTask": "pip_install_func_tests", + "presentation": { + "hidden": false, + "group": "", + "order": 1, + "panel": "shared" + } + }, + { + "name": "Python: Current File", + "type": "python", + "request": "launch", + "program": "${file}", + "console": "integratedTerminal" } ] } \ No newline at end of file diff --git a/.vscode/tasks.json b/.vscode/tasks.json index eea4924e3..530155510 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -19,6 +19,45 @@ "clear": false } }, + { + "label": "pip_install_enrichment", + "type": "shell", + "command": "pip", + "args": [ + "install", + "-r", + "${workspaceFolder}/app/enrichment/requirements.txt" + ], + "presentation": { + "echo": true, + "reveal": "always", + "focus": false, + "panel": "shared", + "showReuseMessage": true, + "clear": false + } + }, + { + "label": "pip_install_func_tests", + "type": "shell", + "command": "pip", + "args": [ + "install", + "-r", + "${workspaceFolder}/tests/requirements.txt" + ], + "options": { + "cwd": "${workspaceFolder}/tests", + }, + "presentation": { + "echo": true, + "reveal": "always", + "focus": false, + "panel": "shared", + "showReuseMessage": true, + "clear": false + } + }, { "type": "func", "label": "func host start", diff --git a/Makefile b/Makefile index 3165a82a5..7706ec4ec 100644 --- a/Makefile +++ b/Makefile @@ -9,16 +9,23 @@ help: ## Show this help | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%s\033[0m|%s\n", $$1, $$2}' \ | column -t -s '|' -deploy: build infrastructure extract-env deploy-search-indexes deploy-webapp deploy-functions ## Deploy infrastructure and application code +deploy: build infrastructure extract-env deploy-enrichments deploy-search-indexes deploy-webapp deploy-functions ## Deploy infrastructure and application code +build-deploy-webapp: build extract-env deploy-webapp ##Build and Deploy the Webapp +build-deploy-enrichments: build extract-env deploy-enrichments ##Build and Deploy the Enrichment Webapp +build-deploy-functions: build extract-env deploy-functions ##Build and Deploy the Functions + build: ## Build application code @./scripts/build.sh +build-containers: extract-env + @./app/enrichment/docker-build.sh + infrastructure: check-subscription ## Deploy infrastructure @./scripts/inf-create.sh extract-env: extract-env-debug-webapp extract-env-debug-functions ## Extract infrastructure.env file from BICEP output - @./scripts/json-to-env.sh < infra_output.json > ./scripts/environments/infrastructure.env + @./scripts/json-to-env.sh < infra_output.json > ./scripts/environments/infrastructure.env deploy-webapp: extract-env ## Deploys the web app code to Azure App Service @./scripts/deploy-webapp.sh @@ -26,6 +33,9 @@ deploy-webapp: extract-env ## Deploys the web app code to Azure App Service deploy-functions: extract-env ## Deploys the function code to Azure Function Host @./scripts/deploy-functions.sh +deploy-enrichments: extract-env ## Deploys the web app code to Azure App Service + @./scripts/deploy-enrichment-webapp.sh + deploy-search-indexes: extract-env ## Deploy search indexes @./scripts/deploy-search-indexes.sh @@ -45,3 +55,7 @@ take-dir-ownership: destroy-inf: check-subscription @./scripts/inf-destroy.sh + +functional-tests: extract-env ## Run functional tests to check the processing pipeline is working + @./scripts/functional-tests.sh + diff --git a/README.md b/README.md index 5f6907a06..63da052d4 100644 --- a/README.md +++ b/README.md @@ -6,82 +6,35 @@ The accelerator adapts prompts based on the model type for enhanced performance. --- -![Process Flow](docs/process_flow.drawio.png) - -# Features - -## Retrieval Augmented Generation (RAG) - -**Retrieve Contextually Relevant Documents:** Utilize Azure Cognitive Search's indexing capabilities to retrieve documents that are contextually relevant for precise answers. - -**Dynamic Model Selection:** Use GPT models (GPT-3.5 or GPT-4) tailored to your needs. - -Technical overview of RAG: [Retrieval Augmented Generation using Azure Machine Learning prompt flow](https://learn.microsoft.com/en-us/azure/machine-learning/concept-retrieval-augmented-generation?view=azureml-api-2#why-use-rag) - -## Prompt Engineering - -**Adaptable Prompt Structure:** Our prompt structure is designed to be compatible with current and future Azure OpenAI's Chat Completion API versions and GPT models, ensuring flexibility and sustainability. - -**Dynamic Prompts:** Dynamic prompt context based on the selected GPT model and users settings. - -**Built-in Chain of Thought (COT):** COT is integrated into our prompts to address fabrications that may arise with Large Language Models (LLM). COT encourages the LLM to follow a set of instructions, explain its reasoning, and enhances the reliability of responses. - -**Few-Shot Prompting:** We employ few-shot prompting in conjunction with COT to further mitigate fabrications and improve response accuracy. - -Go here for more information on [Prompt engineering techniques](https://learn.microsoft.com/en-us/azure/ai-services/openai/concepts/advanced-prompt-engineering?pivots=programming-language-chat-completions) - -## Document Pre-Processing - -**Custom Document Chunking:** The Azure OpenAI GPT models have a maximum token limit, which includes both input and output tokens. Tokens are units of text which can represent a single word, a part of a word, or even a character, depending on the specific language and text encoding being used. Consequently the model will not be able to process a 500 page text based document. Likewise, the models will not be able to process complex file types, such as PDF. This is why we pre-process these documents, before passing these to our search capability to then be exposed by the RAG pattern. Our process focused on - -* content extraction from text-based documents -* creating a standard JSON representation of all a documents text-based content -* chunking and saving metadata into a manageable size to be used in the RAG pattern - -Additional information on this process can be found [here](./docs/functions_flow.md) - -### Azure Cognitive Search Integration - -Search is used to index the chunks that were created during pre-processing. When a question is asked and an optimal search term is generated, this is passed to Search to identify and return the optimal set of chunks to be used in generation of the response. Some further details are listed below - -- **Data Enrichments:** Uses many Out-of-the-box Skillsets to extract enrichments from files such as utilizing Optical Character Recognition (OCR) to process images or converting tables within text into searchable text. - -- **Multilingual Translation:** Leverages the Text Translation skill to interact with your data in supported native languages*, expanding your application's global reach. - -*\*See [Configuring your own language ENV file](./docs/features/configuring_language_env_files.md) for supported languages* - -## Customization and Personalization - -**User-Selectable Options:** Users can fine-tune their interactions by adjusting settings such as temperature and persona, tailoring the AI experience to their specific needs. - -**UX Settings:** Easily tweak behavior and experiment with various options directly in the user interface. - -## Enhanced AI Interaction - -**Simple File Upload and Status:** We have put uploading of files into the Accelerator in the hands of the users by providing a simple drag-and-drop user interface for adding new content and a status page for monitoring document pre-processing. - -**Visualizing Thought Process:** Gain insights into the AI's decision-making process by visualizing how it arrives at answers, providing transparency and control. - -**Proper Citations and References:** The platform generates referenceable source content, designed to enhance trustworthiness and accountability in AI-generated responses. - -## Works in Progress (Future releases) - -**Incorporating Vector and Hybrid Search in Azure Cognitive Search:** We're actively working on enhancing Azure Cognitive Search by incorporating vector and hybrid search capabilities. This will enable more advanced search and retrieval mechanisms, further improving the precision and efficiency of document retrieval. - -**Adding Evaluation Guidance and Metrics:** To ensure transparency and accountability, we are researching comprehensive evaluation guidance and metrics. This will assist users in assessing the performance and trustworthiness of AI-generated responses, fostering confidence in the platform. - -**Research of [Unstructured.io](https://unstructured-io.github.io/unstructured/):** -The unstructured library is open source and designed to help pre-process unstructured data, such as documents, for use in downstream machine learning tasks. Our current position is we will continue with the Document Intelligence service, formerly Form Recognizer, for PDF pre-processing, but we will introduce unstructured.io as a catcher for many document types which we don't currently process. - -![Chat screen](docs/images/info_assistant_chatscreen.png) +![Process Flow](/docs/process_flow.drawio.png) + +## Features + +The IA Accelerator contains several features, many of which have their own documentation. + +* [Retrieval Augmented Generation (RAG)](/docs/features/features.md#retrieval-augmented-generation-rag) +* [Prompt Engineering](/docs/features/features.md#prompt-engineering) +* [Document Pre-Processing](/docs/features/features.md#document-pre-processing) +* [Image Search](/docs/features/features.md#image-search) +* [Azure Cognitive Search Integration](/docs/features/features.md#azure-cognitive-search-integration) +* [Customization and Personalization](/docs/features/features.md#customization-and-personalization) +* [Enhanced AI Interaction](/docs/features/features.md#enhanced-ai-interaction) +* [User Experience](/docs/features/features.md#user-experience) +* [Developer Settings](/docs/features/features.md#developer-settings) + * [Configuring your own language ENV file](/docs/features/features.md#configuring-your-own-language-env-file) + * [Debugging functions](/docs/features/features.md#debugging-functions) + * [Debugging the web app](/docs/features/features.md#debugging-the-web-app) + * [Debugging the container web app](/docs/features/features.md#debugging-the-container-web-app) + * [Build pipeline for Sandbox](/docs/features/features.md#build-pipeline-for-sandbox) + * [Customer Usage Attribution](/docs/features/features.md#customer-usage-attribution) +* [Sovereign Region Deployment](/docs/features/features.md#sovereign-region-deployment) +* [Works in Progress](/docs/features/features.md#works-in-progress-future-releases) For a detailed review see our [Features](./docs/features/features.md) page. ---- - ## Data Collection Notice -The software may collect information about you and your use of the software and send it to Microsoft. Microsoft may use this information to provide services and improve our products and services. You may turn off the telemetry as described in the repository. There are also some features in the software that may enable you and Microsoft to collect data from users of your applications. If you use these features, you must comply with applicable law, including providing appropriate notices to users of your applications together with a copy of Microsoft’s privacy statement. Our privacy statement is located at https://go.microsoft.com/fwlink/?LinkID=824704. You can learn more about data collection and use in the help documentation and our privacy statement. Your use of the software operates as your consent to these practices. +The software may collect information about you and your use of the software and send it to Microsoft. Microsoft may use this information to provide services and improve our products and services. You may turn off the telemetry as described in the repository. There are also some features in the software that may enable you and Microsoft to collect data from users of your applications. If you use these features, you must comply with applicable law, including providing appropriate notices to users of your applications together with a copy of Microsoft’s privacy statement. Our privacy statement is located at . You can learn more about data collection and use in the help documentation and our privacy statement. Your use of the software operates as your consent to these practices. ### About Data Collection @@ -91,104 +44,21 @@ Data collection is implemented by the presence of a tracking GUID in the environ ### How to Disable Data Collection -To disable data collection, follow the instructions in the [Configure ENV files](./docs/development_environment.md#configure-env-files) section for `ENABLE_CUSTOMER_USAGE_ATTRIBUTION` variable before deploying. - ---- +To disable data collection, follow the instructions in the [Configure ENV files](/docs/deployment/deployment.md#configure-env-files) section for `ENABLE_CUSTOMER_USAGE_ATTRIBUTION` variable before deploying. ## Responsible AI -The Information Assistant (IA) Accelerator and Microsoft are committed to the advancement of AI driven by ethical principles that put people first. +The Information Assistant (IA) Accelerator and Microsoft are committed to the advancement of AI driven by ethical principles that put people first. -**Read our [Transparency Note](./docs/transparency.md)** +**Read our [Transparency Note](/docs/transparency.md)** Find out more with Microsoft's [Responsible AI resources](https://www.microsoft.com/en-us/ai/responsible-ai) ---- - -## Getting Started - -The IA Accelerator relies on multiple Azure services and has certain prerequisites that need to be met before deployment. It's essential to procure these prerequisites prior to proceeding with the deployment instructions in this guide. - ---- - -## Prerequisites - -To get started with the IA Accelerator you will need the following: -> ->* An azure subscription with access enabled for the Azure OpenAI service. -You can request access [here](https://aka.ms/oaiapply) * ->* Administrative rights on the Azure Subscription ->* [Visual studio code](https://code.visualstudio.com/) -> -> -You can sign up for an Azure subscription [here](https://azure.microsoft.com/en-us/free/). - -Once you have your prerequisite items, please move on to the Deployment Configuration step. - -**NOTICE:** * This codebase relies on the Azure OpenAI Service which must be procured first separately, subject to any applicable license agreement. Access to this code does not grant you a license or right to use Azure OpenAI Service. - -The Information Assistant Accelerator requires access to one of the following Azure OpenAI models. - -Model Name | Supported Versions ----|--- -gpt-35-turbo | 0301, 0613 -**gpt-35-turbo-16k** | current version -**gpt-4** | current version -gpt-4-32k | current version - -**Important:** It is recommended to use gpt-4 models to achieve the best results from the IA Accelerator. Access to gpt-4 requires approval which can be requested [here](https://aka.ms/oai/get-gpt4). If gpt-4 access is not available gpt-35-turbo-16k (0613) is recommended. - ---- - -## Deployment Configuration - -The deployment process for the IA Accelerator, uses a concept of **Developing inside a Container** to containerize all the necessary prerequisite components without requiring them to be installed on the local machine. The environment you will work in will be created using a development container, or dev container hosted on a virtual machine using GitHub Codespaces. - -[![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/microsoft/PubSec-Info-Assistant) - -Begin by setting up your own Codespace using our [Developing in a Codespaces](./docs/developing_in_a_codespaces.md) documentation. - -*If you want to configure your local desktop for development container, follow our [Configuring your System for Development Containers](./docs/configure_local_dev_environment.md) guide. More information can be found at [Developing inside a Container](https://code.visualstudio.com/docs/remote/containers).* - -Once you have the completed the setting up Codespaces, please move on to the Sizing Estimation step. - ---- - -## Sizing Estimator - - The IA Accelerator needs to be sized appropriately based on your use case. Please review our [Sizing Estimator](./docs/costestimator.md) to help find the configuration that fits your needs. - -Once you have completed the Sizing Estimator, please move on to the Deployment steps. - ---- - ## Deployment -The following checklist will guide you through configuring the IA Accelerator in your Azure subscription. Please follow the steps in the order they are provided as values from one step may be used in subsequent steps. - ->1. Configure your deployment settings -> * [Configuring your Development Environment](./docs/development_environment.md) ->1. Configure Azure resources -> * [Configure Azure resources](https://github.com/microsoft/PubSec-Info-Assistant/blob/main/infra/README.md) - ---- +Please follow the instructions in [the deployment guide](/docs/deployment/deployment.md) to install the IA Accelerator in your Azure subscription. -## Using IA Accelerator for the first time - -Now that you have successfully deployed the IA Accelerator, you are ready to use the accelerator to process some data. - -To use the IA Accelerator, you need to follow these steps: - -> 1. Prepare your data and upload it to Azure. -> -> * Your data must be in a specified format to be valid for processing. See our [supported document types in the Feature documentation](./docs/features/features.md#document-pre-processing). -> * Upload your data [via the data upload user interface](./docs/features/features.md#uploading-documents). -> 2. Once uploaded the system will automatically process and make the document(s) available to you and other users of the deployment. -> 3. Begin [having conversations with your data](./docs/features/features.md#having-a-conversation-with-your-data) by selecting the appropriate interaction method. - -For more detailed information review the [Features](./docs/features/features.md) section of the documentation. - ---- +Once completed, follow the [instructions for using IA Accelerator for the first time](/docs/deployment/using_ia_first_time.md). ## Navigating the Source Code @@ -198,22 +68,22 @@ File/Folder | Description ---|--- .devcontainer/ | Dockerfile, devcontainer configuration, and supporting script to enable both CodeSpaces and local DevContainers. app/backend/ | The middleware part of the IA website that contains the prompt engineering and provides an API layer for the client code to pass through when communicating with the various Azure services. This code is python based and hosted as a Flask app. +app/enrichment/ | The text-based file enrichment process that handles language translation, embedding the text chunks, and inserting text chunks into the Azure Cognitive Search hybrid index. This code is python based and is hosted as a Flask app that subscribes to an Azure Storage Queue. app/frontend/ | The User Experience layer of the IA website. This code is Typescript based and hosted as a Vite app and compiled using npm. -azure_search/ | The configuration of the Azure Search Index, Indexer, Skillsets, and Data Source that are applied in the deployment scripts. +azure_search/ | The configuration of the Azure Search hybrid index that is applied in the deployment scripts. docs/adoption_workshop/ | PPT files that match what is covered in the Adoption Workshop videos in Discussions. +docs/deployment/ | Detailed documentation on how to deploy and start using Information Assistant. docs/features/ | Detailed documentation of specific features and development level configuration for Information Assistant. -docs/ | Deployment and other supporting documentation that is primarily linked to from the README.md +docs/ | Other supporting documentation that is primarily linked to from the other markdown files. functions/ | The pipeline of Azure Functions that handle the document extraction and chunking as well as the custom CosmosDB logging. -infra/ | The BICEP scripts that deploy the entire IA Accelerator. The overall accelerator is orchestrated via the `main.bicep` file but most of the resource deployments are modularized under the **core** folder. +infra/ | The BICEP scripts that deploy the entire IA Accelerator. The overall accelerator is orchestrated via the `main.bicep` file but most of the resource deployments are modularized under the **core** folder. pipelines/ | Azure DevOps pipelines that can be used to enable CI/CD deployments of the accelerator. scripts/environments/ | Deployment configuration files. This is where all external configuration values will be set. scripts/ | Supporting scripts that perform the various deployment tasks such as infrastructure deployment, Azure WebApp and Function deployments, building of the webapp and functions source code, etc. These scripts align to the available commands in the `Makefile`. +tests/ | Functional Test scripts that are used to validate a deployed Information Assistant's document processing pipelines are working as expected. Makefile | Deployment command definitions and configurations. You can use `make help` to get more details on available commands. README.md | Starting point for this repo. It covers overviews of the Accelerator, Responsible AI, Environment, Deployment, and Usage of the Accelerator. - ---- - ## Resources * [Revolutionize your Enterprise Data with ChatGPT: Next-gen Apps w/ Azure OpenAI and Cognitive Search](https://aka.ms/entgptsearchblog) diff --git a/app/backend/app.py b/app/backend/app.py index 62e15a446..0615f7f88 100644 --- a/app/backend/app.py +++ b/app/backend/app.py @@ -21,17 +21,21 @@ generate_account_sas, ) from flask import Flask, jsonify, request -from shared_code.status_log import State, StatusLog +from shared_code.status_log import State, StatusClassification, StatusLog +from shared_code.tags_helper import TagsHelper +str_to_bool = {'true': True, 'false': False} # Replace these with your own values, either in environment variables or directly here AZURE_BLOB_STORAGE_ACCOUNT = ( os.environ.get("AZURE_BLOB_STORAGE_ACCOUNT") or "mystorageaccount" ) +AZURE_BLOB_STORAGE_ENDPOINT = os.environ.get("AZURE_BLOB_STORAGE_ENDPOINT") AZURE_BLOB_STORAGE_KEY = os.environ.get("AZURE_BLOB_STORAGE_KEY") AZURE_BLOB_STORAGE_CONTAINER = ( os.environ.get("AZURE_BLOB_STORAGE_CONTAINER") or "content" ) AZURE_SEARCH_SERVICE = os.environ.get("AZURE_SEARCH_SERVICE") or "gptkb" +AZURE_SEARCH_SERVICE_ENDPOINT = os.environ.get("AZURE_SEARCH_SERVICE_ENDPOINT") AZURE_SEARCH_SERVICE_KEY = os.environ.get("AZURE_SEARCH_SERVICE_KEY") AZURE_SEARCH_INDEX = os.environ.get("AZURE_SEARCH_INDEX") or "gptkbindex" AZURE_OPENAI_SERVICE = os.environ.get("AZURE_OPENAI_SERVICE") or "myopenai" @@ -39,20 +43,39 @@ AZURE_OPENAI_CHATGPT_DEPLOYMENT = ( os.environ.get("AZURE_OPENAI_CHATGPT_DEPLOYMENT") or "chat" ) +AZURE_OPENAI_CHATGPT_MODEL_NAME = ( os.environ.get("AZURE_OPENAI_CHATGPT_MODEL_NAME") or "") +AZURE_OPENAI_CHATGPT_MODEL_VERSION = ( os.environ.get("AZURE_OPENAI_CHATGPT_MODEL_VERSION") or "") +USE_AZURE_OPENAI_EMBEDDINGS = str_to_bool.get(os.environ.get("USE_AZURE_OPENAI_EMBEDDINGS").lower()) or False +EMBEDDING_DEPLOYMENT_NAME = ( os.environ.get("EMBEDDING_DEPLOYMENT_NAME") or "") +AZURE_OPENAI_EMBEDDINGS_MODEL_NAME = ( os.environ.get("AZURE_OPENAI_EMBEDDINGS_MODEL_NAME") or "") +AZURE_OPENAI_EMBEDDINGS_VERSION = ( os.environ.get("AZURE_OPENAI_EMBEDDINGS_VERSION") or "") + AZURE_OPENAI_SERVICE_KEY = os.environ.get("AZURE_OPENAI_SERVICE_KEY") AZURE_SUBSCRIPTION_ID = os.environ.get("AZURE_SUBSCRIPTION_ID") +IS_GOV_CLOUD_DEPLOYMENT = str_to_bool.get(os.environ.get("IS_GOV_CLOUD_DEPLOYMENT").lower()) or False +CHAT_WARNING_BANNER_TEXT = os.environ.get("CHAT_WARNING_BANNER_TEXT") or "" +APPLICATION_TITLE = os.environ.get("APPLICATION_TITLE") or "Information Assistant, built with Azure OpenAI" + -KB_FIELDS_CONTENT = os.environ.get("KB_FIELDS_CONTENT") or "merged_content" -KB_FIELDS_CATEGORY = os.environ.get("KB_FIELDS_CATEGORY") or "category" -KB_FIELDS_SOURCEPAGE = os.environ.get("KB_FIELDS_SOURCEPAGE") or "file_storage_path" +KB_FIELDS_CONTENT = os.environ.get("KB_FIELDS_CONTENT") or "content" +KB_FIELDS_PAGENUMBER = os.environ.get("KB_FIELDS_PAGENUMBER") or "pages" +KB_FIELDS_SOURCEFILE = os.environ.get("KB_FIELDS_SOURCEFILE") or "file_uri" +KB_FIELDS_CHUNKFILE = os.environ.get("KB_FIELDS_CHUNKFILE") or "chunk_file" COSMOSDB_URL = os.environ.get("COSMOSDB_URL") COSMODB_KEY = os.environ.get("COSMOSDB_KEY") -COSMOSDB_DATABASE_NAME = os.environ.get("COSMOSDB_DATABASE_NAME") or "statusdb" -COSMOSDB_CONTAINER_NAME = os.environ.get("COSMOSDB_CONTAINER_NAME") or "statuscontainer" +COSMOSDB_LOG_DATABASE_NAME = os.environ.get("COSMOSDB_LOG_DATABASE_NAME") or "statusdb" +COSMOSDB_LOG_CONTAINER_NAME = os.environ.get("COSMOSDB_LOG_CONTAINER_NAME") or "statuscontainer" +COSMOSDB_TAGS_DATABASE_NAME = os.environ.get("COSMOSDB_TAGS_DATABASE_NAME") or "tagsdb" +COSMOSDB_TAGS_CONTAINER_NAME = os.environ.get("COSMOSDB_TAGS_CONTAINER_NAME") or "tagscontainer" QUERY_TERM_LANGUAGE = os.environ.get("QUERY_TERM_LANGUAGE") or "English" +TARGET_EMBEDDING_MODEL = os.environ.get("TARGET_EMBEDDINGS_MODEL") or "BAAI/bge-small-en-v1.5" +ENRICHMENT_APPSERVICE_NAME = os.environ.get("ENRICHMENT_APPSERVICE_NAME") or "enrichment" + +# embedding_service_suffix = "xyoek" + # Use the current user identity to authenticate with Azure OpenAI, Cognitive Search and Blob Storage (no secrets needed, # just use 'az login' locally, and managed identity when deployed on Azure). If you need to use keys, use separate AzureKeyCredential instances with the # keys for each service @@ -67,7 +90,10 @@ # Setup StatusLog to allow access to CosmosDB for logging statusLog = StatusLog( - COSMOSDB_URL, COSMODB_KEY, COSMOSDB_DATABASE_NAME, COSMOSDB_CONTAINER_NAME + COSMOSDB_URL, COSMODB_KEY, COSMOSDB_LOG_DATABASE_NAME, COSMOSDB_LOG_CONTAINER_NAME +) +tagsHelper = TagsHelper( + COSMOSDB_URL, COSMODB_KEY, COSMOSDB_TAGS_DATABASE_NAME, COSMOSDB_TAGS_CONTAINER_NAME ) # Comment these two lines out if using keys, set your API key in the OPENAI_API_KEY environment variable instead @@ -77,25 +103,49 @@ # Set up clients for Cognitive Search and Storage search_client = SearchClient( - endpoint=f"https://{AZURE_SEARCH_SERVICE}.search.windows.net", + endpoint=AZURE_SEARCH_SERVICE_ENDPOINT, index_name=AZURE_SEARCH_INDEX, credential=azure_search_key_credential, ) blob_client = BlobServiceClient( - account_url=f"https://{AZURE_BLOB_STORAGE_ACCOUNT}.blob.core.windows.net", + account_url=AZURE_BLOB_STORAGE_ENDPOINT, credential=AZURE_BLOB_STORAGE_KEY, ) blob_container = blob_client.get_container_client(AZURE_BLOB_STORAGE_CONTAINER) -# Set up OpenAI management client -openai_mgmt_client = CognitiveServicesManagementClient( - credential=azure_credential, - subscription_id=AZURE_SUBSCRIPTION_ID) +model_name = '' +model_version = '' + +if (IS_GOV_CLOUD_DEPLOYMENT): + model_name = AZURE_OPENAI_CHATGPT_MODEL_NAME + model_version = AZURE_OPENAI_CHATGPT_MODEL_VERSION + embedding_model_name = AZURE_OPENAI_EMBEDDINGS_MODEL_NAME + embedding_model_version = AZURE_OPENAI_EMBEDDINGS_VERSION +else: + # Set up OpenAI management client + openai_mgmt_client = CognitiveServicesManagementClient( + credential=azure_credential, + subscription_id=AZURE_SUBSCRIPTION_ID) + + deployment = openai_mgmt_client.deployments.get( + resource_group_name=AZURE_OPENAI_RESOURCE_GROUP, + account_name=AZURE_OPENAI_SERVICE, + deployment_name=AZURE_OPENAI_CHATGPT_DEPLOYMENT) -deployment = openai_mgmt_client.deployments.get( - resource_group_name=AZURE_OPENAI_RESOURCE_GROUP, - account_name=AZURE_OPENAI_SERVICE, - deployment_name=AZURE_OPENAI_CHATGPT_DEPLOYMENT) + model_name = deployment.properties.model.name + model_version = deployment.properties.model.version + + if USE_AZURE_OPENAI_EMBEDDINGS: + embedding_deployment = openai_mgmt_client.deployments.get( + resource_group_name=AZURE_OPENAI_RESOURCE_GROUP, + account_name=AZURE_OPENAI_SERVICE, + deployment_name=EMBEDDING_DEPLOYMENT_NAME) + + embedding_model_name = embedding_deployment.properties.model.name + embedding_model_version = embedding_deployment.properties.model.version + else: + embedding_model_name = "" + embedding_model_version = "" chat_approaches = { "rrr": ChatReadRetrieveReadApproach( @@ -103,12 +153,18 @@ AZURE_OPENAI_SERVICE, AZURE_OPENAI_SERVICE_KEY, AZURE_OPENAI_CHATGPT_DEPLOYMENT, - KB_FIELDS_SOURCEPAGE, + KB_FIELDS_SOURCEFILE, KB_FIELDS_CONTENT, + KB_FIELDS_PAGENUMBER, + KB_FIELDS_CHUNKFILE, + AZURE_BLOB_STORAGE_CONTAINER, blob_client, QUERY_TERM_LANGUAGE, - deployment.properties.model.name, - deployment.properties.model.version + model_name, + model_version, + IS_GOV_CLOUD_DEPLOYMENT, + TARGET_EMBEDDING_MODEL, + ENRICHMENT_APPSERVICE_NAME ) } @@ -118,10 +174,12 @@ @app.route("/", defaults={"path": "index.html"}) @app.route("/") def static_file(path): + """Serve static files from the 'static' directory""" return app.send_static_file(path) @app.route("/chat", methods=["POST"]) def chat(): + """Chat with the bot using a given approach""" approach = request.json["approach"] try: impl = chat_approaches.get(approach) @@ -140,12 +198,13 @@ def chat(): } ) - except Exception as e: + except Exception as ex: logging.exception("Exception in /chat") - return jsonify({"error": str(e)}), 500 + return jsonify({"error": str(ex)}), 500 @app.route("/getblobclienturl") def get_blob_client_url(): + """Get a URL for a file in Blob Storage with SAS token""" sas_token = generate_account_sas( AZURE_BLOB_STORAGE_ACCOUNT, AZURE_BLOB_STORAGE_KEY, @@ -166,41 +225,100 @@ def get_blob_client_url(): @app.route("/getalluploadstatus", methods=["POST"]) def get_all_upload_status(): + """Get the status of all file uploads in the last N hours""" timeframe = request.json["timeframe"] state = request.json["state"] try: results = statusLog.read_files_status_by_timeframe(timeframe, State[state]) - except Exception as e: + except Exception as ex: logging.exception("Exception in /getalluploadstatus") - return jsonify({"error": str(e)}), 500 + return jsonify({"error": str(ex)}), 500 return jsonify(results) +@app.route("/logstatus", methods=["POST"]) +def logstatus(): + """Log the status of a file upload to CosmosDB""" + try: + path = request.json["path"] + status = request.json["status"] + status_classification = StatusClassification[request.json["status_classification"].upper()] + state = State[request.json["state"].upper()] + + statusLog.upsert_document(document_path=path, + status=status, + status_classification=status_classification, + state=state, + fresh_start=True) + statusLog.save_document(document_path=path) + + except Exception as ex: + logging.exception("Exception in /logstatus") + return jsonify({"error": str(ex)}), 500 + return jsonify({"status": 200}) + # Return AZURE_OPENAI_CHATGPT_DEPLOYMENT @app.route("/getInfoData") def get_info_data(): + """Get the info data for the app""" response = jsonify( { "AZURE_OPENAI_CHATGPT_DEPLOYMENT": f"{AZURE_OPENAI_CHATGPT_DEPLOYMENT}", - "AZURE_OPENAI_MODEL_NAME": f"{deployment.properties.model.name}", - "AZURE_OPENAI_MODEL_VERSION": f"{deployment.properties.model.version}", + "AZURE_OPENAI_MODEL_NAME": f"{model_name}", + "AZURE_OPENAI_MODEL_VERSION": f"{model_version}", "AZURE_OPENAI_SERVICE": f"{AZURE_OPENAI_SERVICE}", "AZURE_SEARCH_SERVICE": f"{AZURE_SEARCH_SERVICE}", "AZURE_SEARCH_INDEX": f"{AZURE_SEARCH_INDEX}", - "TARGET_LANGUAGE": f"{QUERY_TERM_LANGUAGE}" + "TARGET_LANGUAGE": f"{QUERY_TERM_LANGUAGE}", + "USE_AZURE_OPENAI_EMBEDDINGS": USE_AZURE_OPENAI_EMBEDDINGS, + "EMBEDDINGS_DEPLOYMENT": f"{EMBEDDING_DEPLOYMENT_NAME}", + "EMBEDDINGS_MODEL_NAME": f"{embedding_model_name}", + "EMBEDDINGS_MODEL_VERSION": f"{embedding_model_version}", + }) + return response + +# Return AZURE_OPENAI_CHATGPT_DEPLOYMENT +@app.route("/getWarningBanner") +def get_warning_banner(): + """Get the warning banner text""" + response = jsonify( + { + "WARNING_BANNER_TEXT": f"{CHAT_WARNING_BANNER_TEXT}" }) return response @app.route("/getcitation", methods=["POST"]) def get_citation(): + """Get the citation for a given file""" citation = urllib.parse.unquote(request.json["citation"]) try: blob = blob_container.get_blob_client(citation).download_blob() decoded_text = blob.readall().decode() results = jsonify(json.loads(decoded_text)) - except Exception as e: + except Exception as ex: logging.exception("Exception in /getalluploadstatus") - return jsonify({"error": str(e)}), 500 + return jsonify({"error": str(ex)}), 500 return jsonify(results.json) +# Return APPLICATION_TITLE +@app.route("/getApplicationTitle") +def get_application_title(): + """Get the application title text""" + response = jsonify( + { + "APPLICATION_TITLE": f"{APPLICATION_TITLE}" + }) + return response + +@app.route("/getalltags", methods=["GET"]) +def get_all_tags(): + """Get the status of all tags in the system""" + try: + results = tagsHelper.get_all_tags() + except Exception as ex: + logging.exception("Exception in /getalltags") + return jsonify({"error": str(ex)}), 500 + return jsonify(results) + if __name__ == "__main__": - app.run() + logging.info("IA WebApp Starting Up...") + app.run(threaded=True) diff --git a/app/backend/approaches/chatreadretrieveread.py b/app/backend/approaches/chatreadretrieveread.py index e9f184b88..2bcce8280 100644 --- a/app/backend/approaches/chatreadretrieveread.py +++ b/app/backend/approaches/chatreadretrieveread.py @@ -1,4 +1,8 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + import json +import re import logging import urllib.parse from datetime import datetime, timedelta @@ -6,8 +10,14 @@ import openai from approaches.approach import Approach -from azure.search.documents import SearchClient +from azure.core.credentials import AzureKeyCredential +from azure.search.documents import SearchClient +from azure.search.documents.indexes import SearchIndexClient +from azure.search.documents.models import RawVectorQuery from azure.search.documents.models import QueryType + +from text import nonewlines +from datetime import datetime, timedelta from azure.storage.blob import ( AccountSasPermissions, BlobServiceClient, @@ -19,6 +29,8 @@ from core.messagebuilder import MessageBuilder from core.modelhelper import get_token_limit from core.modelhelper import num_tokens_from_messages +import requests +from urllib.parse import quote # Simple retrieve-then-read implementation, using the Cognitive Search and # OpenAI APIs directly. It first retrieves top documents from search, @@ -77,44 +89,64 @@ class ChatReadRetrieveReadApproach(Approach): {'role': USER, 'content': 'What steps are being taken to promote energy conservation?'}, {'role': ASSISTANT, 'content': 'Several steps are being taken to promote energy conservation including reducing energy consumption, increasing energy efficiency, and increasing the use of renewable energy sources.Citations[info1.json]'} ] - + + # # Define a class variable for the base URL + # EMBEDDING_SERVICE_BASE_URL = 'https://infoasst-cr-{}.azurewebsites.net' + def __init__( self, search_client: SearchClient, oai_service_name: str, oai_service_key: str, chatgpt_deployment: str, - source_page_field: str, + source_file_field: str, content_field: str, + page_number_field: str, + chunk_file_field: str, + content_storage_container: str, blob_client: BlobServiceClient, query_term_language: str, model_name: str, - model_version: str + model_version: str, + is_gov_cloud_deployment: str, + TARGET_EMBEDDING_MODEL: str, + ENRICHMENT_APPSERVICE_NAME: str ): self.search_client = search_client self.chatgpt_deployment = chatgpt_deployment - self.source_page_field = source_page_field + self.source_file_field = source_file_field self.content_field = content_field + self.page_number_field = page_number_field + self.chunk_file_field = chunk_file_field + self.content_storage_container = content_storage_container self.blob_client = blob_client self.query_term_language = query_term_language self.chatgpt_token_limit = get_token_limit(model_name) - + #escape target embeddiong model name + self.escaped_target_model = re.sub(r'[^a-zA-Z0-9_\-.]', '_', TARGET_EMBEDDING_MODEL) + + if is_gov_cloud_deployment: + self.embedding_service_url = f'https://{ENRICHMENT_APPSERVICE_NAME}.azurewebsites.us' + else: + self.embedding_service_url = f'https://{ENRICHMENT_APPSERVICE_NAME}.azurewebsites.net' + openai.api_base = 'https://' + oai_service_name + '.openai.azure.com/' openai.api_type = 'azure' openai.api_key = oai_service_key self.model_name = model_name self.model_version = model_version + self.is_gov_cloud_deployment = is_gov_cloud_deployment # def run(self, history: list[dict], overrides: dict) -> any: def run(self, history: Sequence[dict[str, str]], overrides: dict[str, Any]) -> Any: use_semantic_captions = True if overrides.get("semantic_captions") else False top = overrides.get("top") or 3 - exclude_category = overrides.get("exclude_category") or None - category_filter = "category ne '{}'".format(exclude_category.replace("'", "''")) if exclude_category else None user_persona = overrides.get("user_persona", "") system_persona = overrides.get("system_persona", "") response_length = int(overrides.get("response_length") or 1024) + folder_filter = overrides.get("selected_folders", "") + tags_filter = overrides.get("selected_tags", "") user_q = 'Generate search query for: ' + history[-1]["user"] @@ -131,7 +163,6 @@ def run(self, history: Sequence[dict[str, str]], overrides: dict[str, Any]) -> A ) chat_completion = openai.ChatCompletion.create( - deployment_id=self.chatgpt_deployment, model=self.model_name, messages=messages, @@ -140,78 +171,94 @@ def run(self, history: Sequence[dict[str, str]], overrides: dict[str, Any]) -> A n=1) generated_query = chat_completion.choices[0].message.content - #if we fail to generate a query, return the last user question if generated_query.strip() == "0": generated_query = history[-1]["user"] - # STEP 2: Retrieve relevant documents from the search index with the optimized query term - if overrides.get("semantic_ranker"): - raw_search_results = self.search_client.search( + # Generate embedding using REST API + url = f'{self.embedding_service_url}/models/{self.escaped_target_model}/embed' + data = [f'"{generated_query}"'] + headers = { + 'Accept': 'application/json', + 'Content-Type': 'application/json', + } + + response = requests.post(url, json=data,headers=headers,timeout=60) + if response.status_code == 200: + response_data = response.json() + embedded_query_vector =response_data.get('data') + else: + logging.error(f"Error generating embedding:: {response.status_code}") + raise Exception('Error generating embedding:', response.status_code) + + #vector set up for pure vector search & Hybrid search & Hybrid semantic + vector = RawVectorQuery(vector=embedded_query_vector, k=top, fields="contentVector") + + #Create a filter for the search query + if (folder_filter != "") & (folder_filter != "All"): + search_filter = f"search.in(folder, '{folder_filter}')" + else: + search_filter = None + if tags_filter != "" : + quoted_tags_filter = tags_filter.replace(",","','") + if search_filter is not None: + search_filter = search_filter + f" and tags/any(t: search.in(t, '{quoted_tags_filter}'))" + else: + search_filter = f"tags/any(t: search.in(t, '{quoted_tags_filter}'))" + + # Hybrid Search + # r = self.search_client.search(generated_query, vector_queries =[vector], top=top) + + # Pure Vector Search + # r=self.search_client.search(search_text=None,vector_queries =[vector], top=top) + + # vector search with filter + # r=self.search_client.search(search_text=None, vectors=[vector], filter="processed_datetime le 2023-09-18T04:06:29.675Z" , top=top) + # r=self.search_client.search(search_text=None, vectors=[vector], filter="search.ismatch('upload/ospolicydocs/China, climate change and the energy transition.pdf', 'file_name')", top=top) + + # hybrid semantic search using semantic reranker + + if (not self.is_gov_cloud_deployment and overrides.get("semantic_ranker")): + r = self.search_client.search( generated_query, - filter=category_filter, query_type=QueryType.SEMANTIC, query_language="en-us", query_speller="lexicon", semantic_configuration_name="default", top=top, query_caption="extractive|highlight-false" - if use_semantic_captions - else None, + if use_semantic_captions else None, + vector_queries =[vector], + filter=search_filter ) else: - raw_search_results = self.search_client.search( - generated_query, filter=category_filter, top=top + r = self.search_client.search( + generated_query, top=top,vector_queries =[vector], filter=search_filter ) citation_lookup = {} # dict of "FileX" moniker to the actual file name results = [] # list of results to be used in the prompt data_points = [] # list of data points to be used in the response - for idx, doc in enumerate( - raw_search_results - ): # for each document in the search results - if use_semantic_captions: - # if using semantic captions, use the captions instead of the content - # include the "FileX" moniker in the prompt, and the actual file name in the response - results.append( - f"File{idx} " - + "| " - + nonewlines(" . ".join([c.text for c in doc["@search.captions"]])) - ) - data_points.append( - "/".join(doc[self.source_page_field].split("/")[4:]) - + "| " - + nonewlines(" . ".join([c.text for c in doc["@search.captions"]])) - ) - else: - # if not using semantic captions, use the content instead of the captions - # include the "FileX" moniker in the prompt, and the actual file name in the response - results.append( - f"File{idx} " + "| " + nonewlines(doc[self.content_field]) - ) - data_points.append( - "/".join( - urllib.parse.unquote(doc[self.source_page_field]).split("/")[4:] - ) - + "| " - + nonewlines(doc[self.content_field]) + for idx, doc in enumerate(r): # for each document in the search results + # include the "FileX" moniker in the prompt, and the actual file name in the response + results.append( + f"File{idx} " + "| " + nonewlines(doc[self.content_field]) + ) + data_points.append( + "/".join(urllib.parse.unquote(doc[self.source_file_field]).split("/")[4:] + ) + "| " + nonewlines(doc[self.content_field]) ) - # uncomment to debug size of each search result content_field - print(f"File{idx}: ", self.num_tokens_from_string(f"File{idx} " + "| " + nonewlines(doc[self.content_field]), "cl100k_base")) - # add the "FileX" moniker and full file name to the citation lookup + # uncomment to debug size of each search result content_field + # print(f"File{idx}: ", self.num_tokens_from_string(f"File{idx} " + / + # "| " + nonewlines(doc[self.content_field]), "cl100k_base")) + # add the "FileX" moniker and full file name to the citation lookup citation_lookup[f"File{idx}"] = { - "citation": urllib.parse.unquote(doc[self.source_page_field]), - "source_path": self.get_source_file_name(doc[self.content_field]), - "page_number": self.get_first_page_num_for_chunk( - doc[self.content_field] - ), - } - - - - + "citation": urllib.parse.unquote("https://" + doc[self.source_file_field].split("/")[2] + f"/{self.content_storage_container}/" + doc[self.chunk_file_field]), + "source_path": self.get_source_file_with_sas(doc[self.source_file_field]), + "page_number": str(doc[self.page_number_field][0]) or "0", + } # create a single string of all the results to be used in the prompt results_text = "".join(results) @@ -282,7 +329,6 @@ def run(self, history: Sequence[dict[str, str]], overrides: dict[str, Any]) -> A #print("System Message Tokens: ", self.num_tokens_from_string(system_message, "cl100k_base")) #print("Few Shot Tokens: ", self.num_tokens_from_string(self.response_prompt_few_shots[0]['content'], "cl100k_base")) #print("Message Tokens: ", self.num_tokens_from_string(message_string, "cl100k_base")) - chat_completion = openai.ChatCompletion.create( deployment_id=self.chatgpt_deployment, @@ -291,7 +337,7 @@ def run(self, history: Sequence[dict[str, str]], overrides: dict[str, Any]) -> A temperature=float(overrides.get("response_temp")) or 0.6, n=1 ) - + elif self.model_name.startswith("gpt-4"): messages = self.get_messages_from_history( "Sources:\n" + content + "\n\n" + system_message, @@ -321,25 +367,9 @@ def run(self, history: Sequence[dict[str, str]], overrides: dict[str, Any]) -> A temperature=float(overrides.get("response_temp")) or 0.6, max_tokens=1024, n=1 - ) - # chat_completion = openai.ChatCompletion.create( - # deployment_id=self.chatgpt_deployment, - # model=self.model_name, - # messages=messages, - # temperature=float(overrides.get("response_temp")) or 0.6, - # max_tokens=1024, - # n=1 - - # ) - - #Aparmar.Token Debugging Code. Uncomment to debug token usage. - # generated_response_message = chat_completion.choices[0].message - # # Count the tokens in the generated response message - # token_count = num_tokens_from_messages(generated_response_message, 'gpt-4') - # print("Generated Response Tokens:", token_count) - + # STEP 4: Format the response msg_to_display = '\n\n'.join([str(message) for message in messages]) return { @@ -384,6 +414,7 @@ def get_messages_from_history( #Get the prompt text for the response length def get_response_length_prompt_text(self, response_length: int): + """ Function to return the response length prompt text""" levels = { 1024: "succinct", 2048: "standard", @@ -392,18 +423,15 @@ def get_response_length_prompt_text(self, response_length: int): level = levels[response_length] return f"Please provide a {level} answer. This means that your answer should be no more than {response_length} tokens long." - def get_source_file_name(self, content: str) -> str: - """ - Parse the search document content for "file_name" attribute and generate a SAS token for it. - - Args: - content: The search document content (JSON string) + def num_tokens_from_string(self, string: str, encoding_name: str) -> int: + """ Function to return the number of tokens in a text string""" + encoding = tiktoken.get_encoding(encoding_name) + num_tokens = len(encoding.encode(string)) + return num_tokens - Returns: - The source file name with SAS token. - """ + def get_source_file_with_sas(self, source_file: str) -> str: + """ Function to return the source file with a SAS token""" try: - source_path = urllib.parse.unquote(json.loads(content)["file_name"]) sas_token = generate_account_sas( self.blob_client.account_name, self.blob_client.credential.account_key, @@ -420,32 +448,7 @@ def get_source_file_name(self, content: str) -> str: ), expiry=datetime.utcnow() + timedelta(hours=1), ) - return self.blob_client.url + source_path + "?" + sas_token + return source_file + "?" + sas_token except Exception as error: - logging.exception("Unable to parse source file name: " + str(error) + "") - return "" - - def get_first_page_num_for_chunk(self, content: str) -> str: - """ - Parse the search document content for the first page from the "pages" attribute - - Args: - content: The search document content (JSON string) - - Returns: - The first page number. - """ - try: - page_num = str(json.loads(content)["pages"][0]) - if page_num is None: - return "0" - return page_num - except Exception as error: - logging.exception("Unable to parse first page num: " + str(error) + "") - return "0" - - def num_tokens_from_string(self, string: str, encoding_name: str) -> int: - """ Function to return the number of tokens in a text string""" - encoding = tiktoken.get_encoding(encoding_name) - num_tokens = len(encoding.encode(string)) - return num_tokens \ No newline at end of file + logging.error(f"Unable to parse source file name: {str(error)}") + return "" \ No newline at end of file diff --git a/app/backend/core/modelhelper.py b/app/backend/core/modelhelper.py index 6508416e5..970d5ed8a 100644 --- a/app/backend/core/modelhelper.py +++ b/app/backend/core/modelhelper.py @@ -1,12 +1,14 @@ import tiktoken +#Values from https://platform.openai.com/docs/models/gpt-3-5 + MODELS_2_TOKEN_LIMITS = { - "gpt-35-turbo": 4000, - "gpt-3.5-turbo": 4000, - "gpt-35-turbo-16k": 16000, - "gpt-3.5-turbo-16k": 16000, - "gpt-4": 8100, - "gpt-4-32k": 32000 + "gpt-35-turbo": 4097, + "gpt-3.5-turbo": 4097, + "gpt-35-turbo-16k": 16385, + "gpt-3.5-turbo-16k": 16385, + "gpt-4": 8192, + "gpt-4-32k": 32768 } AOAI_2_OAI = { @@ -17,7 +19,7 @@ def get_token_limit(model_id: str) -> int: if model_id not in MODELS_2_TOKEN_LIMITS: - raise ValueError("Expected model gpt-35-turbo and above") + raise ValueError("Expected model gpt-35-turbo and above. Got: " + model_id) return MODELS_2_TOKEN_LIMITS.get(model_id) diff --git a/app/backend/requirements.txt b/app/backend/requirements.txt index 2ff021a29..518909a03 100644 --- a/app/backend/requirements.txt +++ b/app/backend/requirements.txt @@ -1,9 +1,12 @@ +#### Any version change made here should also be made and tested for the enrichment and function apps in /functions and /app/enrichment + azure-identity==1.12.0 Flask==2.3.2 langchain>=0.0.157 azure-mgmt-cognitiveservices==13.5.0 openai==0.27.0 -azure-search-documents==11.4.0b3 -azure-storage-blob==12.14.1 +# azure-search-documents==11.4.0b3 +azure-search-documents==11.4.0b11 +azure-storage-blob==12.16.0 azure-cosmos == 4.3.1 tiktoken == 0.4.0 \ No newline at end of file diff --git a/app/enrichment/app.py b/app/enrichment/app.py new file mode 100644 index 000000000..40e40f421 --- /dev/null +++ b/app/enrichment/app.py @@ -0,0 +1,417 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +import json +import logging +import os +import re +from datetime import datetime +from typing import List +import base64 +import requests +import random +from azure.storage.blob import BlobServiceClient +from azure.storage.queue import QueueClient, TextBase64EncodePolicy +from azure.search.documents import SearchClient +from azure.core.credentials import AzureKeyCredential +from data_model import (EmbeddingResponse, ModelInfo, ModelListResponse, + StatusResponse) +from fastapi import FastAPI, HTTPException +from fastapi.responses import RedirectResponse +from fastapi_utils.tasks import repeat_every +from model_handling import load_models +import openai +from tenacity import retry, wait_random_exponential, stop_after_attempt +from sentence_transformers import SentenceTransformer +from shared_code.utilities_helper import UtilitiesHelper +from shared_code.status_log import State, StatusClassification, StatusLog +from shared_code.tags_helper import TagsHelper + +# === ENV Setup === + +ENV = { + "AZURE_BLOB_STORAGE_KEY": None, + "EMBEDDINGS_QUEUE": None, + "LOG_LEVEL": "DEBUG", # Will be overwritten by LOG_LEVEL in Environment + "DEQUEUE_MESSAGE_BATCH_SIZE": 1, + "AZURE_BLOB_STORAGE_ACCOUNT": None, + "AZURE_BLOB_STORAGE_CONTAINER": None, + "AZURE_BLOB_STORAGE_ENDPOINT": None, + "AZURE_BLOB_STORAGE_UPLOAD_CONTAINER": None, + "COSMOSDB_URL": None, + "COSMOSDB_KEY": None, + "COSMOSDB_LOG_DATABASE_NAME": None, + "COSMOSDB_LOG_CONTAINER_NAME": None, + "COSMOSDB_TAGS_DATABASE_NAME": None, + "COSMOSDB_TAGS_CONTAINER_NAME": None, + "MAX_EMBEDDING_REQUEUE_COUNT": 5, + "EMBEDDING_REQUEUE_BACKOFF": 60, + "AZURE_OPENAI_SERVICE": None, + "AZURE_OPENAI_SERVICE_KEY": None, + "AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME": None, + "AZURE_SEARCH_INDEX": None, + "AZURE_SEARCH_SERVICE_KEY": None, + "AZURE_SEARCH_SERVICE": None, + "BLOB_CONNECTION_STRING": None, + "TARGET_EMBEDDINGS_MODEL": None, + "EMBEDDING_VECTOR_SIZE": None, + "AZURE_SEARCH_SERVICE_ENDPOINT": None, + "AZURE_BLOB_STORAGE_ENDPOINT": None +} + +for key, value in ENV.items(): + new_value = os.getenv(key) + if new_value is not None: + ENV[key] = new_value + elif value is None: + raise ValueError(f"Environment variable {key} not set") + +search_creds = AzureKeyCredential(ENV["AZURE_SEARCH_SERVICE_KEY"]) + +openai.api_base = "https://" + ENV["AZURE_OPENAI_SERVICE"] + ".openai.azure.com/" +openai.api_type = "azure" +openai.api_key = ENV["AZURE_OPENAI_SERVICE_KEY"] +openai.api_version = "2023-06-01-preview" + +class AzOAIEmbedding(object): + """A wrapper for a Azure OpenAI Embedding model""" + def __init__(self, deployment_name) -> None: + self.deployment_name = deployment_name + + @retry(wait=wait_random_exponential(multiplier=1, max=10), stop=stop_after_attempt(5)) + def encode(self, texts): + """Embeds a list of texts using a given model""" + response = openai.Embedding.create( + engine=self.deployment_name, + input=texts + ) + return response + +class STModel(object): + """A wrapper for a sentence-transformers model""" + def __init__(self, deployment_name) -> None: + self.deployment_name = deployment_name + + @retry(wait=wait_random_exponential(multiplier=1, max=10), stop=stop_after_attempt(5)) + def encode(self, texts) -> None: + """Embeds a list of texts using a given model""" + model = SentenceTransformer(self.deployment_name) + response = model.encode(texts) + return response + +# === Get Logger === + +log = logging.getLogger("uvicorn") +log.setLevel(ENV["LOG_LEVEL"]) +log.info("Starting up") + +# === Azure Setup === + +utilities_helper = UtilitiesHelper( + azure_blob_storage_account=ENV["AZURE_BLOB_STORAGE_ACCOUNT"], + azure_blob_storage_endpoint=ENV["AZURE_BLOB_STORAGE_ENDPOINT"], + azure_blob_storage_key=ENV["AZURE_BLOB_STORAGE_KEY"], +) + +statusLog = StatusLog(ENV["COSMOSDB_URL"], ENV["COSMOSDB_KEY"], ENV["COSMOSDB_LOG_DATABASE_NAME"], ENV["COSMOSDB_LOG_CONTAINER_NAME"]) + +tagsHelper = TagsHelper(ENV["COSMOSDB_URL"], ENV["COSMOSDB_KEY"], ENV["COSMOSDB_TAGS_DATABASE_NAME"], ENV["COSMOSDB_TAGS_CONTAINER_NAME"]) +# === API Setup === + +start_time = datetime.now() + +IS_READY = False + +#download models +log.debug("Loading embedding models...") +models, model_info = load_models() + +# Add Azure OpenAI Embedding & additional Model +models["azure-openai_" + ENV["AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME"]] = AzOAIEmbedding( + ENV["AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME"]) + +model_info["azure-openai_" + ENV["AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME"]] = { + "model": "azure-openai_" + ENV["AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME"], + "vector_size": 1536, + # Source: https://platform.openai.com/docs/guides/embeddings/what-are-embeddings +} + +log.debug("Models loaded") +IS_READY = True + +# Create API +app = FastAPI( + title="Text Embedding Service", + description="A simple API and Queue Polling service that uses sentence-transformers to embed text", + version="0.1.0", + openapi_tags=[ + {"name": "models", "description": "Get information about the available models"}, + {"name": "health", "description": "Health check"}, + ], + docs_url="/docs", + redoc_url="/redoc", + openapi_url="/openapi.json", +) + +# === API Routes === +@app.get("/", include_in_schema=False, response_class=RedirectResponse) +def root(): + return RedirectResponse(url="/docs") + +@app.get("/health", response_model=StatusResponse, tags=["health"]) +def health(): + """Returns the health of the API + + Returns: + StatusResponse: The health of the API + """ + + uptime = datetime.now() - start_time + uptime_seconds = uptime.total_seconds() + + output = {"status": None, "uptime_seconds": uptime_seconds, "version": app.version} + + if IS_READY: + output["status"] = "ready" + else: + output["status"] = "loading" + + return output + + +# Models and Embeddings +@app.get("/models", response_model=ModelListResponse, tags=["models"]) +def get_models(): + """Returns a list of available models + + Returns: + ModelListResponse: A list of available models + """ + return {"models": list(model_info.values())} + + +@app.get("/models/{model}", response_model=ModelInfo, tags=["models"]) +def get_model(model: str): + """Returns information about a given model + + Args: + model (str): The name of the model + + Returns: + ModelInfo: Information about the model + """ + + if model not in models: + return {"message": f"Model {model} not found"} + return model_info[model] + + +@app.post("/models/{model}/embed", response_model=EmbeddingResponse, tags=["models"]) +def embed_texts(model: str, texts: List[str]): + """Embeds a list of texts using a given model + Args: + model (str): The name of the model + texts (List[str]): A list of texts + + Returns: + EmbeddingResponse: The embeddings of the texts + """ + + output = {} + if model not in models: + return {"message": f"Model {model} not found"} + + model_obj = models[model] + try: + if model.startswith("azure-openai_"): + embeddings = model_obj.encode(texts) + embeddings = embeddings['data'][0]['embedding'] + else: + embeddings = model_obj.encode(texts) + embeddings = embeddings.tolist()[0] + + output = { + "model": model, + "model_info": model_info[model], + "data": embeddings + } + + except Exception as error: + logging.error(f"Failed to embed: {str(error)}") + raise HTTPException(status_code=500, detail=f"Failed to embed: {str(error)}") from error + + return output + + + +def index_sections(chunks): + """ Pushes a batch of content to the search index + """ + search_client = SearchClient(endpoint=ENV["AZURE_SEARCH_SERVICE_ENDPOINT"], + index_name=ENV["AZURE_SEARCH_INDEX"], + credential=search_creds) + + results = search_client.upload_documents(documents=chunks) + succeeded = sum([1 for r in results if r.succeeded]) + log.debug(f"\tIndexed {len(results)} chunks, {succeeded} succeeded") + +def get_tags_and_upload_to_cosmos(blob_service_client, blob_path): + """ Gets the tags from the blob metadata and uploads them to cosmos db""" + file_name, file_extension, file_directory = utilities_helper.get_filename_and_extension(blob_path) + path = file_directory + file_name + file_extension + blob_client = blob_service_client.get_blob_client( + container=ENV["AZURE_BLOB_STORAGE_UPLOAD_CONTAINER"], + blob=path) + blob_properties = blob_client.get_blob_properties() + tags = blob_properties.metadata.get("tags") + if tags is not None: + if isinstance(tags, str): + tags_list = [tags] + else: + tags_list = tags.split(",") + else: + tags_list = [] + # Write the tags to cosmos db + tagsHelper.upsert_document(blob_path, tags_list) + return tags_list + +@app.on_event("startup") +@repeat_every(seconds=5, logger=log, raise_exceptions=True) +def poll_queue() -> None: + """Polls the queue for messages and embeds them""" + + if IS_READY == False: + log.debug("Skipping poll_queue call, models not yet loaded") + return + + queue_client = QueueClient.from_connection_string( + conn_str=ENV["BLOB_CONNECTION_STRING"], queue_name=ENV["EMBEDDINGS_QUEUE"] + ) + + log.debug("Polling embeddings queue for messages...") + response = queue_client.receive_messages(max_messages=int(ENV["DEQUEUE_MESSAGE_BATCH_SIZE"])) + messages = [x for x in response] + + target_embeddings_model = re.sub(r'[^a-zA-Z0-9_\-.]', '_', ENV["TARGET_EMBEDDINGS_MODEL"]) + + # Remove from queue to prevent duplicate processing from any additional instances + for message in messages: + queue_client.delete_message(message) + + for message in messages: + message_b64 = message.content + message_json = json.loads(base64.b64decode(message_b64)) + blob_path = message_json["blob_name"] + + try: + statusLog.upsert_document(blob_path, f'Embeddings process started with model {target_embeddings_model}', StatusClassification.INFO, State.PROCESSING) + + file_name, file_extension, file_directory = utilities_helper.get_filename_and_extension(blob_path) + chunk_folder_path = file_directory + file_name + file_extension + blob_service_client = BlobServiceClient.from_connection_string(ENV["BLOB_CONNECTION_STRING"]) + container_client = blob_service_client.get_container_client(ENV["AZURE_BLOB_STORAGE_CONTAINER"]) + index_chunks = [] + + # Iterate over the chunks in the container + chunk_list = container_client.list_blobs(name_starts_with=chunk_folder_path) + chunks = list(chunk_list) + i = 0 + for chunk in chunks: + + statusLog.update_document_state( blob_path, f"Indexing {i+1}/{len(chunks)}") + # open the file and extract the content + blob_path_plus_sas = utilities_helper.get_blob_and_sas( + ENV["AZURE_BLOB_STORAGE_CONTAINER"] + '/' + chunk.name) + response = requests.get(blob_path_plus_sas) + response.raise_for_status() + chunk_dict = json.loads(response.text) + + # create the json to be indexed + try: + text = ( + chunk_dict["translated_title"] + " \n " + + chunk_dict["translated_subtitle"] + " \n " + + chunk_dict["translated_section"] + " \n " + + chunk_dict["translated_content"] + ) + except KeyError: + text = ( + chunk_dict["title"] + " \n " + + chunk_dict["subtitle"] + " \n " + + chunk_dict["section"] + " \n " + + chunk_dict["content"] + ) + + # create embedding + embedding = embed_texts(target_embeddings_model, [text]) + embedding_data = embedding['data'] + + tag_list = get_tags_and_upload_to_cosmos(blob_service_client, chunk_dict["file_name"]) + + index_chunk = {} + index_chunk['id'] = statusLog.encode_document_id(chunk.name) + index_chunk['processed_datetime'] = f"{chunk_dict['processed_datetime']}+00:00" + index_chunk['file_name'] = chunk_dict["file_name"] + index_chunk['file_uri'] = chunk_dict["file_uri"] + index_chunk['folder'] = file_directory[:-1] + index_chunk['tags'] = tag_list + index_chunk['chunk_file'] = chunk.name + index_chunk['file_class'] = chunk_dict["file_class"] + index_chunk['title'] = chunk_dict["title"] + index_chunk['pages'] = chunk_dict["pages"] + index_chunk['translated_title'] = chunk_dict["translated_title"] + index_chunk['content'] = text + index_chunk['contentVector'] = embedding_data + index_chunk['entities'] = chunk_dict["entities"] + index_chunk['key_phrases'] = chunk_dict["key_phrases"] + index_chunks.append(index_chunk) + i += 1 + + # push batch of content to index + if i % 200 == 0: + index_sections(index_chunks) + index_chunks = [] + + # push remainder chunks content to index + if len(index_chunks) > 0: + index_sections(index_chunks) + + statusLog.upsert_document(blob_path, + 'Embeddings process complete', + StatusClassification.INFO, State.COMPLETE) + + except Exception as error: + # Dequeue message and update the embeddings queued count to limit the max retries + try: + requeue_count = message_json['embeddings_queued_count'] + except KeyError: + requeue_count = 0 + requeue_count += 1 + + if requeue_count <= int(ENV["MAX_EMBEDDING_REQUEUE_COUNT"]): + message_json['embeddings_queued_count'] = requeue_count + # Requeue with a random backoff within limits + queue_client = QueueClient.from_connection_string( + ENV["BLOB_CONNECTION_STRING"], + ENV["EMBEDDINGS_QUEUE"], + message_encode_policy=TextBase64EncodePolicy()) + message_string = json.dumps(message_json) + max_seconds = int(ENV["EMBEDDING_REQUEUE_BACKOFF"]) * (requeue_count**2) + backoff = random.randint( + int(ENV["EMBEDDING_REQUEUE_BACKOFF"]) * requeue_count, max_seconds) + queue_client.send_message(message_string, visibility_timeout=backoff) + statusLog.upsert_document(blob_path, f'Message requed to embeddings queue, attempt {str(requeue_count)}. Visible in {str(backoff)} seconds. Error: {str(error)}.', + StatusClassification.ERROR, + State.QUEUED) + else: + # max retries has been reached + statusLog.upsert_document( + blob_path, + f"An error occurred, max requeue limit was reached. Error description: {str(error)}", + StatusClassification.ERROR, + State.ERROR, + ) + + statusLog.save_document(blob_path) + + diff --git a/app/enrichment/data_model.py b/app/enrichment/data_model.py new file mode 100644 index 000000000..2f7f6f575 --- /dev/null +++ b/app/enrichment/data_model.py @@ -0,0 +1,39 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +from typing import List + +import pydantic + +# === Data Models === + + +class ModelInfo(pydantic.BaseModel): + model: str + vector_size: int + + +class Embedding(pydantic.BaseModel): + object: str = "embedding" + index: int + embedding: List[float] + + +class EmbeddingResponse(pydantic.BaseModel): + data: List[float] + model: str + model_info: ModelInfo + + +class EmbeddingRequest(pydantic.BaseModel): + sentences: List[str] + + +class ModelListResponse(pydantic.BaseModel): + models: List[ModelInfo] + + +class StatusResponse(pydantic.BaseModel): + status: str + uptime_seconds: float + version: str diff --git a/app/enrichment/model_handling.py b/app/enrichment/model_handling.py new file mode 100644 index 000000000..32a7ab446 --- /dev/null +++ b/app/enrichment/model_handling.py @@ -0,0 +1,42 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +import logging +import os +import re + +from sentence_transformers import SentenceTransformer + + +def load_models(): + model_names = os.getenv( + "TARGET_EMBEDDINGS_MODEL", "all-mpnet-base-v2|paraphrase-multilingual-MiniLM-L12-v2|BAAI/bge-small-en-v1.5" + ) + print("Downloading models: ", model_names) + + models_to_download = model_names.split("|") + + models_path = "models/" + models = {} + model_info = {} + + try: + for model_name in models_to_download: + # Ignore AOAI models as they are downloaded elsewhere + if model_name.startswith("azure-openai"): + continue + model = SentenceTransformer(model_name) + sanitized_model_name = re.sub(r'[^a-zA-Z0-9_\-.]', '_', model_name) + model.save(os.path.join(models_path,sanitized_model_name)) + models[sanitized_model_name] = model + logging.debug(f"Loaded model {model_name}") + + model_info_entry = { + "model": sanitized_model_name, + "vector_size": model.get_sentence_embedding_dimension(), + } + model_info[sanitized_model_name] = model_info_entry + except Exception as error: + logging.error(f"Failed to retrieve models - {str(error)}") + + return models, model_info diff --git a/app/enrichment/pyvenv.cfg b/app/enrichment/pyvenv.cfg new file mode 100644 index 000000000..d9e892325 --- /dev/null +++ b/app/enrichment/pyvenv.cfg @@ -0,0 +1,3 @@ +home = /workspaces/info-asst/.venv/bin +include-system-site-packages = false +version = 3.10.10 \ No newline at end of file diff --git a/app/enrichment/requirements.txt b/app/enrichment/requirements.txt new file mode 100644 index 000000000..71887593a --- /dev/null +++ b/app/enrichment/requirements.txt @@ -0,0 +1,13 @@ +#### Any version change made here should also be made and tested for the webapp backend and function apps in /functions and /app/backend + +sentence-transformers == 2.2.2 +fastapi == 0.103.2 +fastapi-utils == 0.2.1 +uvicorn == 0.23.2 +azure-storage-queue == 12.6.0 +azure-storage-blob==12.16.0 +azure.search.documents==11.4.0b11 +azure-cosmos == 4.3.1 +azure-core == 1.26.4 +tenacity == 8.2.3 +openai == 0.27.0 \ No newline at end of file diff --git a/app/frontend/index.html b/app/frontend/index.html index db9a893e3..218795599 100644 --- a/app/frontend/index.html +++ b/app/frontend/index.html @@ -7,7 +7,7 @@ - Information Assistant Accelerator powered by Azure OpenAI + Information Assistant Accelerator, built with Azure OpenAI
diff --git a/app/frontend/src/api/api.ts b/app/frontend/src/api/api.ts index c48bb17cc..b306755a1 100644 --- a/app/frontend/src/api/api.ts +++ b/app/frontend/src/api/api.ts @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. -import { AskRequest, AskResponse, ChatRequest, BlobClientUrlResponse, AllFilesUploadStatus, GetUploadStatusRequest, GetInfoResponse, ActiveCitation } from "./models"; +import { AskRequest, AskResponse, ChatRequest, BlobClientUrlResponse, AllFilesUploadStatus, GetUploadStatusRequest, GetInfoResponse, ActiveCitation, GetWarningBanner, StatusLogEntry, StatusLogResponse, ApplicationTitle, GetTagsResponse } from "./models"; export async function askApi(options: AskRequest): Promise { const response = await fetch("/ask", { @@ -59,7 +59,9 @@ export async function chatApi(options: ChatRequest): Promise { system_persona: options.overrides?.systemPersona, ai_persona: options.overrides?.aiPersona, response_length: options.overrides?.responseLength, - response_temp: options.overrides?.responseTemp + response_temp: options.overrides?.responseTemp, + selected_folders: options.overrides?.selectedFolders, + selected_tags: options.overrides?.selectedTags } }) }); @@ -112,6 +114,29 @@ export async function getAllUploadStatus(options: GetUploadStatusRequest): Promi return results; } +export async function logStatus(status_log_entry: StatusLogEntry): Promise { + var response = await fetch("/logstatus", { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ + "path": status_log_entry.path, + "status": status_log_entry.status, + "status_classification": status_log_entry.status_classification, + "state": status_log_entry.state + }) + }); + + var parsedResponse: StatusLogResponse = await response.json(); + if (response.status > 299 || !response.ok) { + throw Error(parsedResponse.error || "Unknown error"); + } + + var results: StatusLogResponse = {status: parsedResponse.status}; + return results; +} + export async function getInfoData(): Promise { const response = await fetch("/getInfoData", { method: "GET", @@ -128,6 +153,22 @@ export async function getInfoData(): Promise { return parsedResponse; } +export async function getWarningBanner(): Promise { + const response = await fetch("/getWarningBanner", { + method: "GET", + headers: { + "Content-Type": "application/json" + } + }); + const parsedResponse: GetWarningBanner = await response.json(); + if (response.status > 299 || !response.ok) { + console.log(response); + throw Error(parsedResponse.error || "Unknown error"); + } + console.log(parsedResponse); + return parsedResponse; +} + export async function getCitationObj(citation: string): Promise { const response = await fetch(`/getcitation`, { method: "POST", @@ -144,4 +185,39 @@ export async function getCitationObj(citation: string): Promise throw Error(parsedResponse.error || "Unknown error"); } return parsedResponse; +} + +export async function getApplicationTitle(): Promise { + console.log("fetch Application Titless"); + const response = await fetch("/getApplicationTitle", { + method: "GET", + headers: { + "Content-Type": "application/json" + } + }); + + const parsedResponse: ApplicationTitle = await response.json(); + if (response.status > 299 || !response.ok) { + console.log(response); + throw Error(parsedResponse.error || "Unknown error"); + } + console.log(parsedResponse); + return parsedResponse; +} + +export async function getAllTags(): Promise { + const response = await fetch("/getalltags", { + method: "GET", + headers: { + "Content-Type": "application/json" + } + }); + + const parsedResponse: any = await response.json(); + if (response.status > 299 || !response.ok) { + console.log(response); + throw Error(parsedResponse.error || "Unknown error"); + } + var results: GetTagsResponse = {tags: parsedResponse}; + return results; } \ No newline at end of file diff --git a/app/frontend/src/api/models.ts b/app/frontend/src/api/models.ts index 59e291099..6db38afd7 100644 --- a/app/frontend/src/api/models.ts +++ b/app/frontend/src/api/models.ts @@ -22,6 +22,8 @@ export type AskRequestOverrides = { aiPersona?: string; responseLength?: number; responseTemp?: number; + selectedFolders?: string; + selectedTags?: string; }; export type AskRequest = { @@ -97,6 +99,10 @@ export type GetInfoResponse = { AZURE_SEARCH_SERVICE: string; AZURE_SEARCH_INDEX: string; TARGET_LANGUAGE: string; + USE_AZURE_OPENAI_EMBEDDINGS: boolean; + EMBEDDINGS_DEPLOYMENT: string; + EMBEDDINGS_MODEL_NAME: string; + EMBEDDINGS_MODEL_VERSION: string; error?: string; }; @@ -110,4 +116,52 @@ export type ActiveCitation = { token_count: number; content: string; error?: string; +} + +export type GetWarningBanner = { + WARNING_BANNER_TEXT: string; + error?: string; +}; + +// These keys need to match case with the defined Enum in the +// shared code (functions/shared_code/status_log.py) +export const enum StatusLogClassification { + Debug = "Debug", + Info = "Info", + Error = "Error" +} + +// These keys need to match case with the defined Enum in the +// shared code (functions/shared_code/status_log.py) +export const enum StatusLogState { + Processing = "Processing", + Skipped = "Skipped", + Queued = "Queued", + Complete = "Complete", + Error = "Error", + Throttled = "Throttled", + Uploaded = "Uploaded", + All = "All" +} + +export type StatusLogEntry = { + path: string; + status: string; + status_classification: StatusLogClassification; + state: StatusLogState; +} + +export type StatusLogResponse = { + status: number; + error?: string; +} + +export type ApplicationTitle = { + APPLICATION_TITLE: string; + error?: string; +}; + +export type GetTagsResponse = { + tags: string; + error?: string; } \ No newline at end of file diff --git a/app/frontend/src/components/FileStatus/DocumentsDetailList.tsx b/app/frontend/src/components/FileStatus/DocumentsDetailList.tsx index 00adec784..2bbcbdd5e 100644 --- a/app/frontend/src/components/FileStatus/DocumentsDetailList.tsx +++ b/app/frontend/src/components/FileStatus/DocumentsDetailList.tsx @@ -110,7 +110,7 @@ export const DocumentsDetailList = ({ items, onFilesSorted}: Props) => { { key: 'column4', name: 'Submitted On', - fieldName: 'submittedOn', + fieldName: 'upload_timestamp', minWidth: 70, maxWidth: 90, isResizable: true, @@ -126,12 +126,12 @@ export const DocumentsDetailList = ({ items, onFilesSorted}: Props) => { { key: 'column5', name: 'Last Updated', - fieldName: 'lastUpdated', + fieldName: 'modified_timestamp', minWidth: 70, maxWidth: 90, isResizable: true, isSorted: true, - isSortedDescending: true, + isSortedDescending: false, sortAscendingAriaLabel: 'Sorted Oldest to Newest', sortDescendingAriaLabel: 'Sorted Newest to Oldest', isCollapsible: true, @@ -146,6 +146,7 @@ export const DocumentsDetailList = ({ items, onFilesSorted}: Props) => { return (
+ {"(" + items.length as string + ") records."} void; + preSelectedKeys?: string[]; +} + +export const FolderPicker = ({allowFolderCreation, onSelectedKeyChange, preSelectedKeys}: Props) => { + + const buttonId = useId('targetButton'); + const tooltipId = useId('folderpicker-tooltip'); + const textFieldId = useId('textField'); + + const [teachingBubbleVisible, { toggle: toggleTeachingBubbleVisible }] = useBoolean(false); + const [selectedKeys, setSelectedKeys] = useState([]); + const [options, setOptions] = useState([]); + const selectableOptions = options.filter( + option => + (option.itemType === SelectableOptionMenuItemType.Normal || option.itemType === undefined) && !option.disabled, + ); + const comboBoxStyles: Partial = { root: { maxWidth: 300 } }; + const hostStyles: Partial = { root: { display: 'inline-block' } }; + const addFolderIcon: IIconProps = { iconName: 'Add' }; + + allowNewFolders = allowFolderCreation as boolean; + + const teachingBubbleStyles: Partial = { + content: { + background: "#d3d3d3", + borderColor: "#696969" + }, + headline: { + color: "#696969" + }, + } + + const teachingBubblePrimaryButtonClick = () => { + const textField = document.getElementById(textFieldId) as HTMLInputElement; + if (!textField.defaultValue || textField.defaultValue.trim() === '') { + alert('Please enter a folder name.'); + } else if (textField.defaultValue.trim().includes(' ')) { + alert('Folder name cannot contain spaces.'); + } else { + // add the folder to the dropdown list and select it + // This will be passed to the file-picker component to determine the folder to upload to + const trimVal = textField.defaultValue.trim() + const currentOptions = options; + currentOptions.push({ key: trimVal, text: trimVal }); + setOptions(currentOptions); + setSelectedKeys([trimVal]); + onSelectedKeyChange([trimVal]); + toggleTeachingBubbleVisible(); + } + }; + + const examplePrimaryButtonProps: IButtonProps = { + children: 'Create folder', + onClick: teachingBubblePrimaryButtonClick, + }; + + async function fetchBlobFolderData() { + try { + const blobClientUrl = await getBlobClientUrl(); + const blobServiceClient = new BlobServiceClient(blobClientUrl); + var containerClient = blobServiceClient.getContainerClient("upload"); + const delimiter = "/"; + const prefix = ""; + var newOptions: IComboBoxOption[] = allowNewFolders ? [] : [ + { key: 'selectAll', text: 'Select All', itemType: SelectableOptionMenuItemType.SelectAll }, + { key: 'FolderHeader', text: 'Folders', itemType: SelectableOptionMenuItemType.Header }]; + for await (const item of containerClient.listBlobsByHierarchy(delimiter, { + prefix, + })) { + // Check if the item is a folder + if (item.kind === "prefix") { + // Get the folder name and add to the dropdown list + var folderName = item.name.slice(0,-1); + + newOptions.push({key: folderName, text: folderName}); + setOptions(newOptions); + } + } + if (!allowNewFolders) { + var filteredOptions = newOptions.filter( + option => + (option.itemType === SelectableOptionMenuItemType.Normal || option.itemType === undefined) && !option.disabled, + ); + if (preSelectedKeys !== undefined && preSelectedKeys.length > 0) { + setSelectedKeys(preSelectedKeys); + onSelectedKeyChange(preSelectedKeys); + } + else { + setSelectedKeys(['selectAll', ...filteredOptions.map(o => o.key as string)]); + onSelectedKeyChange(['selectAll', ...filteredOptions.map(o => o.key as string)]); + } + } + } catch (error) { + // Handle the error here + console.log(error); + } + } + + useEffect(() => { + fetchBlobFolderData(); + }, []); + + function getStyles(props: ITextFieldStyleProps): Partial { + const { required } = props; + return { + fieldGroup: [ + { width: 300 }, + required && { + borderColor: "#F8f8ff", + }, + ], + subComponentStyles: { + label: getLabelStyles, + }, + }; + } + + function getLabelStyles(props: ILabelStyleProps): ILabelStyles { + const { required } = props; + return { + root: required && { + color: "#696969", + }, + }; + } + + const onChange = ( + event: React.FormEvent, + option?: IComboBoxOption, + index?: number, + value?: string, + ): void => { + const selected = option?.selected; + const currentSelectedOptionKeys = selectedKeys.filter(key => key !== 'selectAll'); + const selectAllState = currentSelectedOptionKeys.length === selectableOptions.length; + if (!allowNewFolders) { + if (option) { + if (option?.itemType === SelectableOptionMenuItemType.SelectAll) { + if (selectAllState) { + setSelectedKeys([]) + onSelectedKeyChange([]); + } + else { + setSelectedKeys(['selectAll', ...selectableOptions.map(o => o.key as string)]); + onSelectedKeyChange(['selectAll', ...selectableOptions.map(o => o.key as string)]); + } + } else { + const updatedKeys = selected + ? [...currentSelectedOptionKeys, option!.key as string] + : currentSelectedOptionKeys.filter(k => k !== option.key); + if (updatedKeys.length === selectableOptions.length) { + updatedKeys.push('selectAll'); + } + setSelectedKeys(updatedKeys); + onSelectedKeyChange(updatedKeys); + } + } + } + else { + setSelectedKeys([option!.key as string]); + onSelectedKeyChange([option!.key as string]); + } + }; + + return ( +
+
+ + + + +
+ {allowNewFolders ? ( +
+ + Create new folder + + {teachingBubbleVisible && ( + + + + )} +
) : ""} +
+ ); +}; \ No newline at end of file diff --git a/app/frontend/src/components/FolderPicker/index.tsx b/app/frontend/src/components/FolderPicker/index.tsx new file mode 100644 index 000000000..8d950ca32 --- /dev/null +++ b/app/frontend/src/components/FolderPicker/index.tsx @@ -0,0 +1,4 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +export * from "./FolderPicker"; diff --git a/app/frontend/src/components/InfoContent/InfoContent.tsx b/app/frontend/src/components/InfoContent/InfoContent.tsx index 144ee4388..eb0e4c998 100644 --- a/app/frontend/src/components/InfoContent/InfoContent.tsx +++ b/app/frontend/src/components/InfoContent/InfoContent.tsx @@ -33,9 +33,21 @@ export const InfoContent = ({ className }: Props) => {
Azure OpenAI {infoData?.AZURE_OPENAI_SERVICE} - {infoData?.AZURE_OPENAI_CHATGPT_DEPLOYMENT} - {infoData?.AZURE_OPENAI_MODEL_NAME} - {infoData?.AZURE_OPENAI_MODEL_VERSION} + {infoData?.AZURE_OPENAI_CHATGPT_DEPLOYMENT} + {infoData?.AZURE_OPENAI_MODEL_NAME} + {infoData?.AZURE_OPENAI_MODEL_VERSION} + {infoData?.USE_AZURE_OPENAI_EMBEDDINGS ? ( +
+ {infoData?.EMBEDDINGS_DEPLOYMENT} + {infoData?.EMBEDDINGS_MODEL_NAME} + {infoData?.EMBEDDINGS_MODEL_VERSION} +
+ ) : ( +
+ Open Source Embeddings + {infoData?.EMBEDDINGS_DEPLOYMENT} +
+ )} Azure Cognitive Search {infoData?.AZURE_SEARCH_SERVICE} {infoData?.AZURE_SEARCH_INDEX} diff --git a/app/frontend/src/components/ResponseTempButtonGroup/ResponseTempButtonGroup.tsx b/app/frontend/src/components/ResponseTempButtonGroup/ResponseTempButtonGroup.tsx index 0b37b1ff4..c7cb8eb08 100644 --- a/app/frontend/src/components/ResponseTempButtonGroup/ResponseTempButtonGroup.tsx +++ b/app/frontend/src/components/ResponseTempButtonGroup/ResponseTempButtonGroup.tsx @@ -17,7 +17,7 @@ export const ResponseTempButtonGroup = ({ className, onClick, defaultValue }: Pr
- + diff --git a/app/frontend/src/components/TagPicker/TagPicker.module.css b/app/frontend/src/components/TagPicker/TagPicker.module.css new file mode 100644 index 000000000..a34483033 --- /dev/null +++ b/app/frontend/src/components/TagPicker/TagPicker.module.css @@ -0,0 +1,29 @@ + +.rootClass { + max-width: 800px; +} + +.rootClassFilter { + max-width: 250px; +} + +.tagPicker { + background-color: white; +} + +.tagArea { + display: flex; + flex-direction: column; + justify-content: right; + width: 600px; + padding-bottom: 20px; + } + +.tagSelection { + display: flex; + flex-direction: row; + padding-bottom: 5px; + justify-content: left; + text-align: left; + height: 90px; + } \ No newline at end of file diff --git a/app/frontend/src/components/TagPicker/TagPicker.tsx b/app/frontend/src/components/TagPicker/TagPicker.tsx new file mode 100644 index 000000000..75fbf43b3 --- /dev/null +++ b/app/frontend/src/components/TagPicker/TagPicker.tsx @@ -0,0 +1,159 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +import { useState, useEffect } from 'react'; +import { TagPicker, ITag, IBasePickerSuggestionsProps} from '@fluentui/react/lib/Pickers'; +import { TooltipHost, + ITooltipHostStyles} from "@fluentui/react"; +import { Info16Regular } from '@fluentui/react-icons'; +import { mergeStyles } from '@fluentui/react/lib/Styling'; +import { useId } from '@fluentui/react-hooks'; +import { getAllTags } from "../../api"; + +import styles from "./TagPicker.module.css"; + +var allowAddNew = false; + +interface Props { + allowNewTags?: boolean; + onSelectedTagsChange: (selectedTags: ITag[]) => void; + preSelectedTags?: ITag[]; +} + +export const TagPickerInline = ({allowNewTags, onSelectedTagsChange, preSelectedTags}: Props) => { + + const pickerId = useId('tag-inline-picker'); + const tooltipId = useId('tagpicker-tooltip'); + const hostStyles: Partial = { root: { display: 'inline-block' } }; + const newItem = mergeStyles({ color: '#f00', background: '#ddf', padding: '10px' }); + const existingItem = mergeStyles({ color: '#222', padding: '10px' }); + + const [selectedTags, setSelectedTags] = useState([]); + const [tags, setTags] = useState([]); + const getTextFromItem = (item: ITag) => item.name; + + allowAddNew = allowNewTags as boolean; + + const listContainsTagList = (tag: ITag, tagList?: ITag[]): boolean => { + if (!tagList || !tagList.length || tagList.length === 0) { + return false; + } + return tagList.some((compareTag: ITag) => compareTag.key === tag.key); + }; + + const filterSuggestedTags = (filterText: string, tagList: ITag[] | undefined): ITag[] => { + var existingMatches = filterText + ? tags.filter( + tag => tag.name.toLowerCase().indexOf(filterText.toLowerCase()) === 0 && !listContainsTagList(tag, tagList), + ) + : []; + + if (allowAddNew) { + return existingMatches.some(a=> a.key === filterText) + ? existingMatches : + [{ key: filterText, name: filterText, isNewItem: true } as ITag].concat(existingMatches); + } + else { + return existingMatches; + } + }; + + const onItemSelected = (item: any | undefined): ITag | PromiseLike | null => { + const selected = selectedTags; + if(item && item.isNewItem) { + item.isNewItem = false; + var newTags = tags; + newTags.push(item); + setTags(newTags); + } + return item as ITag; + }; + + const onRenderSuggestionsItem = (props: any, itemProps: any): JSX.Element => { + if (allowAddNew) { + return
+ {props.name} +
; + } + else { + return
+ {props.name} +
; + } + + }; + + const pickerSuggestionsProps: IBasePickerSuggestionsProps = { + suggestionsHeaderText: 'Existing Tags', + noResultsFoundText: allowAddNew ? 'Press Enter to add as a new tag' : 'No matching tag found', + }; + + async function fetchTagsfromCosmos() { + try { + const response = await getAllTags(); + var newTags: ITag[] = []; + response.tags.split(",").forEach((tag: string) => { + const trimmedTag = tag.trim(); + if (trimmedTag !== "" && !newTags.some(t => t.key === trimmedTag)) { + const newTag: any = { key: trimmedTag, name: trimmedTag, isNewItem: false }; + newTags.push(newTag); + } + }); + setTags(newTags); + if (preSelectedTags !== undefined && preSelectedTags.length > 0) { + setSelectedTags(preSelectedTags); + onSelectedTagsChange(preSelectedTags); + } + else { + setSelectedTags([]); + onSelectedTagsChange([]); + } + } + catch (error) { + console.log(error); + } + } + + const onChange = (items?: ITag[] | undefined) => { + if (items) { + setSelectedTags(items); + onSelectedTagsChange(items); + } + }; + + useEffect(() => { + fetchTagsfromCosmos(); + }, []); + + return ( +
+
+
+ + +
+ + + +
+
+ ); +}; diff --git a/app/frontend/src/components/TagPicker/index.tsx b/app/frontend/src/components/TagPicker/index.tsx new file mode 100644 index 000000000..c12156573 --- /dev/null +++ b/app/frontend/src/components/TagPicker/index.tsx @@ -0,0 +1,4 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +export * from "./TagPicker"; diff --git a/app/frontend/src/components/Title/Title.tsx b/app/frontend/src/components/Title/Title.tsx new file mode 100644 index 000000000..06728876b --- /dev/null +++ b/app/frontend/src/components/Title/Title.tsx @@ -0,0 +1,32 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import React, { useEffect, useState } from "react"; +import { ApplicationTitle, getApplicationTitle } from "../../api"; + +export const Title = () => { + const [Title, setTitle] = useState(null); + + async function fetchApplicationTitle() { + console.log("fetch Application Title"); + try { + + + const v = await getApplicationTitle(); + if (!v.APPLICATION_TITLE) { + return null; + } + + setTitle(v); + } catch (error) { + // Handle the error here + console.log(error); + } + } + + useEffect(() => { + fetchApplicationTitle(); + }, []); + + return (<>{Title?.APPLICATION_TITLE}); +}; \ No newline at end of file diff --git a/app/frontend/src/components/WarningBanner/WarningBanner.module.css b/app/frontend/src/components/WarningBanner/WarningBanner.module.css new file mode 100644 index 000000000..54b50bfe9 --- /dev/null +++ b/app/frontend/src/components/WarningBanner/WarningBanner.module.css @@ -0,0 +1,10 @@ +/* Copyright (c) Microsoft Corporation. + Licensed under the MIT license. */ + +.warningBanner { + background-color: #4CAF50; /* Green color */ + color: #ffffff; /* White color */ + padding: 0px; /* Adjust the padding as needed */ + text-align: center; /* Center the text */ + font-weight: bold; +} diff --git a/app/frontend/src/components/WarningBanner/WarningBanner.tsx b/app/frontend/src/components/WarningBanner/WarningBanner.tsx new file mode 100644 index 000000000..1ca57a86c --- /dev/null +++ b/app/frontend/src/components/WarningBanner/WarningBanner.tsx @@ -0,0 +1,43 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import React, { useEffect, useState } from "react"; +import { Text } from "@fluentui/react"; +import { Label } from '@fluentui/react/lib/Label'; +import { Separator } from '@fluentui/react/lib/Separator'; +import { getWarningBanner, GetWarningBanner } from "../../api"; + +import styles from "./WarningBanner.module.css"; + +interface Props { + className?: string; +} + +export const WarningBanner = ({ className }: Props) => { + const [infoData, setWarningBanner] = useState(null); + + async function fetchWarningBanner() { + console.log("Warning Banner 1"); + try { + const fetchedWarningBannerInfo = await getWarningBanner(); + if (!fetchedWarningBannerInfo.WARNING_BANNER_TEXT){ + return null; + } + + setWarningBanner(fetchedWarningBannerInfo); + } catch (error) { + // Handle the error here + console.log(error); + } + } + + useEffect(() => { + fetchWarningBanner(); + }, []); + + return ( +
+ {infoData?.WARNING_BANNER_TEXT} +
+ ); +}; \ No newline at end of file diff --git a/app/frontend/src/components/WarningBanner/index.ts b/app/frontend/src/components/WarningBanner/index.ts new file mode 100644 index 000000000..b3913b7f5 --- /dev/null +++ b/app/frontend/src/components/WarningBanner/index.ts @@ -0,0 +1,4 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +export * from "./WarningBanner"; \ No newline at end of file diff --git a/app/frontend/src/components/filepicker/drop-zone.module.css b/app/frontend/src/components/filepicker/drop-zone.module.css index 1eb504dd0..de02baba6 100644 --- a/app/frontend/src/components/filepicker/drop-zone.module.css +++ b/app/frontend/src/components/filepicker/drop-zone.module.css @@ -11,7 +11,7 @@ .banner_text { font-size: 1.5rem; - color: #ccc; + color: #929090; display: block; margin: 0.5rem 0; } @@ -19,7 +19,7 @@ .banner { background-color: #fafafa; width: 100%; - border: 4px dashed #ccc; + border: 4px dashed #969595; height: 200px; display: flex; flex-direction: column; diff --git a/app/frontend/src/components/filepicker/file-picker.module.css b/app/frontend/src/components/filepicker/file-picker.module.css index aced74dec..198716e91 100644 --- a/app/frontend/src/components/filepicker/file-picker.module.css +++ b/app/frontend/src/components/filepicker/file-picker.module.css @@ -8,7 +8,8 @@ justify-content: center; width: 600px; padding: 1rem; - box-shadow: 0 0 10px rgba(0, 0, 0, 0.1); + background-color: #d3d3d3; + box-shadow: 0 0 15px rgba(0, 0, 0, 0.1); border-radius: 4px; } diff --git a/app/frontend/src/components/filepicker/file-picker.tsx b/app/frontend/src/components/filepicker/file-picker.tsx index 17b9b8242..3dcbeb9d0 100644 --- a/app/frontend/src/components/filepicker/file-picker.tsx +++ b/app/frontend/src/components/filepicker/file-picker.tsx @@ -8,13 +8,19 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { DropZone } from "./drop-zone" import styles from "./file-picker.module.css"; import { FilesList } from "./files-list"; -import { getBlobClientUrl } from "../../api" +import { getBlobClientUrl, logStatus, StatusLogClassification, StatusLogEntry, StatusLogState } from "../../api" +interface Props { + folderPath: string; + tags: string[]; +} -const FilePicker = () => { +const FilePicker = ({folderPath, tags}: Props) => { const [files, setFiles] = useState([]); const [progress, setProgress] = useState(0); const [uploadStarted, setUploadStarted] = useState(false); + const folderName = folderPath; + const tagList = tags; // handler called when files are selected via the Dropzone component const handleOnChange = useCallback((files: any) => { @@ -51,15 +57,28 @@ const FilePicker = () => { const containerClient = blobServiceClient.getContainerClient("upload"); var counter = 1; - files.forEach((indexedFile: any) => { + files.forEach(async (indexedFile: any) => { // add each file into Azure Blob Storage - const file = indexedFile.file as File; - const blobClient = containerClient.getBlockBlobClient(file.name); + var file = indexedFile.file as File; + var filePath = (folderName == "") ? file.name : folderName + "/" + file.name; + const blobClient = containerClient.getBlockBlobClient(filePath); // set mimetype as determined from browser with file upload control - const options = { blobHTTPHeaders: { blobContentType: file.type } }; + const options = { + blobHTTPHeaders: { blobContentType: file.type }, + metadata: { tags: tagList.join(",") } + }; // upload file blobClient.uploadData(file, options); + //write status to log + var logEntry: StatusLogEntry = { + path: "upload/"+filePath, + status: "File uploaded from browser to Azure Blob Storage", + status_classification: StatusLogClassification.Info, + state: StatusLogState.Uploaded + } + await logStatus(logEntry); + setProgress((counter/files.length) * 100); counter++; }); diff --git a/app/frontend/src/pages/chat/Chat.module.css b/app/frontend/src/pages/chat/Chat.module.css index b4c547c2e..560677987 100644 --- a/app/frontend/src/pages/chat/Chat.module.css +++ b/app/frontend/src/pages/chat/Chat.module.css @@ -39,14 +39,31 @@ } .chatEmptyObjectives { - padding-left: 120px; - padding-right: 120px; + padding-left: 160px; + padding-right: 160px; text-align: center; font-size: 14px; } .chatEmptyObjectivesList { + text-align: center; font-size: 14px; + display: flex; + flex-direction: row; + padding-bottom: 50px; + padding-top: 30px; +} + +.chatEmptyObjectivesListItem { + display: flex; + flex-direction: column; + align-items: center; + width: 200px; +} + +.chatEmptyObjectivesListItemText { + padding-right: 10px; + padding-left: 10px; } .chatEmptyStateSubtitle { diff --git a/app/frontend/src/pages/chat/Chat.tsx b/app/frontend/src/pages/chat/Chat.tsx index dc7287f13..929b14001 100644 --- a/app/frontend/src/pages/chat/Chat.tsx +++ b/app/frontend/src/pages/chat/Chat.tsx @@ -2,8 +2,9 @@ // Licensed under the MIT license. import { useRef, useState, useEffect } from "react"; -import { Checkbox, Panel, DefaultButton, TextField, SpinButton} from "@fluentui/react"; -import { SparkleFilled } from "@fluentui/react-icons"; +import { Checkbox, Panel, DefaultButton, TextField, SpinButton, Separator} from "@fluentui/react"; +import { SparkleFilled, ClockFilled, TargetArrowFilled, OptionsFilled, SearchInfoFilled, PersonStarFilled, TextBulletListSquareSparkleFilled } from "@fluentui/react-icons"; +import { ITag } from '@fluentui/react/lib/Pickers'; import styles from "./Chat.module.css"; import rlbgstyles from "../../components/ResponseLengthButtonGroup/ResponseLengthButtonGroup.module.css"; @@ -21,6 +22,8 @@ import { ClearChatButton } from "../../components/ClearChatButton"; import { ResponseLengthButtonGroup } from "../../components/ResponseLengthButtonGroup"; import { ResponseTempButtonGroup } from "../../components/ResponseTempButtonGroup"; import { InfoContent } from "../../components/InfoContent/InfoContent"; +import { FolderPicker } from "../../components/FolderPicker"; +import { TagPickerInline } from "../../components/TagPicker"; const Chat = () => { const [isConfigPanelOpen, setIsConfigPanelOpen] = useState(false); @@ -34,18 +37,16 @@ const Chat = () => { const [userPersona, setUserPersona] = useState("analyst"); const [systemPersona, setSystemPersona] = useState("an Assistant"); const [aiPersona, setAiPersona] = useState(""); - // Setting responseLength to 1024 by default, this will effect the default display of the ResponseLengthButtonGroup below. + // Setting responseLength to 2048 by default, this will effect the default display of the ResponseLengthButtonGroup below. // It must match a valid value of one of the buttons in the ResponseLengthButtonGroup.tsx file. // If you update the default value here, you must also update the default value in the onResponseLengthChange method. - const [responseLength, setResponseLength] = useState(1024); + const [responseLength, setResponseLength] = useState(2048); // Setting responseTemp to 0.6 by default, this will effect the default display of the ResponseTempButtonGroup below. // It must match a valid value of one of the buttons in the ResponseTempButtonGroup.tsx file. // If you update the default value here, you must also update the default value in the onResponseTempChange method. const [responseTemp, setResponseTemp] = useState(0.6); const lastQuestionRef = useRef(""); - const testQuestion = "This is a test question."; - const thisquestion = ""; const chatMessageStreamEnd = useRef(null); const [isLoading, setIsLoading] = useState(false); @@ -55,6 +56,8 @@ const Chat = () => { const [activeCitationSourceFile, setActiveCitationSourceFile] = useState(); const [activeCitationSourceFilePageNumber, setActiveCitationSourceFilePageNumber] = useState(); const [activeAnalysisPanelTab, setActiveAnalysisPanelTab] = useState(undefined); + const [selectedFolders, setSelectedFolders] = useState([]); + const [selectedTags, setSelectedTags] = useState([]); const [selectedAnswer, setSelectedAnswer] = useState(0); const [answers, setAnswers] = useState<[user: string, response: AskResponse][]>([]); @@ -83,7 +86,9 @@ const Chat = () => { systemPersona: systemPersona, aiPersona: aiPersona, responseLength: responseLength, - responseTemp: responseTemp + responseTemp: responseTemp, + selectedFolders: selectedFolders.includes("selectAll") ? "All" : selectedFolders.length == 0 ? "All" : selectedFolders.join(","), + selectedTags: selectedTags.map(tag => tag.name).join(",") } }; const result = await chatApi(request); @@ -139,14 +144,14 @@ const Chat = () => { } } // the or value here needs to match the default value assigned to responseLength above. - setResponseLength(_ev.target.value as number || 1024) + setResponseLength(_ev.target.value as number || 2048) }; const onResponseTempChange = (_ev: any) => { for (let node of _ev.target.parentNode.childNodes) { if (node.value == _ev.target.value) { switch (node.value) { - case "1.3": + case "1.0": node.className = `${rtbgstyles.buttonleftactive}`; break; case "0.6": @@ -162,7 +167,7 @@ const Chat = () => { } else { switch (node.value) { - case "1.3": + case "1.0": node.className = `${rtbgstyles.buttonleft}`; break; case "0.6": @@ -226,6 +231,14 @@ const Chat = () => { setSelectedAnswer(index); }; + const onSelectedKeyChanged = (selectedFolders: string[]) => { + setSelectedFolders(selectedFolders) + }; + + const onSelectedTagsChange = (selectedTags: ITag[]) => { + setSelectedTags(selectedTags) + } + return (
@@ -240,17 +253,35 @@ const Chat = () => {