diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 609632959..0fc7a8ec7 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -1,4 +1,4 @@ -FROM mcr.microsoft.com/vscode/devcontainers/python:0-3.10-bullseye +FROM mcr.microsoft.com/vscode/devcontainers/python:3.10-bullseye # Avoid warnings by switching to noninteractive ENV DEBIAN_FRONTEND=noninteractive @@ -48,11 +48,26 @@ RUN /tmp/docker-client.sh $USERNAME RUN git clone https://github.com/magicmonty/bash-git-prompt.git ~/.bash-git-prompt --depth=1 \ && echo "if [ -f \"$HOME/.bash-git-prompt/gitprompt.sh\" ]; then GIT_PROMPT_ONLY_IN_REPO=1 && source $HOME/.bash-git-prompt/gitprompt.sh; fi" >> "/home/$USERNAME/.bashrc" +# terraform + tflint +ARG TERRAFORM_VERSION=1.3.7 +ARG TFLINT_VERSION=0.44.1 +RUN mkdir -p /tmp/docker-downloads \ + && curl -sSL -o /tmp/docker-downloads/terraform.zip https://releases.hashicorp.com/terraform/${TERRAFORM_VERSION}/terraform_${TERRAFORM_VERSION}_linux_amd64.zip \ + && unzip /tmp/docker-downloads/terraform.zip \ + && mv terraform /usr/local/bin \ + && rm /tmp/docker-downloads/terraform.zip \ + && echo "alias tf=terraform" >> "/home/$USERNAME/.bashrc" + +RUN curl -sSL -o /tmp/docker-downloads/tflint.zip https://github.com/wata727/tflint/releases/download/v${TFLINT_VERSION}/tflint_linux_amd64.zip \ + && unzip /tmp/docker-downloads/tflint.zip \ + && mv tflint /usr/local/bin \ + && rm /tmp/docker-downloads/tflint.zip + # azure-cli COPY ./scripts/azure-cli.sh /tmp/ RUN /tmp/azure-cli.sh -# Install dotnet 6 & Azure Functions Core Tools +# Install Azure Functions Core Tools RUN echo "deb [arch=amd64] https://packages.microsoft.com/repos/microsoft-debian-$(lsb_release -cs)-prod $(lsb_release -cs) main" > /etc/apt/sources.list.d/microsoft.list \ && echo "deb [arch=amd64] https://packages.microsoft.com/debian/$(lsb_release -rs | cut -d'.' -f 1)/prod $(lsb_release -cs) main" > /etc/apt/sources.list.d/dotnetdev.list \ && wget https://packages.microsoft.com/config/debian/10/packages-microsoft-prod.deb \ @@ -78,4 +93,10 @@ RUN if [ -z "$TZ" ]; then TZ="Europe/London"; fi && sudo ln -snf /usr/share/zone # Install gettext-base so that we have envsubst RUN sudo apt-get update \ - && sudo apt-get -y install gettext-base \ No newline at end of file + && sudo apt-get -y install gettext-base + +# Install python packages for migration +RUN pip install azure-cosmos +RUN pip install pyfiglet +RUN pip install azure-identity +RUN pip install azure-keyvault-secrets \ No newline at end of file diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 6d88ead57..8276a6457 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -23,58 +23,57 @@ "type=bind,source=/var/run/docker.sock,target=/var/run/docker.sock" ], - // Set *default* container specific settings.json values on container create. - "settings": { - "python.pythonPath": "/opt/conda/envs/development/bin/python", - "python.languageServer": "Pylance", - "python.linting.enabled": true, - "python.linting.pylintEnabled": true, - "python.formatting.autopep8Path": "/usr/local/py-utils/bin/autopep8", - "python.formatting.blackPath": "/usr/local/py-utils/bin/black", - "python.formatting.yapfPath": "/usr/local/py-utils/bin/yapf", - "python.linting.banditPath": "/usr/local/py-utils/bin/bandit", - "python.linting.flake8Path": "/usr/local/py-utils/bin/flake8", - "python.linting.mypyPath": "/usr/local/py-utils/bin/mypy", - "python.linting.pycodestylePath": "/usr/local/py-utils/bin/pycodestyle", - "python.linting.pydocstylePath": "/usr/local/py-utils/bin/pydocstyle", - "python.linting.pylintPath": "/usr/local/py-utils/bin/pylint", - "files.watcherExclude": { - "**/.git/objects/**": true, - "**/.git/subtree-cache/**": true, - "**/node_modules/*/**": true, - "**/.python_packages/*/**": true - }, - "files.associations": { - "*.workbook": "[jsonc]" + // Set *default* container specific settings values on container create. + "customizations": { + "vscode": { + "settings": { + "python.pythonPath": "/opt/conda/envs/development/bin/python", + "python.languageServer": "Pylance", + "python.formatting.autopep8Path": "/usr/local/py-utils/bin/autopep8", + "python.formatting.blackPath": "/usr/local/py-utils/bin/black", + "python.formatting.yapfPath": "/usr/local/py-utils/bin/yapf", + "files.watcherExclude": { + "**/.git/objects/**": true, + "**/.git/subtree-cache/**": true, + "**/node_modules/*/**": true, + "**/.python_packages/*/**": true + }, + "files.associations": { + "*.workbook": "[jsonc]" + } + }, + // Add extensions you want installed when the container is created into this array + "extensions": [ + "ms-python.python", + "ms-python.vscode-pylance", + "IronGeek.vscode-env", + "ms-azuretools.vscode-docker", + "ms-toolsai.jupyter", + "humao.rest-client", + "ms-dotnettools.csharp", + "ms-vsliveshare.vsliveshare-pack", + "ms-vscode.powershell", + "DavidAnson.vscode-markdownlint", + "redhat.vscode-yaml", + "ms-azure-devops.azure-pipelines", + "k--kato.docomment", + "hediet.vscode-drawio", + "msazurermtools.azurerm-vscode-tools", + "ms-azuretools.vscode-azurestorage", + "GitHub.copilot", + "GitHub.copilot-chat", + "BelkacemBerras.spellcheck", + "ms-azuretools.vscode-azureresourcegroups", + "ms-azuretools.vscode-azurefunctions", + "ms-python.pylint", + "ms-python.mypy", + "HashiCorp.terraform", + "mhutchie.git-graph", + "esbenp.prettier-vscode", + "mutantdino.resourcemonitor" + ] } }, - // Add the IDs of extensions you want installed when the container is created. - "extensions": [ - "ms-python.python", - "ms-python.vscode-pylance", - "irongeek.vscode-env", - "ms-azuretools.vscode-docker", - "ms-toolsai.jupyter", - "humao.rest-client", - "ms-dotnettools.csharp", - "ms-vsliveshare.vsliveshare-pack", - "ms-vscode.powershell", - "DavidAnson.vscode-markdownlint", - "redhat.vscode-yaml", - "ms-azure-devops.azure-pipelines", - "k--kato.docomment", - "hediet.vscode-drawio", - "msazurermtools.azurerm-vscode-tools", - "ms-azuretools.vscode-azurestorage", - "ms-azuretools.vscode-bicep", - "GitHub.copilot", - "BelkacemBerras.spellcheck", - "ms-azuretools.vscode-azureresourcegroups", - "ms-azuretools.vscode-azurefunctions", - "ms-python.python", - "ms-python.pylint" - ], - "remoteUser": "vscode" -} \ No newline at end of file +} diff --git a/.gitignore b/.gitignore index b62a0055a..48927fe36 100644 --- a/.gitignore +++ b/.gitignore @@ -369,6 +369,7 @@ main.parameters.json infrastructure.env infrastructure.debug.env infra_output.json +inf_output.json random.txt .state @@ -385,4 +386,13 @@ app/backend/shared_code packages-microsoft* # docker container build artifacts -app/enrichment/shared_code \ No newline at end of file +app/enrichment/shared_code + +#terraform +.terraform +infra/.terraform* +terraform.tfstate +terraform.tfstate.d +.tfplan.txt +infra/infoasst* +infra/sp_config/config.json \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json index 090a3ccf1..be3394284 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -4,24 +4,18 @@ // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 "version": "0.2.0", "configurations": [ - { "name": "Python: WebApp backend", "type": "python", "request": "launch", - "module": "flask", - "cwd": "${workspaceFolder}/app/backend", - "env": { - "FLASK_APP": "app.py", - "FLASK_ENV": "development", - "FLASK_DEBUG": "0" - }, + "module": "uvicorn", "args": [ - "run", - "--no-debugger", - "--no-reload", - "-p 5000" + "app:app", + "--reload", + "--port", + "5000" ], + "cwd": "${workspaceFolder}/app/backend", "console": "integratedTerminal", "justMyCode": true, "envFile": "${workspaceFolder}/scripts/environments/infrastructure.debug.env", diff --git a/Makefile b/Makefile index 6a44733c8..031877276 100644 --- a/Makefile +++ b/Makefile @@ -25,7 +25,7 @@ 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 < inf_output.json > ./scripts/environments/infrastructure.env deploy-webapp: extract-env ## Deploys the web app code to Azure App Service @./scripts/deploy-webapp.sh @@ -40,10 +40,10 @@ deploy-search-indexes: extract-env ## Deploy search indexes @./scripts/deploy-search-indexes.sh extract-env-debug-webapp: ## Extract infrastructure.debug.env file from BICEP output - @./scripts/json-to-env.webapp.debug.sh < infra_output.json > ./scripts/environments/infrastructure.debug.env + @./scripts/json-to-env.webapp.debug.sh < inf_output.json > ./scripts/environments/infrastructure.debug.env extract-env-debug-functions: ## Extract local.settings.json to debug functions from BICEP output - @./scripts/json-to-env.function.debug.sh < infra_output.json > ./functions/local.settings.json + @./scripts/json-to-env.function.debug.sh < inf_output.json > ./functions/local.settings.json # Utils (used by other Makefile rules) check-subscription: @@ -53,8 +53,16 @@ check-subscription: take-dir-ownership: @sudo chown -R vscode . +terraform-remote-backend: + @./scripts/terraform-remote-backend.sh + +infrastructure-remote-backend: terraform-remote-backend infrastructure + 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 + +run-migration: ## Migrate from bicep to terraform + python ./scripts/merge-databases.py \ No newline at end of file diff --git a/README.md b/README.md index 2d7fd06fd..9eeb7cff2 100644 --- a/README.md +++ b/README.md @@ -3,8 +3,9 @@ > [!IMPORTANT] > As of November 15, 2023, Azure Cognitive Search has been renamed to Azure AI Search. Azure Cognitive Services have also been renamed to Azure AI Services. -## Table of Contents +## Table of Contents +- [Response Generation Approaches](#response-generation-approaches) - [Features](#features) - [Azure account requirements](#azure-account-requirements) - [Azure Deployment](./docs/deployment/deployment.md) @@ -19,6 +20,7 @@ - [Using the app](/docs/deployment/using_ia_first_time.md) - [Responsible AI](#responsible-ai) - [Transparency Note](#transparency-note) + - [Content Safety](#content-safety) - [Data Collection Notice](#data-collection-notice) - [Resources](#resources) - [Known Issues](./docs/knownissues.md) @@ -39,6 +41,22 @@ The accelerator adapts prompts based on the model type for enhanced performance. Please [see this video](https://aka.ms/InfoAssist/video) for use cases that may be achievable with this accelerator. +# Response Generation Approaches + +## Work(Grounded) +It utilizes a retrieval-augmented generation (RAG) pattern to generate responses grounded in specific data sourced from your own dataset. By combining retrieval of relevant information with generative capabilities, It can produce responses that are not only contextually relevant but also grounded in verified data. The RAG pipeline accesses your dataset to retrieve relevant information before generating responses, ensuring accuracy and reliability. Additionally, each response includes a citation to the document chunk from which the answer is derived, providing transparency and allowing users to verify the source. This approach is particularly advantageous in domains where precision and factuality are paramount. Users can trust that the responses generated are based on reliable data sources, enhancing the credibility and usefulness of the application. Specific information on our Grounded (RAG) can be found in [RAG](docs/features/cognitive_search.md#azure-ai-search-integration) + +## Ungrounded +It leverages the capabilities of a large language model (LLM) to generate responses in an ungrounded manner, without relying on external data sources or retrieval-augmented generation techniques. The LLM has been trained on a vast corpus of text data, enabling it to generate coherent and contextually relevant responses solely based on the input provided. This approach allows for open-ended and creative generation, making it suitable for tasks such as ideation, brainstorming, and exploring hypothetical scenarios. It's important to note that the generated responses are not grounded in specific factual data and should be evaluated critically, especially in domains where accuracy and verifiability are paramount. + +## Work and Web +It offers 3 response options: one generated through our retrieval-augmented generation (RAG) pipeline, and the other grounded in content directly from the web. When users opt for the RAG response, they receive a grounded answer sourced from your data, complete with citations to document chunks for transparency and verification. Conversely, selecting the web response provides access to a broader range of sources, potentially offering more diverse perspectives. Each web response is grounded in content from the web accompanied by citations of web links, allowing users to explore the original sources for further context and validation. Upon request, It can also generate a final response that compares and contrasts both responses. This comparative analysis allows users to make informed decisions based on the reliability, relevance, and context of the information provided. +Specific information about our Grounded and Web can be found in [Web](/docs/features/features.md#bing-search-and-compare) + +## Assistants +It generates response by using LLM as a reasoning engine. The key strength lies in agent's ability to autonomously reason about tasks, decompose them into steps, and determine the appropriate tools and data sources to leverage, all without the need for predefined task definitions or rigid workflows. This approach allows for a dynamic and adaptive response generation process without predefining set of tasks. It harnesses the capabilities of LLM to understand natural language queries and generate responses tailored to specific tasks. These Agents are being released in preview mode as we continue to evaluate and mitigate the potential risks associated with autonomous reasoning, such as misuse of external tools, lack of transparency, biased outputs, privacy concerns, and remote code execution vulnerabilities. With future releases, we plan to work to enhance the safety and robustness of these autonomous reasoning capabilities. Specific information on our preview agents can be found in [Assistants](/docs/features/features.md#autonomous-reasoning-with-assistants-agents). + + ## Features The IA Accelerator contains several features, many of which have their own documentation. @@ -46,10 +64,17 @@ The IA Accelerator contains several features, many of which have their own docum - Examples of custom Retrieval Augmented Generation (RAG), Prompt Engineering, and Document Pre-Processing - Azure AI Search Integration to include text search of both text documents and images - Customization and Personalization to enable enhanced AI interaction +- Preview into autonomous agents For a detailed review see our [Features](./docs/features/features.md) page. -![Process Flow](/docs/process_flow.png) +### Process Flow for Work(Grounded), Ungrounded, and Work and Web + +![Process Flow for Chat](/docs/process_flow_chat.png) + +### Process Flow for Assistants + +![Process Flow for Assistants](/docs/process_flow_agent.png) ## Azure account requirements @@ -61,7 +86,7 @@ For a detailed review see our [Features](./docs/features/features.md) page. Model Name | Supported Versions ---|--- - gpt-35-turbo | 0301, 0613 + gpt-35-turbo | current version **gpt-35-turbo-16k** | current version **gpt-4** | current version gpt-4-32k | current version @@ -97,6 +122,20 @@ The Information Assistant (IA) Accelerator and Microsoft are committed to the ad Find out more with Microsoft's [Responsible AI resources](https://www.microsoft.com/en-us/ai/responsible-ai) +### Content Safety + +Content safety is provided through Azure Open AI service. The Azure OpenAI Service includes a content filtering system that runs alongside the core AI models. This system uses an ensemble of classification models to detect four categories of potentially harmful content (violence, hate, sexual, and self-harm) at four severity levels (safe, low, medium, high).These 4 categories may not be sufficient for all use cases, especially for minors. Please read our [Transaparncy Note](/docs/transparency.md) + +By default, the content filters are set to filter out prompts and completions that are detected as medium or high severity for those four harm categories. Content labeled as low or safe severity is not filtered. + +There are optional binary classifiers/filters that can detect jailbreak risk (trying to bypass filters) as well as existing text or code pulled from public repositories. These are turned off by default, but some scenarios may require enabling the public content detection models to retain coverage under the customer copyright commitment. + +The filtering configuration can be customized at the resource level, allowing customers to adjust the severity thresholds for filtering each harm category separately for prompts and completions. + +This provides controls for Azure customers to tailor the content filtering behavior to their needs while aiming to prevent potentially harmful generated content and any copyright violations from public content. + +Instructions on how to confiure content filters via Azure OpenAI Studio can be found here + ## 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 . 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. @@ -154,3 +193,4 @@ This project has adopted the [Microsoft Open Source Code of Conduct](https://ope ### Reporting Security Issues For security concerns, please see [Security Guidelines](./SECURITY.md) + diff --git a/app/backend/app.py b/app/backend/app.py index af8e93be6..27956f35f 100644 --- a/app/backend/app.py +++ b/app/backend/app.py @@ -1,15 +1,27 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT license. - +from io import StringIO +from typing import Optional +import asyncio +#from sse_starlette.sse import EventSourceResponse +#from starlette.responses import StreamingResponse +from starlette.responses import Response import logging -import mimetypes import os import json import urllib.parse -from datetime import datetime, timedelta - +import pandas as pd +from datetime import datetime, time, timedelta +from fastapi.staticfiles import StaticFiles +from fastapi import FastAPI, File, HTTPException, Request, UploadFile +from fastapi.responses import RedirectResponse, StreamingResponse import openai +from approaches.comparewebwithwork import CompareWebWithWork +from approaches.compareworkwithweb import CompareWorkWithWeb from approaches.chatreadretrieveread import ChatReadRetrieveReadApproach +from approaches.chatwebretrieveread import ChatWebRetrieveRead +from approaches.gpt_direct_approach import GPTDirectApproach +from approaches.approach import Approaches from azure.core.credentials import AzureKeyCredential from azure.identity import DefaultAzureCredential, AzureAuthorityHosts from azure.mgmt.cognitiveservices import CognitiveServicesManagementClient @@ -20,205 +32,304 @@ ResourceTypes, generate_account_sas, ) -from flask import Flask, jsonify, request -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" -AZURE_OPENAI_RESOURCE_GROUP = os.environ.get("AZURE_OPENAI_RESOURCE_GROUP") or "" -AZURE_OPENAI_CHATGPT_DEPLOYMENT = ( - os.environ.get("AZURE_OPENAI_CHATGPT_DEPLOYMENT") or "gpt-35-turbo-16k" +from approaches.mathassistant import( + generate_response, + process_agent_scratch_pad, + process_agent_response, + stream_agent_responses ) -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_MANAGEMENT_URL = ( os.environ.get("AZURE_MANAGEMENT_URL") or "") +from approaches.tabulardataassistant import ( + refreshagent, + save_df, + process_agent_response as td_agent_response, + process_agent_scratch_pad as td_agent_scratch_pad, + get_images_in_temp -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 "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_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" +) +from shared_code.status_log import State, StatusClassification, StatusLog, StatusQueryLevel +from azure.cosmos import CosmosClient + + +# === ENV Setup === + +ENV = { + "AZURE_BLOB_STORAGE_ACCOUNT": None, + "AZURE_BLOB_STORAGE_ENDPOINT": None, + "AZURE_BLOB_STORAGE_KEY": None, + "AZURE_BLOB_STORAGE_CONTAINER": "content", + "AZURE_BLOB_STORAGE_UPLOAD_CONTAINER": "upload", + "AZURE_SEARCH_SERVICE": "gptkb", + "AZURE_SEARCH_SERVICE_ENDPOINT": None, + "AZURE_SEARCH_SERVICE_KEY": None, + "AZURE_SEARCH_INDEX": "gptkbindex", + "USE_SEMANTIC_RERANKER": "true", + "AZURE_OPENAI_SERVICE": "myopenai", + "AZURE_OPENAI_RESOURCE_GROUP": "", + "AZURE_OPENAI_ENDPOINT": "", + "AZURE_OPENAI_AUTHORITY_HOST": "AzureCloud", + "AZURE_OPENAI_CHATGPT_DEPLOYMENT": "gpt-35-turbo-16k", + "AZURE_OPENAI_CHATGPT_MODEL_NAME": "", + "AZURE_OPENAI_CHATGPT_MODEL_VERSION": "", + "USE_AZURE_OPENAI_EMBEDDINGS": "false", + "EMBEDDING_DEPLOYMENT_NAME": "", + "AZURE_OPENAI_EMBEDDINGS_MODEL_NAME": "", + "AZURE_OPENAI_EMBEDDINGS_VERSION": "", + "AZURE_OPENAI_SERVICE_KEY": None, + "AZURE_SUBSCRIPTION_ID": None, + "AZURE_ARM_MANAGEMENT_API": "https://management.azure.com", + "CHAT_WARNING_BANNER_TEXT": "", + "APPLICATION_TITLE": "Information Assistant, built with Azure OpenAI", + "KB_FIELDS_CONTENT": "content", + "KB_FIELDS_PAGENUMBER": "pages", + "KB_FIELDS_SOURCEFILE": "file_uri", + "KB_FIELDS_CHUNKFILE": "chunk_file", + "COSMOSDB_URL": None, + "COSMOSDB_KEY": None, + "COSMOSDB_LOG_DATABASE_NAME": "statusdb", + "COSMOSDB_LOG_CONTAINER_NAME": "statuscontainer", + "QUERY_TERM_LANGUAGE": "English", + "TARGET_EMBEDDINGS_MODEL": "BAAI/bge-small-en-v1.5", + "ENRICHMENT_APPSERVICE_URL": "enrichment", + "TARGET_TRANSLATION_LANGUAGE": "en", + "ENRICHMENT_ENDPOINT": None, + "ENRICHMENT_KEY": None, + "AZURE_AI_TRANSLATION_DOMAIN": "api.cognitive.microsofttranslator.com", + "BING_SEARCH_ENDPOINT": "https://api.bing.microsoft.com/", + "BING_SEARCH_KEY": "", + "ENABLE_BING_SAFE_SEARCH": "true", + "ENABLE_WEB_CHAT": "false", + "ENABLE_UNGROUNDED_CHAT": "false", + "ENABLE_MATH_ASSISTANT": "false", + "ENABLE_TABULAR_DATA_ASSISTANT": "false", + "ENABLE_MULTIMEDIA": "false", + "MAX_CSV_FILE_SIZE": "7" + } + +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") -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" +str_to_bool = {'true': True, 'false': False} -# embedding_service_suffix = "xyoek" +log = logging.getLogger("uvicorn") +log.setLevel('DEBUG') +log.propagate = True +dffinal = None # Used by the OpenAI SDK openai.api_type = "azure" - -authority = AzureAuthorityHosts.AZURE_PUBLIC_CLOUD - -if (IS_GOV_CLOUD_DEPLOYMENT): - authority = AzureAuthorityHosts.AZURE_GOVERNMENT - openai.api_base = f"https://{AZURE_OPENAI_SERVICE}.openai.azure.us" +openai.api_base = ENV["AZURE_OPENAI_ENDPOINT"] +if ENV["AZURE_OPENAI_AUTHORITY_HOST"] == "AzureUSGovernment": + AUTHORITY = AzureAuthorityHosts.AZURE_GOVERNMENT else: - openai.api_base = f"https://{AZURE_OPENAI_SERVICE}.openai.azure.com" - -openai.api_version = "2023-06-01-preview" - + AUTHORITY = AzureAuthorityHosts.AZURE_PUBLIC_CLOUD +openai.api_version = "2023-12-01-preview" # 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 # If you encounter a blocking error during a DefaultAzureCredntial resolution, you can exclude the problematic credential by using a parameter (ex. exclude_shared_token_cache_credential=True) -azure_credential = DefaultAzureCredential(authority=authority) -azure_search_key_credential = AzureKeyCredential(AZURE_SEARCH_SERVICE_KEY) +azure_credential = DefaultAzureCredential(authority=AUTHORITY) +# Comment these two lines out if using keys, set your API key in the OPENAI_API_KEY environment variable instead +# openai.api_type = "azure_ad" +# openai_token = azure_credential.get_token("https://cognitiveservices.azure.com/.default") +openai.api_key = ENV["AZURE_OPENAI_SERVICE_KEY"] # Setup StatusLog to allow access to CosmosDB for logging statusLog = StatusLog( - 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 + ENV["COSMOSDB_URL"], + ENV["COSMOSDB_KEY"], + ENV["COSMOSDB_LOG_DATABASE_NAME"], + ENV["COSMOSDB_LOG_CONTAINER_NAME"] ) -# Comment these two lines out if using keys, set your API key in the OPENAI_API_KEY environment variable instead -# openai.api_type = "azure_ad" -# openai_token = azure_credential.get_token("https://cognitiveservices.azure.com/.default") -openai.api_key = AZURE_OPENAI_SERVICE_KEY - +azure_search_key_credential = AzureKeyCredential(ENV["AZURE_SEARCH_SERVICE_KEY"]) # Set up clients for Cognitive Search and Storage search_client = SearchClient( - endpoint=AZURE_SEARCH_SERVICE_ENDPOINT, - index_name=AZURE_SEARCH_INDEX, + endpoint=ENV["AZURE_SEARCH_SERVICE_ENDPOINT"], + index_name=ENV["AZURE_SEARCH_INDEX"], credential=azure_search_key_credential, ) blob_client = BlobServiceClient( - account_url=AZURE_BLOB_STORAGE_ENDPOINT, - credential=AZURE_BLOB_STORAGE_KEY, + account_url=ENV["AZURE_BLOB_STORAGE_ENDPOINT"], + credential=ENV["AZURE_BLOB_STORAGE_KEY"], ) -blob_container = blob_client.get_container_client(AZURE_BLOB_STORAGE_CONTAINER) +blob_container = blob_client.get_container_client(ENV["AZURE_BLOB_STORAGE_CONTAINER"]) model_name = '' model_version = '' -# Python issue Logged > https://github.com/Azure/azure-sdk-for-python/issues/34337 -# Once fixed, this If statement can be removed. -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 +# Set up OpenAI management client +openai_mgmt_client = CognitiveServicesManagementClient( + credential=azure_credential, + subscription_id=ENV["AZURE_SUBSCRIPTION_ID"], + base_url=ENV["AZURE_ARM_MANAGEMENT_API"], + credential_scopes=[ENV["AZURE_ARM_MANAGEMENT_API"] + "/.default"]) + +deployment = openai_mgmt_client.deployments.get( + resource_group_name=ENV["AZURE_OPENAI_RESOURCE_GROUP"], + account_name=ENV["AZURE_OPENAI_SERVICE"], + deployment_name=ENV["AZURE_OPENAI_CHATGPT_DEPLOYMENT"]) + +model_name = deployment.properties.model.name +model_version = deployment.properties.model.version + +if (str_to_bool.get(ENV["USE_AZURE_OPENAI_EMBEDDINGS"])): + embedding_deployment = openai_mgmt_client.deployments.get( + resource_group_name=ENV["AZURE_OPENAI_RESOURCE_GROUP"], + account_name=ENV["AZURE_OPENAI_SERVICE"], + deployment_name=ENV["EMBEDDING_DEPLOYMENT_NAME"]) + + embedding_model_name = embedding_deployment.properties.model.name + embedding_model_version = embedding_deployment.properties.model.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) - - 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 = "" + embedding_model_name = "" + embedding_model_version = "" chat_approaches = { - "rrr": ChatReadRetrieveReadApproach( - search_client, - AZURE_OPENAI_SERVICE, - AZURE_OPENAI_SERVICE_KEY, - AZURE_OPENAI_CHATGPT_DEPLOYMENT, - KB_FIELDS_SOURCEFILE, - KB_FIELDS_CONTENT, - KB_FIELDS_PAGENUMBER, - KB_FIELDS_CHUNKFILE, - AZURE_BLOB_STORAGE_CONTAINER, - blob_client, - QUERY_TERM_LANGUAGE, - model_name, - model_version, - IS_GOV_CLOUD_DEPLOYMENT, - TARGET_EMBEDDING_MODEL, - ENRICHMENT_APPSERVICE_NAME + Approaches.ReadRetrieveRead: ChatReadRetrieveReadApproach( + search_client, + ENV["AZURE_OPENAI_ENDPOINT"], + ENV["AZURE_OPENAI_SERVICE_KEY"], + ENV["AZURE_OPENAI_CHATGPT_DEPLOYMENT"], + ENV["KB_FIELDS_SOURCEFILE"], + ENV["KB_FIELDS_CONTENT"], + ENV["KB_FIELDS_PAGENUMBER"], + ENV["KB_FIELDS_CHUNKFILE"], + ENV["AZURE_BLOB_STORAGE_CONTAINER"], + blob_client, + ENV["QUERY_TERM_LANGUAGE"], + model_name, + model_version, + ENV["TARGET_EMBEDDINGS_MODEL"], + ENV["ENRICHMENT_APPSERVICE_URL"], + ENV["TARGET_TRANSLATION_LANGUAGE"], + ENV["ENRICHMENT_ENDPOINT"], + ENV["ENRICHMENT_KEY"], + ENV["AZURE_AI_TRANSLATION_DOMAIN"], + str_to_bool.get(ENV["USE_SEMANTIC_RERANKER"]) + ), + Approaches.ChatWebRetrieveRead: ChatWebRetrieveRead( + model_name, + ENV["AZURE_OPENAI_CHATGPT_DEPLOYMENT"], + ENV["TARGET_TRANSLATION_LANGUAGE"], + ENV["BING_SEARCH_ENDPOINT"], + ENV["BING_SEARCH_KEY"], + str_to_bool.get(ENV["ENABLE_BING_SAFE_SEARCH"]) + ), + Approaches.CompareWorkWithWeb: CompareWorkWithWeb( + model_name, + ENV["AZURE_OPENAI_CHATGPT_DEPLOYMENT"], + ENV["TARGET_TRANSLATION_LANGUAGE"], + ENV["BING_SEARCH_ENDPOINT"], + ENV["BING_SEARCH_KEY"], + str_to_bool.get(ENV["ENABLE_BING_SAFE_SEARCH"]) + ), + Approaches.CompareWebWithWork: CompareWebWithWork( + search_client, + ENV["AZURE_OPENAI_ENDPOINT"], + ENV["AZURE_OPENAI_SERVICE_KEY"], + ENV["AZURE_OPENAI_CHATGPT_DEPLOYMENT"], + ENV["KB_FIELDS_SOURCEFILE"], + ENV["KB_FIELDS_CONTENT"], + ENV["KB_FIELDS_PAGENUMBER"], + ENV["KB_FIELDS_CHUNKFILE"], + ENV["AZURE_BLOB_STORAGE_CONTAINER"], + blob_client, + ENV["QUERY_TERM_LANGUAGE"], + model_name, + model_version, + ENV["TARGET_EMBEDDINGS_MODEL"], + ENV["ENRICHMENT_APPSERVICE_URL"], + ENV["TARGET_TRANSLATION_LANGUAGE"], + ENV["ENRICHMENT_ENDPOINT"], + ENV["ENRICHMENT_KEY"], + ENV["AZURE_AI_TRANSLATION_DOMAIN"], + str_to_bool.get(ENV["USE_SEMANTIC_RERANKER"]) + ), + Approaches.GPTDirect: GPTDirectApproach( + ENV["AZURE_OPENAI_SERVICE"], + ENV["AZURE_OPENAI_SERVICE_KEY"], + ENV["AZURE_OPENAI_CHATGPT_DEPLOYMENT"], + ENV["QUERY_TERM_LANGUAGE"], + model_name, + model_version, + ENV["AZURE_OPENAI_ENDPOINT"] ) } -app = Flask(__name__) +# Create API +app = FastAPI( + title="IA Web API", + description="A Python API to serve as Backend For the Information Assistant Web App", + version="0.1.0", + docs_url="/docs", +) + +@app.get("/", include_in_schema=False, response_class=RedirectResponse) +async def root(): + """Redirect to the index.html page""" + return RedirectResponse(url="/index.html") + +@app.post("/chat") +async def chat(request: Request): + """Chat with the bot using a given approach -@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) + Args: + request (Request): The incoming request object -@app.route("/chat", methods=["POST"]) -def chat(): - """Chat with the bot using a given approach""" - approach = request.json["approach"] + Returns: + dict: The response containing the chat results + + Raises: + dict: The error response if an exception occurs during the chat + """ + json_body = await request.json() + approach = json_body.get("approach") try: - impl = chat_approaches.get(approach) + impl = chat_approaches.get(Approaches(int(approach))) if not impl: - return jsonify({"error": "unknown approach"}), 400 - r = impl.run(request.json["history"], request.json.get("overrides") or {}) - - # return jsonify(r) - # To fix citation bug,below code is added.aparmar - return jsonify( - { + return {"error": "unknown approach"}, 400 + + if (Approaches(int(approach)) == Approaches.CompareWorkWithWeb or Approaches(int(approach)) == Approaches.CompareWebWithWork): + r = await impl.run(json_body.get("history", []), json_body.get("overrides", {}), json_body.get("citation_lookup", {}), json_body.get("thought_chain", {})) + else: + r = await impl.run(json_body.get("history", []), json_body.get("overrides", {}), {}, json_body.get("thought_chain", {})) + + response = { "data_points": r["data_points"], "answer": r["answer"], "thoughts": r["thoughts"], - "citation_lookup": r["citation_lookup"], - } - ) + "thought_chain": r["thought_chain"], + "work_citation_lookup": r["work_citation_lookup"], + "web_citation_lookup": r["web_citation_lookup"] + } + + return response except Exception as ex: - logging.exception("Exception in /chat") - return jsonify({"error": str(ex)}), 500 + log.error(f"Error in chat:: {ex}") + raise HTTPException(status_code=500, detail=str(ex)) from ex + + + -@app.route("/getblobclienturl") -def get_blob_client_url(): - """Get a URL for a file in Blob Storage with SAS token""" +@app.get("/getblobclienturl") +async def get_blob_client_url(): + """Get a URL for a file in Blob Storage with SAS token. + + This function generates a Shared Access Signature (SAS) token for accessing a file in Blob Storage. + The generated URL includes the SAS token as a query parameter. + + Returns: + dict: A dictionary containing the URL with the SAS token. + """ sas_token = generate_account_sas( - AZURE_BLOB_STORAGE_ACCOUNT, - AZURE_BLOB_STORAGE_KEY, + ENV["AZURE_BLOB_STORAGE_ACCOUNT"], + ENV["AZURE_BLOB_STORAGE_KEY"], resource_types=ResourceTypes(object=True, service=True, container=True), permission=AccountSasPermissions( read=True, @@ -232,28 +343,203 @@ def get_blob_client_url(): ), expiry=datetime.utcnow() + timedelta(hours=1), ) - return jsonify({"url": f"{blob_client.url}?{sas_token}"}) + return {"url": f"{blob_client.url}?{sas_token}"} + +@app.post("/getalluploadstatus") +async def get_all_upload_status(request: Request): + """ + Get the status and tags of all file uploads in the last N hours. + + Parameters: + - request: The HTTP request object. + + Returns: + - results: The status of all file uploads in the specified timeframe. + """ + json_body = await request.json() + timeframe = json_body.get("timeframe") + state = json_body.get("state") + folder = json_body.get("folder") + tag = json_body.get("tag") + try: + results = statusLog.read_files_status_by_timeframe(timeframe, + State[state], + folder, + tag, + os.environ["AZURE_BLOB_STORAGE_UPLOAD_CONTAINER"]) + + # retrieve tags for each file + # Initialize an empty list to hold the tags + items = [] + cosmos_client = CosmosClient(url=statusLog._url, credential=statusLog._key) + database = cosmos_client.get_database_client(statusLog._database_name) + container = database.get_container_client(statusLog._container_name) + query_string = "SELECT DISTINCT VALUE t FROM c JOIN t IN c.tags" + items = list(container.query_items( + query=query_string, + enable_cross_partition_query=True + )) + + # Extract and split tags + unique_tags = set() + for item in items: + tags = item.split(',') + unique_tags.update(tags) -@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"] + + except Exception as ex: + log.exception("Exception in /getalluploadstatus") + raise HTTPException(status_code=500, detail=str(ex)) from ex + return results + +@app.post("/getfolders") +async def get_folders(request: Request): + """ + Get all folders. + + Parameters: + - request: The HTTP request object. + + Returns: + - results: list of unique folders. + """ try: - results = statusLog.read_files_status_by_timeframe(timeframe, State[state]) + blob_container = blob_client.get_container_client(os.environ["AZURE_BLOB_STORAGE_UPLOAD_CONTAINER"]) + # Initialize an empty list to hold the folder paths + folders = [] + # List all blobs in the container + blob_list = blob_container.list_blobs() + # Iterate through the blobs and extract folder names and add unique values to the list + for blob in blob_list: + # Extract the folder path if exists + folder_path = os.path.dirname(blob.name) + if folder_path and folder_path not in folders: + folders.append(folder_path) except Exception as ex: - logging.exception("Exception in /getalluploadstatus") - return jsonify({"error": str(ex)}), 500 - return jsonify(results) + log.exception("Exception in /getfolders") + raise HTTPException(status_code=500, detail=str(ex)) from ex + return folders + + +@app.post("/deleteItems") +async def delete_Items(request: Request): + """ + Delete a blob. + + Parameters: + - request: The HTTP request object. + + Returns: + - results: list of unique folders. + """ + json_body = await request.json() + full_path = json_body.get("path") + # remove the container prefix + path = full_path.split("/", 1)[1] + try: + blob_container = blob_client.get_container_client(os.environ["AZURE_BLOB_STORAGE_UPLOAD_CONTAINER"]) + blob_container.delete_blob(path) + statusLog.upsert_document(document_path=full_path, + status='Delete intiated', + status_classification=StatusClassification.INFO, + state=State.DELETING, + fresh_start=False) + statusLog.save_document(document_path=full_path) -@app.route("/logstatus", methods=["POST"]) -def logstatus(): - """Log the status of a file upload to CosmosDB""" + except Exception as ex: + log.exception("Exception in /delete_Items") + raise HTTPException(status_code=500, detail=str(ex)) from ex + return True + + +@app.post("/resubmitItems") +async def resubmit_Items(request: Request): + """ + Resubmit a blob. + + Parameters: + - request: The HTTP request object. + + Returns: + - results: list of unique folders. + """ + json_body = await request.json() + path = json_body.get("path") + # remove the container prefix + path = path.split("/", 1)[1] try: - path = request.json["path"] - status = request.json["status"] - status_classification = StatusClassification[request.json["status_classification"].upper()] - state = State[request.json["state"].upper()] + blob_container = blob_client.get_container_client(os.environ["AZURE_BLOB_STORAGE_UPLOAD_CONTAINER"]) + # Read the blob content into memory + blob_data = blob_container.download_blob(path).readall() + # Overwrite the blob with the modified data + blob_container.upload_blob(name=path, data=blob_data, overwrite=True) + # add the container to the path to avoid adding another doc in the status db + full_path = os.environ["AZURE_BLOB_STORAGE_UPLOAD_CONTAINER"] + '/' + path + statusLog.upsert_document(document_path=full_path, + status='Resubmitted to the processing pipeline', + status_classification=StatusClassification.INFO, + state=State.QUEUED, + fresh_start=False) + statusLog.save_document(document_path=full_path) + + except Exception as ex: + log.exception("Exception in /resubmitItems") + raise HTTPException(status_code=500, detail=str(ex)) from ex + return True + + +@app.post("/gettags") +async def get_tags(request: Request): + """ + Get all tags. + + Parameters: + - request: The HTTP request object. + + Returns: + - results: list of unique tags. + """ + try: + # Initialize an empty list to hold the tags + items = [] + cosmos_client = CosmosClient(url=statusLog._url, credential=statusLog._key) + database = cosmos_client.get_database_client(statusLog._database_name) + container = database.get_container_client(statusLog._container_name) + query_string = "SELECT DISTINCT VALUE t FROM c JOIN t IN c.tags" + items = list(container.query_items( + query=query_string, + enable_cross_partition_query=True + )) + + # Extract and split tags + unique_tags = set() + for item in items: + tags = item.split(',') + unique_tags.update(tags) + + except Exception as ex: + log.exception("Exception in /gettags") + raise HTTPException(status_code=500, detail=str(ex)) from ex + return unique_tags + +@app.post("/logstatus") +async def logstatus(request: Request): + """ + Log the status of a file upload to CosmosDB. + + Parameters: + - request: Request object containing the HTTP request data. + + Returns: + - A dictionary with the status code 200 if successful, or an error + message with status code 500 if an exception occurs. + """ + try: + json_body = await request.json() + path = json_body.get("path") + status = json_body.get("status") + status_classification = StatusClassification[json_body.get("status_classification").upper()] + state = State[json_body.get("state").upper()] statusLog.upsert_document(document_path=path, status=status, @@ -261,75 +547,328 @@ def logstatus(): 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"{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}", - "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}", - }) + log.exception("Exception in /logstatus") + raise HTTPException(status_code=500, detail=str(ex)) from ex + raise HTTPException(status_code=200, detail="Success") + +@app.get("/getInfoData") +async def get_info_data(): + """ + Get the info data for the app. + + Returns: + dict: A dictionary containing various information data for the app. + - "AZURE_OPENAI_CHATGPT_DEPLOYMENT": The deployment information for Azure OpenAI ChatGPT. + - "AZURE_OPENAI_MODEL_NAME": The name of the Azure OpenAI model. + - "AZURE_OPENAI_MODEL_VERSION": The version of the Azure OpenAI model. + - "AZURE_OPENAI_SERVICE": The Azure OpenAI service information. + - "AZURE_SEARCH_SERVICE": The Azure search service information. + - "AZURE_SEARCH_INDEX": The Azure search index information. + - "TARGET_LANGUAGE": The target language for query terms. + - "USE_AZURE_OPENAI_EMBEDDINGS": Flag indicating whether to use Azure OpenAI embeddings. + - "EMBEDDINGS_DEPLOYMENT": The deployment information for embeddings. + - "EMBEDDINGS_MODEL_NAME": The name of the embeddings model. + - "EMBEDDINGS_MODEL_VERSION": The version of the embeddings model. + """ + response = { + "AZURE_OPENAI_CHATGPT_DEPLOYMENT": ENV["AZURE_OPENAI_CHATGPT_DEPLOYMENT"], + "AZURE_OPENAI_MODEL_NAME": f"{model_name}", + "AZURE_OPENAI_MODEL_VERSION": f"{model_version}", + "AZURE_OPENAI_SERVICE": ENV["AZURE_OPENAI_SERVICE"], + "AZURE_SEARCH_SERVICE": ENV["AZURE_SEARCH_SERVICE"], + "AZURE_SEARCH_INDEX": ENV["AZURE_SEARCH_INDEX"], + "TARGET_LANGUAGE": ENV["QUERY_TERM_LANGUAGE"], + "USE_AZURE_OPENAI_EMBEDDINGS": ENV["USE_AZURE_OPENAI_EMBEDDINGS"], + "EMBEDDINGS_DEPLOYMENT": ENV["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(): + +@app.get("/getWarningBanner") +async def get_warning_banner(): """Get the warning banner text""" - response = jsonify( - { - "WARNING_BANNER_TEXT": f"{CHAT_WARNING_BANNER_TEXT}" - }) + response ={ + "WARNING_BANNER_TEXT": ENV["CHAT_WARNING_BANNER_TEXT"] + } + return response + +@app.get("/getMaxCSVFileSize") +async def get_max_csv_file_size(): + """Get the max csv size""" + response ={ + "MAX_CSV_FILE_SIZE": ENV["MAX_CSV_FILE_SIZE"] + } return response -@app.route("/getcitation", methods=["POST"]) -def get_citation(): - """Get the citation for a given file""" - citation = urllib.parse.unquote(request.json["citation"]) +@app.post("/getcitation") +async def get_citation(request: Request): + """ + Get the citation for a given file + + Parameters: + request (Request): The HTTP request object + + Returns: + dict: The citation results in JSON format + """ try: + json_body = await request.json() + citation = urllib.parse.unquote(json_body.get("citation")) blob = blob_container.get_blob_client(citation).download_blob() decoded_text = blob.readall().decode() - results = jsonify(json.loads(decoded_text)) + results = json.loads(decoded_text) except Exception as ex: - logging.exception("Exception in /getalluploadstatus") - return jsonify({"error": str(ex)}), 500 - return jsonify(results.json) + log.exception("Exception in /getcitation") + raise HTTPException(status_code=500, detail=str(ex)) from ex + return results # Return APPLICATION_TITLE -@app.route("/getApplicationTitle") -def get_application_title(): - """Get the application title text""" - response = jsonify( - { - "APPLICATION_TITLE": f"{APPLICATION_TITLE}" - }) +@app.get("/getApplicationTitle") +async def get_application_title(): + """Get the application title text + + Returns: + dict: A dictionary containing the application title. + """ + response = { + "APPLICATION_TITLE": ENV["APPLICATION_TITLE"] + } return response -@app.route("/getalltags", methods=["GET"]) -def get_all_tags(): - """Get the status of all tags in the system""" +@app.get("/getalltags") +async def get_all_tags(): + """ + Get the status of all tags in the system + + Returns: + dict: A dictionary containing the status of all tags + """ + try: + results = statusLog.get_all_tags() + except Exception as ex: + log.exception("Exception in /getalltags") + raise HTTPException(status_code=500, detail=str(ex)) from ex + return results + +@app.get("/getTempImages") +async def get_temp_images(): + """Get the images in the temp directory + + Returns: + list: A list of image data in the temp directory. + """ + images = get_images_in_temp() + return {"images": images} + +@app.get("/getHint") +async def getHint(question: Optional[str] = None): + """ + Get the hint for a question + + Returns: + str: A string containing the hint + """ + if question is None: + raise HTTPException(status_code=400, detail="Question is required") + + try: + results = generate_response(question).split("Clues")[1][2:] + except Exception as ex: + log.exception("Exception in /getHint") + raise HTTPException(status_code=500, detail=str(ex)) from ex + return results + +@app.post("/posttd") +async def posttd(csv: UploadFile = File(...)): + try: + global dffinal + # Read the file into a pandas DataFrame + content = await csv.read() + df = pd.read_csv(StringIO(content.decode('latin-1'))) + + dffinal = df + # Process the DataFrame... + save_df(df) + except Exception as ex: + raise HTTPException(status_code=500, detail=str(ex)) from ex + + + #return {"filename": csv.filename} +@app.get("/process_td_agent_response") +async def process_td_agent_response(retries=3, delay=1000, question: Optional[str] = None): + if question is None: + raise HTTPException(status_code=400, detail="Question is required") + for i in range(retries): + try: + results = td_agent_response(question) + return results + except AttributeError as ex: + log.exception(f"Exception in /process_tabular_data_agent_response:{str(ex)}") + if i < retries - 1: # i is zero indexed + await asyncio.sleep(delay) # wait a bit before trying again + else: + if str(ex) == "'NoneType' object has no attribute 'stream'": + return ["error: Csv has not been loaded"] + else: + raise HTTPException(status_code=500, detail=str(ex)) from ex + except Exception as ex: + log.exception(f"Exception in /process_tabular_data_agent_response:{str(ex)}") + if i < retries - 1: # i is zero indexed + await asyncio.sleep(delay) # wait a bit before trying again + else: + raise HTTPException(status_code=500, detail=str(ex)) from ex + +@app.get("/getTdAnalysis") +async def getTdAnalysis(retries=3, delay=1, question: Optional[str] = None): + global dffinal + if question is None: + raise HTTPException(status_code=400, detail="Question is required") + + for i in range(retries): + try: + save_df(dffinal) + results = td_agent_scratch_pad(question, dffinal) + return results + except AttributeError as ex: + log.exception(f"Exception in /getTdAnalysis:{str(ex)}") + if i < retries - 1: # i is zero indexed + await asyncio.sleep(delay) # wait a bit before trying again + else: + if str(ex) == "'NoneType' object has no attribute 'stream'": + return ["error: Csv has not been loaded"] + else: + raise HTTPException(status_code=500, detail=str(ex)) from ex + except Exception as ex: + log.exception(f"Exception in /getTdAnalysis:{str(ex)}") + if i < retries - 1: # i is zero indexed + await asyncio.sleep(delay) # wait a bit before trying again + else: + raise HTTPException(status_code=500, detail=str(ex)) from ex + +@app.post("/refresh") +async def refresh(): + """ + Refresh the agent's state. + + This endpoint calls the `refresh` function to reset the agent's state. + + Raises: + HTTPException: If an error occurs while refreshing the agent's state. + + Returns: + dict: A dictionary containing the status of the agent's state. + """ + try: + refreshagent() + except Exception as ex: + log.exception("Exception in /refresh") + raise HTTPException(status_code=500, detail=str(ex)) from ex + return {"status": "success"} + +@app.get("/getSolve") +async def getSolve(question: Optional[str] = None): + + if question is None: + raise HTTPException(status_code=400, detail="Question is required") + + try: + results = process_agent_scratch_pad(question) + except Exception as ex: + log.exception("Exception in /getSolve") + raise HTTPException(status_code=500, detail=str(ex)) from ex + return results + + +@app.get("/stream") +async def stream_response(question: str): + try: + stream = stream_agent_responses(question) + return StreamingResponse(stream, media_type="text/event-stream") + except Exception as ex: + log.exception("Exception in /stream") + raise HTTPException(status_code=500, detail=str(ex)) from ex + +@app.get("/tdstream") +async def td_stream_response(question: str): + save_df(dffinal) + + try: - results = tagsHelper.get_all_tags() + stream = td_agent_scratch_pad(question, dffinal) + return StreamingResponse(stream, media_type="text/event-stream") except Exception as ex: - logging.exception("Exception in /getalltags") - return jsonify({"error": str(ex)}), 500 - return jsonify(results) + log.exception("Exception in /stream") + raise HTTPException(status_code=500, detail=str(ex)) from ex + + + + +@app.get("/process_agent_response") +async def stream_agent_response(question: str): + """ + Stream the response of the agent for a given question. + + This endpoint uses Server-Sent Events (SSE) to stream the response of the agent. + It calls the `process_agent_response` function which yields chunks of data as they become available. + + Args: + question (str): The question to be processed by the agent. + + Yields: + dict: A dictionary containing a chunk of the agent's response. + + Raises: + HTTPException: If an error occurs while processing the question. + """ + # try: + # def event_stream(): + # data_generator = iter(process_agent_response(question)) + # while True: + # try: + # chunk = next(data_generator) + # yield chunk + # except StopIteration: + # yield "data: keep-alive\n\n" + # time.sleep(5) + # return StreamingResponse(event_stream(), media_type="text/event-stream") + if question is None: + raise HTTPException(status_code=400, detail="Question is required") + + try: + results = process_agent_response(question) + except Exception as e: + print(f"Error processing agent response: {e}") + raise HTTPException(status_code=500, detail=str(e)) + return results + + +@app.get("/getFeatureFlags") +async def get_feature_flags(): + """ + Get the feature flag settings for the app. + + Returns: + dict: A dictionary containing various feature flags for the app. + - "ENABLE_WEB_CHAT": Flag indicating whether web chat is enabled. + - "ENABLE_UNGROUNDED_CHAT": Flag indicating whether ungrounded chat is enabled. + - "ENABLE_MATH_ASSISTANT": Flag indicating whether the math assistant is enabled. + - "ENABLE_TABULAR_DATA_ASSISTANT": Flag indicating whether the tabular data assistant is enabled. + - "ENABLE_MULTIMEDIA": Flag indicating whether multimedia is enabled. + """ + response = { + "ENABLE_WEB_CHAT": str_to_bool.get(ENV["ENABLE_WEB_CHAT"]), + "ENABLE_UNGROUNDED_CHAT": str_to_bool.get(ENV["ENABLE_UNGROUNDED_CHAT"]), + "ENABLE_MATH_ASSISTANT": str_to_bool.get(ENV["ENABLE_MATH_ASSISTANT"]), + "ENABLE_TABULAR_DATA_ASSISTANT": str_to_bool.get(ENV["ENABLE_TABULAR_DATA_ASSISTANT"]), + "ENABLE_MULTIMEDIA": str_to_bool.get(ENV["ENABLE_MULTIMEDIA"]), + } + return response + +app.mount("/", StaticFiles(directory="static"), name="static") if __name__ == "__main__": - logging.info("IA WebApp Starting Up...") - app.run(threaded=True) + log.info("IA WebApp Starting Up...") diff --git a/app/backend/approaches/approach.py b/app/backend/approaches/approach.py index d9137d0a0..f6b7e1959 100644 --- a/app/backend/approaches/approach.py +++ b/app/backend/approaches/approach.py @@ -1,19 +1,92 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT license. +from core.messagebuilder import MessageBuilder +from typing import Any, Sequence +import tiktoken +from enum import Enum +#This class must match the Enum in app\frontend\src\api +class Approaches(Enum): + RetrieveThenRead = 0 + ReadRetrieveRead = 1 + ReadDecomposeAsk = 2 + GPTDirect = 3 + ChatWebRetrieveRead = 4 + CompareWorkWithWeb = 5 + CompareWebWithWork = 6 class Approach: """ An approach is a method for answering a question from a query and a set of documents. """ + # Chat roles + SYSTEM = "system" + USER = "user" + ASSISTANT = "assistant" - def run(self, history: list[dict], overrides: dict) -> any: + async def run(self, history: list[dict], overrides: dict, citation_lookup: dict[str, Any], thought_chain: dict[str, Any]) -> any: """ Run the approach on the query and documents. Not implemented. Args: history: The chat history. (e.g. [{"user": "hello", "bot": "hi"}]) overrides: Overrides for the approach. (e.g. temperature, etc.) + citation_lookup: The dictionary for the citations. + thought_chain: The dictionary for the thought chain. """ raise NotImplementedError + + def get_messages_from_history( + self, + system_prompt: str, + model_id: str, + history: Sequence[dict[str, str]], + user_conv: str, + few_shots = [dict[str, str]], + max_tokens: int = 4096, + ) -> []: + """ + Construct a list of messages from the chat history and the user's question. + """ + message_builder = MessageBuilder(system_prompt, model_id) + + # Few Shot prompting. Add examples to show the chat what responses we want. It will try to mimic any responses and make sure they match the rules laid out in the system message. + for shot in few_shots: + message_builder.append_message(shot.get('role'), shot.get('content')) + + user_content = user_conv + append_index = len(few_shots) + 1 + + message_builder.append_message(self.USER, user_content, index=append_index) + + for h in reversed(history[:-1]): + if h.get("bot"): + message_builder.append_message(self.ASSISTANT, h.get('bot'), index=append_index) + message_builder.append_message(self.USER, h.get('user'), index=append_index) + if message_builder.token_length > max_tokens: + break + + messages = message_builder.messages + return messages + + #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", + 3072: "thorough", + } + 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 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 diff --git a/app/backend/approaches/chatreadretrieveread.py b/app/backend/approaches/chatreadretrieveread.py index 131373dc7..13d4bd60b 100644 --- a/app/backend/approaches/chatreadretrieveread.py +++ b/app/backend/approaches/chatreadretrieveread.py @@ -1,7 +1,6 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT license. -import json import re import logging import urllib.parse @@ -10,14 +9,9 @@ import openai from approaches.approach import Approach -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, @@ -25,79 +19,72 @@ generate_account_sas, ) from text import nonewlines -import tiktoken -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, -# then constructs a prompt with them, and then uses OpenAI to generate -# an completion (answer) with that prompt. class ChatReadRetrieveReadApproach(Approach): - - # Chat roles - SYSTEM = "system" - USER = "user" - ASSISTANT = "assistant" + """Approach that uses a simple retrieve-then-read implementation, using the Azure AI Search and + Azure OpenAI APIs directly. It first retrieves top documents from search, + then constructs a prompt with them, and then uses Azure OpenAI to generate + an completion (answer) with that prompt.""" - system_message_chat_conversation = """You are an Azure OpenAI Completion system. Your persona is {systemPersona} who helps answer questions about an agency's data. {response_length_prompt} + + + SYSTEM_MESSAGE_CHAT_CONVERSATION = """You are an Azure OpenAI Completion system. Your persona is {systemPersona} who helps answer questions about an agency's data. {response_length_prompt} User persona is {userPersona} Answer ONLY with the facts listed in the list of sources below in {query_term_language} with citations.If there isn't enough information below, say you don't know and do not give citations. For tabular information return it as an html table. Do not return markdown format. Your goal is to provide answers based on the facts listed below in the provided source documents. Avoid making assumptions,generating speculative or generalized information or adding personal opinions. - - - Each source has a file name followed by a pipe character and the actual information.Use square brackets to reference the source, e.g. [info1.txt]. Do not combine sources, list each source separately, e.g. [info1.txt][info2.pdf]. + + Each source has content followed by a pipe character and the URL. Instead of writing the full URL, cite it using placeholders like [File1], [File2], etc., based on their order in the list. Do not combine sources; list each source URL separately, e.g., [File1] [File2]. Never cite the source content using the examples provided in this paragraph that start with info. - + Sources: + - Content about topic A | info.pdf + - Content about topic B | example.txt + + Reference these as [File1] and [File2] respectively in your answers. + Here is how you should answer every question: -Look for information in the source documents to answer the question in {query_term_language}. -If the source document has an answer, please respond with citation.You must include a citation to each document referenced only once when you find answer in source documents. -If you cannot find answer in below sources, respond with I am not sure.Do not provide personal opinions or assumptions and do not include citations. - + -Identify the language of the user's question and translate the final response to that language.if the final answer is " I am not sure" then also translate it to the language of the user's question and then display translated response only. nothing else. + {follow_up_questions_prompt} {injected_prompt} - """ - follow_up_questions_prompt_content = """ - Generate three very brief follow-up questions that the user would likely ask next about their agencies data. Use triple angle brackets to reference the questions, e.g. <<>>. Try not to repeat questions that have already been asked. - Only generate questions and do not generate any text before or after the questions, such as 'Next Questions' + + FOLLOW_UP_QUESTIONS_PROMPT_CONTENT = """ALWAYS generate three very brief unordered follow-up questions surrounded by triple chevrons (<<>>) that the user would likely ask next about their agencies data. + Surround each follow-up question with triple chevrons (<<>>). Try not to repeat questions that have already been asked. + Only generate follow-up questions and do not generate any text before or after the follow-up questions, such as 'Next Questions' """ - query_prompt_template = """Below is a history of the conversation so far, and a new question asked by the user that needs to be answered by searching in source documents. + + QUERY_PROMPT_TEMPLATE = """Below is a history of the conversation so far, and a new question asked by the user that needs to be answered by searching in source documents. Generate a search query based on the conversation and the new question. Treat each search term as an individual keyword. Do not combine terms in quotes or brackets. Do not include cited source filenames and document names e.g info.txt or doc.pdf in the search query terms. Do not include any text inside [] or <<<>>> in the search query terms. Do not include any special characters like '+'. - If the question is not in {query_term_language}, translate the question to {query_term_language} before generating the search query. If you cannot generate a search query, return just the number 0. """ - #Few Shot prompting for Keyword Search Query - query_prompt_few_shots = [ - {'role' : USER, 'content' : 'What are the future plans for public transportation development?' }, - {'role' : ASSISTANT, 'content' : 'Future plans for public transportation' }, - {'role' : USER, 'content' : 'how much renewable energy was generated last year?' }, - {'role' : ASSISTANT, 'content' : 'Renewable energy generation last year' } + QUERY_PROMPT_FEW_SHOTS = [ + {'role' : Approach.USER, 'content' : 'What are the future plans for public transportation development?' }, + {'role' : Approach.ASSISTANT, 'content' : 'Future plans for public transportation' }, + {'role' : Approach.USER, 'content' : 'how much renewable energy was generated last year?' }, + {'role' : Approach.ASSISTANT, 'content' : 'Renewable energy generation last year' } ] - #Few Shot prompting for Response. This will feed into Chain of thought system message. - response_prompt_few_shots = [ - {"role": USER ,'content': 'I am looking for information in source documents'}, - {'role': ASSISTANT, 'content': 'user is looking for information in source documents. Do not provide answers that are not in the source documents'}, - {'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[File0]'} + RESPONSE_PROMPT_FEW_SHOTS = [ + {"role": Approach.USER ,'content': 'I am looking for information in source documents'}, + {'role': Approach.ASSISTANT, 'content': 'user is looking for information in source documents. Do not provide answers that are not in the source documents'}, + {'role': Approach.USER, 'content': 'What steps are being taken to promote energy conservation?'}, + {'role': Approach.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[File0]'} ] - # # 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_endpoint: str, oai_service_key: str, chatgpt_deployment: str, source_file_field: str, @@ -109,9 +96,14 @@ def __init__( query_term_language: str, model_name: str, model_version: str, - is_gov_cloud_deployment: str, - TARGET_EMBEDDING_MODEL: str, - ENRICHMENT_APPSERVICE_NAME: str + target_embedding_model: str, + enrichment_appservice_uri: str, + target_translation_language: str, + enrichment_endpoint:str, + enrichment_key:str, + azure_ai_translation_domain: str, + use_semantic_reranker: bool + ): self.search_client = search_client self.chatgpt_deployment = chatgpt_deployment @@ -124,28 +116,29 @@ def __init__( 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) + self.escaped_target_model = re.sub(r'[^a-zA-Z0-9_\-.]', '_', target_embedding_model) + self.target_translation_language=target_translation_language + self.enrichment_endpoint=enrichment_endpoint + self.enrichment_key=enrichment_key + self.oai_endpoint=oai_endpoint + self.embedding_service_url = enrichment_appservice_uri + self.azure_ai_translation_domain=azure_ai_translation_domain + self.use_semantic_reranker=use_semantic_reranker - 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' - - if is_gov_cloud_deployment: - openai.api_base = 'https://' + oai_service_name + '.openai.azure.us/' - else: - openai.api_base = 'https://' + oai_service_name + '.openai.azure.com/' - + openai.api_base = oai_endpoint 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: + async def run(self, history: Sequence[dict[str, str]], overrides: dict[str, Any], citation_lookup: dict[str, Any], thought_chain: dict[str, Any]) -> Any: + + log = logging.getLogger("uvicorn") + log.setLevel('DEBUG') + log.propagate = True + use_semantic_captions = True if overrides.get("semantic_captions") else False top = overrides.get("top") or 3 user_persona = overrides.get("user_persona", "") @@ -155,21 +148,29 @@ def run(self, history: Sequence[dict[str, str]], overrides: dict[str, Any]) -> A tags_filter = overrides.get("selected_tags", "") user_q = 'Generate search query for: ' + history[-1]["user"] - - query_prompt=self.query_prompt_template.format(query_term_language=self.query_term_language) - + thought_chain["work_query"] = user_q + + # Detect the language of the user's question + detectedlanguage = self.detect_language(user_q) + + if detectedlanguage != self.target_translation_language: + user_question = self.translate_response(user_q, self.target_translation_language) + else: + user_question = user_q + + query_prompt=self.QUERY_PROMPT_TEMPLATE.format(query_term_language=self.query_term_language) # STEP 1: Generate an optimized keyword search query based on the chat history and the last question messages = self.get_messages_from_history( query_prompt, self.model_name, history, - user_q, - self.query_prompt_few_shots, - self.chatgpt_token_limit - len(user_q) + user_question, + self.QUERY_PROMPT_FEW_SHOTS, + self.chatgpt_token_limit - len(user_question) ) - chat_completion = openai.ChatCompletion.create( + chat_completion = await openai.ChatCompletion.acreate( deployment_id=self.chatgpt_deployment, model=self.model_name, messages=messages, @@ -179,10 +180,12 @@ 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"] + thought_chain["work_search_term"] = generated_query # Generate embedding using REST API url = f'{self.embedding_service_url}/models/{self.escaped_target_model}/embed' data = [f'"{generated_query}"'] @@ -196,7 +199,7 @@ def run(self, history: Sequence[dict[str, str]], overrides: dict[str, Any]) -> A response_data = response.json() embedded_query_vector =response_data.get('data') else: - logging.error(f"Error generating embedding:: {response.status_code}") + log.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 @@ -208,11 +211,10 @@ def run(self, history: Sequence[dict[str, str]], overrides: dict[str, Any]) -> A 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}', ','))" + search_filter = search_filter + f" and tags/any(t: search.in(t, '{tags_filter}', ','))" else: - search_filter = f"tags/any(t: search.in(t, '{quoted_tags_filter}', ','))" + search_filter = f"tags/any(t: search.in(t, '{tags_filter}', ','))" # Hybrid Search # r = self.search_client.search(generated_query, vector_queries =[vector], top=top) @@ -225,14 +227,10 @@ def run(self, history: Sequence[dict[str, str]], overrides: dict[str, Any]) -> A # 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")): + if (self.use_semantic_reranker and overrides.get("semantic_ranker")): r = self.search_client.search( generated_query, query_type=QueryType.SEMANTIC, - query_language="en-us", - # query_language=self.query_term_language, - query_speller="lexicon", semantic_configuration_name="default", top=top, query_caption="extractive|highlight-false" @@ -242,24 +240,21 @@ def run(self, history: Sequence[dict[str, str]], overrides: dict[str, Any]) -> A ) else: r = self.search_client.search( - generated_query, top=top,vector_queries =[vector], filter=search_filter + 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 - + # #print search results with score # for idx, doc in enumerate(r): # for each document in the search results # print(f"File{idx}: ", doc['@search.score']) - + # cutoff_score=0.01 - # # Only include results where search.score is greater than cutoff_score # filtered_results = [doc for doc in r if doc['@search.score'] > cutoff_score] # # print("Filtered Results: ", len(filtered_results)) - - 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 @@ -281,7 +276,6 @@ def run(self, history: Sequence[dict[str, str]], overrides: dict[str, Any]) -> A "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) if results_text == "": @@ -291,7 +285,7 @@ def run(self, history: Sequence[dict[str, str]], overrides: dict[str, Any]) -> A # STEP 3: Generate the prompt to be sent to the GPT model follow_up_questions_prompt = ( - self.follow_up_questions_prompt_content + self.FOLLOW_UP_QUESTIONS_PROMPT_CONTENT if overrides.get("suggest_followup_questions") else "" ) @@ -300,7 +294,7 @@ def run(self, history: Sequence[dict[str, str]], overrides: dict[str, Any]) -> A prompt_override = overrides.get("prompt_template") if prompt_override is None: - system_message = self.system_message_chat_conversation.format( + system_message = self.SYSTEM_MESSAGE_CHAT_CONVERSATION.format( query_term_language=self.query_term_language, injected_prompt="", follow_up_questions_prompt=follow_up_questions_prompt, @@ -311,7 +305,7 @@ def run(self, history: Sequence[dict[str, str]], overrides: dict[str, Any]) -> A systemPersona=system_persona, ) elif prompt_override.startswith(">>>"): - system_message = self.system_message_chat_conversation.format( + system_message = self.SYSTEM_MESSAGE_CHAT_CONVERSATION.format( query_term_language=self.query_term_language, injected_prompt=prompt_override[3:] + "\n ", follow_up_questions_prompt=follow_up_questions_prompt, @@ -322,7 +316,7 @@ def run(self, history: Sequence[dict[str, str]], overrides: dict[str, Any]) -> A systemPersona=system_persona, ) else: - system_message = self.system_message_chat_conversation.format( + system_message = self.SYSTEM_MESSAGE_CHAT_CONVERSATION.format( query_term_language=self.query_term_language, follow_up_questions_prompt=follow_up_questions_prompt, response_length_prompt=self.get_response_length_prompt_text( @@ -333,14 +327,13 @@ def run(self, history: Sequence[dict[str, str]], overrides: dict[str, Any]) -> A ) # STEP 3: Generate a contextual and content-specific answer using the search results and chat history. #Added conditional block to use different system messages for different models. - if self.model_name.startswith("gpt-35-turbo"): messages = self.get_messages_from_history( system_message, self.model_name, history, history[-1]["user"] + "Sources:\n" + content + "\n\n", # 3.5 has recency Bias that is why this is here - self.response_prompt_few_shots, + self.RESPONSE_PROMPT_FEW_SHOTS, max_tokens=self.chatgpt_token_limit - 500 ) @@ -354,8 +347,7 @@ 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( + chat_completion = await openai.ChatCompletion.acreate( deployment_id=self.chatgpt_deployment, model=self.model_name, messages=messages, @@ -371,7 +363,7 @@ def run(self, history: Sequence[dict[str, str]], overrides: dict[str, Any]) -> A history, # history[-1]["user"], history[-1]["user"] + "Sources:\n" + content + "\n\n", # GPT 4 starts to degrade with long system messages. so moving sources here - self.response_prompt_few_shots, + self.RESPONSE_PROMPT_FEW_SHOTS, max_tokens=self.chatgpt_token_limit ) @@ -386,7 +378,7 @@ def run(self, history: Sequence[dict[str, str]], overrides: dict[str, Any]) -> A #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( + chat_completion = await openai.ChatCompletion.acreate( deployment_id=self.chatgpt_deployment, model=self.model_name, messages=messages, @@ -394,66 +386,69 @@ def run(self, history: Sequence[dict[str, str]], overrides: dict[str, Any]) -> A max_tokens=1024, n=1 ) - # STEP 4: Format the response msg_to_display = '\n\n'.join([str(message) for message in messages]) + generated_response=chat_completion.choices[0].message.content + # # Detect the language of the response + response_language = self.detect_language(generated_response) + #if response is not in user's language, translate it to user's language + if response_language != detectedlanguage: + translated_response = self.translate_response(generated_response, detectedlanguage) + else: + translated_response = generated_response + thought_chain["work_response"] = urllib.parse.unquote(translated_response) + return { "data_points": data_points, - "answer": f"{urllib.parse.unquote(chat_completion.choices[0].message.content)}", + "answer": f"{urllib.parse.unquote(translated_response)}", "thoughts": f"Searched for:
{generated_query}

Conversations:
" + msg_to_display.replace('\n', '
'), - "citation_lookup": citation_lookup + "thought_chain": thought_chain, + "work_citation_lookup": citation_lookup, + "web_citation_lookup": {} } - #Aparmar. Custom method to construct Chat History as opposed to single string of chat History. - def get_messages_from_history( - self, - system_prompt: str, - model_id: str, - history: Sequence[dict[str, str]], - user_conv: str, - few_shots = [], - max_tokens: int = 4096) -> []: - """ - Construct a list of messages from the chat history and the user's question. - """ - message_builder = MessageBuilder(system_prompt, model_id) - - # Few Shot prompting. Add examples to show the chat what responses we want. It will try to mimic any responses and make sure they match the rules laid out in the system message. - for shot in few_shots: - message_builder.append_message(shot.get('role'), shot.get('content')) - - user_content = user_conv - append_index = len(few_shots) + 1 - - message_builder.append_message(self.USER, user_content, index=append_index) - - for h in reversed(history[:-1]): - if h.get("bot"): - message_builder.append_message(self.ASSISTANT, h.get('bot'), index=append_index) - message_builder.append_message(self.USER, h.get('user'), index=append_index) - if message_builder.token_length > max_tokens: - break - - messages = message_builder.messages - return messages - - #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", - 3072: "thorough", + def detect_language(self, text: str) -> str: + """ Function to detect the language of the text""" + try: + endpoint_region = self.enrichment_endpoint.split("https://")[1].split(".api")[0] + api_detect_endpoint = f"https://{self.azure_ai_translation_domain}/detect?api-version=3.0" + headers = { + 'Ocp-Apim-Subscription-Key': self.enrichment_key, + 'Content-type': 'application/json', + 'Ocp-Apim-Subscription-Region': endpoint_region + } + data = [{"text": text}] + response = requests.post(api_detect_endpoint, headers=headers, json=data) + + if response.status_code == 200: + detected_language = response.json()[0]['language'] + return detected_language + else: + raise Exception(f"Error detecting language: {response.status_code}") + except Exception as e: + raise Exception(f"An error occurred during language detection: {str(e)}") from e + + def translate_response(self, response: str, target_language: str) -> str: + """ Function to translate the response to target language""" + endpoint_region = self.enrichment_endpoint.split("https://")[1].split(".api")[0] + api_translate_endpoint = f"https://{self.azure_ai_translation_domain}/translate?api-version=3.0" + headers = { + 'Ocp-Apim-Subscription-Key': self.enrichment_key, + 'Content-type': 'application/json', + 'Ocp-Apim-Subscription-Region': endpoint_region } - 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 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 + params={'to': target_language } + data = [{ + "text": response + }] + response = requests.post(api_translate_endpoint, headers=headers, json=data, params=params) + + if response.status_code == 200: + translated_response = response.json()[0]['translations'][0]['text'] + return translated_response + else: + raise Exception(f"Error translating response: {response.status_code}") def get_source_file_with_sas(self, source_file: str) -> str: """ Function to return the source file with a SAS token""" diff --git a/app/backend/approaches/chatwebretrieveread.py b/app/backend/approaches/chatwebretrieveread.py new file mode 100644 index 000000000..e8905c66b --- /dev/null +++ b/app/backend/approaches/chatwebretrieveread.py @@ -0,0 +1,247 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +import os +import re +from typing import Any, Sequence +import urllib.parse +from web_search_client import WebSearchClient +from web_search_client.models import SafeSearch +from azure.core.credentials import AzureKeyCredential +import openai +from approaches.approach import Approach +from core.messagebuilder import MessageBuilder +from core.modelhelper import get_token_limit + +class ChatWebRetrieveRead(Approach): + """Class to help perform RAG based on Bing Search and ChatGPT.""" + + SYSTEM_MESSAGE_CHAT_CONVERSATION = """You are an Azure OpenAI Completion system. Your persona is a {systemPersona} who helps answer questions. {response_length_prompt} + User persona is {userPersona}. Answer ONLY with the facts listed in the source URLs below in {query_term_language} with citations. If there isn't enough information below, say "I don't know" and do not give citations. For tabular information, return it as an HTML table. Do not return markdown format. + Your goal is to provide answers based on the facts listed below in the provided URLs and content. Avoid making assumptions, generating speculative or generalized information, or adding personal opinions. + + Each source has content followed by a pipe character and the URL. When citing sources, do not write out the URL or use any formatting other than [url1], [url2], etc., based on their order in the list. For example, instead of writing "[Microsoft Azure](https://en.wikipedia.org/wiki/Microsoft_Azure)", you should write "[url1]". + Sources: + - Content about topic A | http://example.com/link1 + - Content about topic B | http://example.com/link2 + + Reference these as [url1] and [url2] respectively in your answers. + + Here is how you should answer every question: + + - Look for information in the provided content to answer the question in {query_term_language}. + - If the provided content has an answer, please respond with a citation. You must include a citation to each URL referenced only once when you find an answer in source URLs. + - If you cannot find an answer in the below sources, respond with "I am not sure." Do not provide personal opinions or assumptions and do not include citations. + - Identify the language of the user's question and translate the final response to that language. If the final answer is "I am not sure," then also translate it to the language of the user's question and display the translated response only. + + {follow_up_questions_prompt} + """ + + FOLLOW_UP_QUESTIONS_PROMPT_CONTENT = """ALWAYS generate three very brief unordered follow-up questions surrounded by triple chevrons (<<>>) that the user would likely ask next about their agencies data. + Surround each follow-up question with triple chevrons (<<>>). Try not to repeat questions that have already been asked. + Only generate follow-up questions and do not generate any text before or after the follow-up questions, such as 'Next Questions' + """ + + QUERY_PROMPT_TEMPLATE = """Below is a history of the conversation so far, and a new question asked by the user that needs to be answered by searching in Bing Search. + Generate a search query based on the conversation and the new question. Treat each search term as an individual keyword. Do not combine terms in quotes or brackets. + Do not include cited sources in the search query terms. + Do not include any brackets or text within [] or <<<>>> in the search query terms. + Do not include any special characters like '+'. + If you cannot generate a search query, return just the number 0. + """ + + + QUERY_PROMPT_FEW_SHOTS = [ + {'role': Approach.USER, 'content': 'Could you search the web for information on the latest advancements in artificial intelligence,citing the provided URLs.?'}, + {'role': Approach.ASSISTANT, 'content': 'User wants to know about recent advancements in artificial intelligence,with citations from the provided URLs.'}, + {'role': Approach.USER, 'content': 'can you search the web and provide information on impact of climate change on global agriculture,citing the content from the URLs provided. ?'}, + {'role': Approach.ASSISTANT, 'content': 'User is seeking information about the effects of climate change on global agriculture,with citations from the content provided in the URLs.'} +] + + + RESPONSE_PROMPT_FEW_SHOTS = [ + {"role": Approach.USER ,'content': 'I am looking for information in source urls and its snippets'}, + {'role': Approach.ASSISTANT, 'content': 'user is looking for information in source urls and its snippets.'}, + {"role": Approach.USER, 'content': 'I need data extracted from the URLs and their corresponding snippets.'}, + {'role': Approach.ASSISTANT, 'content': 'User requires data extracted from the URLs and their snippets.'} + ] + + + citations = {} + approach_class = "" + + def __init__(self, model_name: str, chatgpt_deployment: str, query_term_language: str, bing_search_endpoint: str, bing_search_key: str, bing_safe_search: bool): + self.name = "ChatBingSearch" + self.model_name = model_name + self.chatgpt_deployment = chatgpt_deployment + self.query_term_language = query_term_language + self.chatgpt_token_limit = get_token_limit(model_name) + self.bing_search_endpoint = bing_search_endpoint + self.bing_search_key = bing_search_key + self.bing_safe_search = bing_safe_search + + + async def run(self, history: Sequence[dict[str, str]],overrides: dict[str, Any], citation_lookup: dict[str, Any], thought_chain: dict[str, Any]) -> Any: + """ + Runs the approach to simulate experience with Bing Chat. + + Args: + history (Sequence[dict[str, str]]): The conversation history. + overrides (dict[str, Any]): The overrides for the approach. + + Returns: + Any: The result of the approach. + """ + + user_query = history[-1].get("user") + user_persona = overrides.get("user_persona", "") + system_persona = overrides.get("system_persona", "") + response_length = int(overrides.get("response_length") or 1024) + thought_chain["web_query"] = user_query + + follow_up_questions_prompt = ( + self.FOLLOW_UP_QUESTIONS_PROMPT_CONTENT + if overrides.get("suggest_followup_questions") + else "" + ) + + # STEP 1: Generate an optimized keyword search query based on the chat history and the last question + messages = self.get_messages_from_history( + self.QUERY_PROMPT_TEMPLATE, + self.model_name, + history, + user_query, + self.QUERY_PROMPT_FEW_SHOTS, + self.chatgpt_token_limit - len(user_query) + ) + + query_resp = await self.make_chat_completion(messages) + thought_chain["web_search_term"] = query_resp + # STEP 2: Use the search query to get the top web search results + url_snippet_dict = await self.web_search_with_safe_search(query_resp) + content = ', '.join(f'{snippet} | {url}' for url, snippet in url_snippet_dict.items()) + user_query += "Url Sources:\n" + content + "\n\n" + + # Use re.sub to replace anything within square brackets with an empty string + query_resp = re.sub(r'\[.*?\]', '', query_resp) + + messages = self.get_messages_builder( + self.SYSTEM_MESSAGE_CHAT_CONVERSATION.format( + query_term_language=self.query_term_language, + follow_up_questions_prompt=follow_up_questions_prompt, + response_length_prompt=self.get_response_length_prompt_text( + response_length + ), + userPersona=user_persona, + systemPersona=system_persona, + ), + self.model_name, + user_query, + self.RESPONSE_PROMPT_FEW_SHOTS, + max_tokens=4097 - 500 + ) + msg_to_display = '\n\n'.join([str(message) for message in messages]) + # STEP 3: Use the search results to answer the user's question + resp = await self.make_chat_completion(messages) + thought_chain["web_response"] = resp + return { + "data_points": None, + "answer": f"{urllib.parse.unquote(resp)}", + "thoughts": f"Searched for:
{query_resp}

Conversations:
" + msg_to_display.replace('\n', '
'), + "thought_chain": thought_chain, + "work_citation_lookup": {}, + "web_citation_lookup": self.citations + } + + + async def web_search_with_safe_search(self, user_query): + """ + Performs a web search with specified parameters. + + Args: + user_query (str): The query string for the web search. + + Returns: + dict: A dictionary containing URL snippets as values and corresponding URLs as keys. + """ + client = WebSearchClient(AzureKeyCredential(self.bing_search_key), endpoint=self.bing_search_endpoint) + + try: + if self.bing_safe_search: + safe_search = SafeSearch.STRICT + else: + safe_search = SafeSearch.OFF + + web_data = client.web.search( + query=user_query, + answer_count=10, + safe_search=safe_search + ) + + if web_data.web_pages.value: + + url_snippet_dict = {} + for idx, page in enumerate(web_data.web_pages.value): + self.citations[f"url{idx}"] = { + "citation": page.url, + "source_path": "", + "page_number": "0", + } + + url_snippet_dict[page.url] = page.snippet.replace("[", "").replace("]", "") + + return url_snippet_dict + + else: + print("Didn't see any Web data..") + + except Exception as err: + print("Encountered exception. {}".format(err)) + + async def make_chat_completion(self, messages): + """ + Generates a chat completion response using the chat-based language model. + + Args: + messages (List[dict[str, str]]): The list of messages for the chat-based language model. + + Returns: + str: The generated chat completion response. + """ + + chat_completion = await openai.ChatCompletion.acreate( + deployment_id=self.chatgpt_deployment, + model=self.model_name, + messages=messages, + temperature=0.6, + n=1 + ) + return chat_completion.choices[0].message.content + + def get_messages_builder( + self, + system_prompt: str, + model_id: str, + user_conv: str, + few_shots = [dict[str, str]], + max_tokens: int = 4096, + ) -> []: + """ + Construct a list of messages from the chat history and the user's question. + """ + message_builder = MessageBuilder(system_prompt, model_id) + + # Few Shot prompting. Add examples to show the chat what responses we want. It will try to mimic any responses and make sure they match the rules laid out in the system message. + for shot in few_shots: + message_builder.append_message(shot.get('role'), shot.get('content')) + + user_content = user_conv + append_index = len(few_shots) + 1 + + message_builder.append_message(self.USER, user_content, index=append_index) + + messages = message_builder.messages + return messages + + + diff --git a/app/backend/approaches/comparewebwithwork.py b/app/backend/approaches/comparewebwithwork.py new file mode 100644 index 000000000..673181f17 --- /dev/null +++ b/app/backend/approaches/comparewebwithwork.py @@ -0,0 +1,213 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + + +import re +import urllib.parse +from typing import Any, Sequence +import openai +from approaches.chatreadretrieveread import ChatReadRetrieveReadApproach +from approaches.approach import Approach +from azure.search.documents import SearchClient +from core.messagebuilder import MessageBuilder +from azure.storage.blob import ( + BlobServiceClient +) +from core.modelhelper import get_token_limit + +class CompareWebWithWork(Approach): + """ + Approach for comparing and contrasting generative response answers based on web search results vs. based on work search results. + """ + + COMPARATIVE_SYSTEM_MESSAGE_CHAT_CONVERSATION = """You are an Azure OpenAI Completion system. Your persona is {systemPersona}. User persona is {userPersona}. + Compare and contrast the answers provided below from two sources of data. The first source is Web where data is retrieved from an internet search while the second source is Work where internal data indexed using a RAG pattern. + Only explain the differences between the two sources and nothing else. Do not provide personal opinions or assumptions. + Only answer in the language {query_term_language}. + If you cannot find answer in below sources, respond with I am not sure. Do not provide personal opinions or assumptions. + + {follow_up_questions_prompt} + """ + + COMPARATIVE_RESPONSE_PROMPT_FEW_SHOTS = [ + # {"role": Approach.USER ,'content': 'I am looking for comparative information on an answer based on Web search results and want to compare against an answer based on Work internal documents'}, + # {'role': Approach.ASSISTANT, 'content': 'User is looking to compare an answer based on Web search results against an answer based on Work internal documents.'} + {"role": Approach.USER, 'content': 'I am looking to compare and contrast answers obtained from both web search results and internal work documents.'}, + {'role': Approach.ASSISTANT, 'content': 'User wants to compare and contrast responses from both web search results and internal work documents.'}, + {"role": Approach.USER, 'content': "Even if one of the sources doesn't provide a definite answer, I still want to compare and contrast the available information."}, + {'role': Approach.ASSISTANT, 'content': "User emphasizes the importance of comparing and contrasting data even if one of the sources is uncertain about the answer."} + ] + + def __init__( + self, + search_client: SearchClient, + oai_service_name: str, + oai_service_key: str, + chatgpt_deployment: 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, + target_embedding_model: str, + enrichment_appservice_url: str, + target_translation_language: str, + enrichment_endpoint:str, + enrichment_key:str, + azure_ai_translation_domain: str, + use_semantic_reranker: bool + ): + self.search_client = search_client + self.chatgpt_deployment = chatgpt_deployment + 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) + self.escaped_target_model = re.sub(r'[^a-zA-Z0-9_\-.]', '_', target_embedding_model) + self.target_translation_language=target_translation_language + self.enrichment_endpoint=enrichment_endpoint + self.enrichment_key=enrichment_key + self.oai_service_name = oai_service_name + self.oai_service_key = oai_service_key + self.model_name = model_name + self.model_version = model_version + self.enrichment_appservice_url = enrichment_appservice_url + self.azure_ai_translation_domain = azure_ai_translation_domain + self.use_semantic_reranker = use_semantic_reranker + + async def run(self, history: Sequence[dict[str, str]], overrides: dict[str, Any], web_citation_lookup: dict[str, Any], thought_chain: dict[str, Any]) -> Any: + """ + Runs the approach to compare and contrast answers from internal data and Web Search results. + + Args: + history (Sequence[dict[str, str]]): The conversation history. + overrides (dict[str, Any]): The overrides for the approach. + + Returns: + Any: The result of the approach. + """ + chat_rrr_approach = ChatReadRetrieveReadApproach( + self.search_client, + self.oai_service_name, + self.oai_service_key, + self.chatgpt_deployment, + self.source_file_field, + self.content_field, + self.page_number_field, + self.chunk_file_field, + self.content_storage_container, + self.blob_client, + self.query_term_language, + self.model_name, + self.model_version, + self.escaped_target_model, + self.enrichment_appservice_url, + self.target_translation_language, + self.enrichment_endpoint, + self.enrichment_key, + self.azure_ai_translation_domain, + self.use_semantic_reranker + ) + rrr_response = await chat_rrr_approach.run(history, overrides, {}, thought_chain) + + + work_citations = rrr_response.get("work_citation_lookup") + user_query = history[-1].get("user") + web_answer = next((obj['bot'] for obj in reversed(history) if 'bot' in obj), None) + user_persona = overrides.get("user_persona", "") + system_persona = overrides.get("system_persona", "") + response_length = int(overrides.get("response_length") or 1024) + + # Step 2: Contruct the comparative system message with passed Rag response and Bing Search Response from above approach + bing_compare_query = user_query + " Web search results:\n" + web_answer + "\n\n" + "Work internal Documents:\n" + rrr_response.get("answer") + "\n\n" + thought_chain["web_to_work_comparison_query"] = bing_compare_query + messages = self.get_messages_builder( + self.COMPARATIVE_SYSTEM_MESSAGE_CHAT_CONVERSATION.format( + query_term_language=self.query_term_language, + follow_up_questions_prompt='', + response_length_prompt=self.get_response_length_prompt_text( + response_length + ), + userPersona=user_persona, + systemPersona=system_persona, + ), + self.model_name, + bing_compare_query, + self.COMPARATIVE_RESPONSE_PROMPT_FEW_SHOTS, + max_tokens=4097 - 500 + ) + msg_to_display = '\n\n'.join([str(message) for message in messages]) + + # Step 3: Final comparative analysis using OpenAI Chat Completion + bing_compare_resp = await self.make_chat_completion(messages) + + final_response = f"{urllib.parse.unquote(bing_compare_resp)}" + + # Step 4: Append web citations from the Bing Search approach + for idx, url in enumerate(work_citations.keys(), start=1): + final_response += f" [File{idx}]" + thought_chain["web_to_work_comparison_response"] = final_response + + return { + "data_points": None, + "answer": f"{urllib.parse.unquote(final_response)}", + "thoughts": "Searched for:
A Comparitive Analysis

Conversations:
" + msg_to_display.replace('\n', '
'), + "thought_chain": thought_chain, + "work_citation_lookup": work_citations, + "web_citation_lookup": web_citation_lookup + } + + async def make_chat_completion(self, messages) -> str: + """ + Generates a chat completion response using the chat-based language model. + + Args: + messages (List[dict[str, str]]): The list of messages for the chat-based language model. + + Returns: + str: The generated chat completion response. + """ + chat_completion = await openai.ChatCompletion.acreate( + deployment_id=self.chatgpt_deployment, + model=self.model_name, + messages=messages, + temperature=0.6, + n=1 + ) + return chat_completion.choices[0].message.content + + def get_messages_builder(self, system_prompt: str, model_id: str, user_conv: str, few_shots = [dict[str, str]], max_tokens: int = 4096) -> []: + """ + Constructs a list of messages for the chat-based language model. + + Args: + system_prompt (str): The system prompt for the chat-based language model. + model_id (str): The ID of the model to be used for chat-based language model. + user_conv (str): The user conversation for the chat-based language model. + few_shots (List[dict[str, str]]): Few shot prompts for the chat-based language model. + max_tokens (int): The maximum number of tokens allowed for the chat-based language model. + + Returns: + List[dict[str, str]]: The list of messages for the chat-based language model. + """ + message_builder = MessageBuilder(system_prompt, model_id) + + # Few Shot prompting. Add examples to show the chat what responses we want. It will try to mimic any responses and make sure they match the rules laid out in the system message. + for shot in few_shots: + message_builder.append_message(shot.get('role'), shot.get('content')) + + user_content = user_conv + append_index = len(few_shots) + 1 + + message_builder.append_message(self.USER, user_content, index=append_index) + + messages = message_builder.messages + return messages diff --git a/app/backend/approaches/compareworkwithweb.py b/app/backend/approaches/compareworkwithweb.py new file mode 100644 index 000000000..2e56ca151 --- /dev/null +++ b/app/backend/approaches/compareworkwithweb.py @@ -0,0 +1,158 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +import os +from typing import Any, Sequence +import urllib.parse +import openai +from approaches.chatwebretrieveread import ChatWebRetrieveRead +from approaches.approach import Approach +from core.messagebuilder import MessageBuilder +from core.modelhelper import get_token_limit + + +class CompareWorkWithWeb(Approach): + """ + Approach class for performing comparative analysis between Generative answer responses based on Bing search results vs. work internal document search results. + """ + + COMPARATIVE_SYSTEM_MESSAGE_CHAT_CONVERSATION = """You are an Azure OpenAI Completion system. Your persona is {systemPersona}. User persona is {userPersona}. + Compare and contrast the answers provided below from two sources of data. The first source is Work where internal data is indexed using a RAG pattern while the second source Web where results are from an internet search. + Only explain the differences between the two sources and nothing else. Do not provide personal opinions or assumptions. + Only answer in the language {query_term_language}. + If you cannot find answer in below sources, respond with I am not sure. Do not provide personal opinions or assumptions. + + {follow_up_questions_prompt} + """ + + COMPARATIVE_RESPONSE_PROMPT_FEW_SHOTS = [ + {"role": Approach.USER ,'content': 'I am looking to compare and contrast answers obtained from both Work internal documents and Web search results'}, + {'role': Approach.ASSISTANT, 'content': 'User wants to compare and contrast responses from both Work internal documents and Web search results.'}, + {"role": Approach.USER, 'content': "Even if one of the sources doesn't provide a definite answer, I still want to compare and contrast the available information."}, + {'role': Approach.ASSISTANT, 'content': "User emphasizes the importance of comparing and contrasting data even if one of the sources is uncertain about the answer."} + ] + + + web_citations = {} + + def __init__(self, model_name: str, chatgpt_deployment: str, query_term_language: str, bing_search_endpoint: str, bing_search_key: str, bing_safe_search: bool): + """ + Initializes the CompareWorkWithWeb approach. + + Args: + model_name (str): The name of the model to be used for chat-based language model. + chatgpt_deployment (str): The deployment ID of the chat-based language model. + query_term_language (str): The language to be used for querying the data. + bing_search_endpoint (str): The endpoint for the Bing Search API. + bing_search_key (str): The API key for the Bing Search API. + bing_safe_search (bool): The flag to enable or disable safe search for the Bing Search API. + """ + self.name = "CompareWorkWithWeb" + self.model_name = model_name + self.chatgpt_deployment = chatgpt_deployment + self.query_term_language = query_term_language + self.chatgpt_token_limit = get_token_limit(model_name) + self.bing_search_endpoint = bing_search_endpoint + self.bing_search_key = bing_search_key + self.bing_safe_search = bing_safe_search + + async def run(self, history: Sequence[dict[str, str]], overrides: dict[str, Any], work_citation_lookup: dict[str, Any], thought_chain: dict[str, Any]) -> Any: + """ + Runs the comparative analysis between Bing Search Response and Internal Documents. + + Args: + history (Sequence[dict[str, str]]): The chat conversation history. + overrides (dict[str, Any]): Overrides for user and system personas, response length, etc. + + Returns: + Any: The result of the comparative analysis. + """ + # Step 1: Call bing Search Approach for a Bing LLM Response and Citations + chat_bing_search = ChatWebRetrieveRead(self.model_name, self.chatgpt_deployment, self.query_term_language, self.bing_search_endpoint, self.bing_search_key, self.bing_safe_search) + bing_search_response = await chat_bing_search.run(history, overrides, {}, thought_chain) + self.web_citations = bing_search_response.get("web_citation_lookup") + + user_query = history[-1].get("user") + rag_answer=next((obj['bot'] for obj in reversed(history) if 'bot' in obj), None) + user_persona = overrides.get("user_persona", "") + system_persona = overrides.get("system_persona", "") + response_length = int(overrides.get("response_length") or 1024) + + # Step 2: Contruct the comparative system message with passed Rag response and Bing Search Response from above approach + bing_compare_query = user_query + "Work internal documents:\n" + rag_answer + "\n\n" + " Web search results:\n" + bing_search_response.get("answer") + "\n\n" + thought_chain["work_to_web_compairison_query"] = bing_compare_query + messages = self.get_messages_builder( + self.COMPARATIVE_SYSTEM_MESSAGE_CHAT_CONVERSATION.format( + query_term_language=self.query_term_language, + follow_up_questions_prompt='', + response_length_prompt=self.get_response_length_prompt_text( + response_length + ), + userPersona=user_persona, + systemPersona=system_persona, + ), + self.model_name, + bing_compare_query, + self.COMPARATIVE_RESPONSE_PROMPT_FEW_SHOTS, + max_tokens=4097 - 500 + ) + msg_to_display = '\n\n'.join([str(message) for message in messages]) + + # Step 3: Final comparative analysis using OpenAI Chat Completion + compare_resp = await self.make_chat_completion(messages) + + final_response = f"{urllib.parse.unquote(compare_resp)}" + + # Step 4: Append web citations from the Bing Search approach + for idx, url in enumerate(self.web_citations.keys(), start=1): + final_response += f" [url{idx}]" + thought_chain["work_to_web_compairison_response"] = final_response + + return { + "data_points": None, + "answer": f"{urllib.parse.unquote(final_response)}", + "thoughts": "Searched for:
A Comparitive Analysis

Conversations:
" + msg_to_display.replace('\n', '
'), + "thought_chain": thought_chain, + "work_citation_lookup": work_citation_lookup, + "web_citation_lookup": self.web_citations + } + + async def make_chat_completion(self, messages): + """ + Generates a chat completion response using the chat-based language model. + + Returns: + str: The generated chat completion response. + """ + chat_completion = await openai.ChatCompletion.acreate( + deployment_id=self.chatgpt_deployment, + model=self.model_name, + messages=messages, + temperature=0.6, + n=1 + ) + return chat_completion.choices[0].message.content + + def get_messages_builder(self, system_prompt: str, model_id: str, user_conv: str, few_shots = [dict[str, str]], max_tokens: int = 4096,) -> []: + """ + Constructs a list of messages for the chat-based language model. + + Returns: + List[dict[str, str]]: The list of messages for the chat-based language model. + """ + message_builder = MessageBuilder(system_prompt, model_id) + + # Few Shot prompting. Add examples to show the chat what responses we want. It will try to mimic any responses and make sure they match the rules laid out in the system message. + for shot in few_shots: + message_builder.append_message(shot.get('role'), shot.get('content')) + + user_content = user_conv + append_index = len(few_shots) + 1 + + message_builder.append_message(self.USER, user_content, index=append_index) + + messages = message_builder.messages + return messages + + + diff --git a/app/backend/approaches/gpt_direct_approach.py b/app/backend/approaches/gpt_direct_approach.py new file mode 100644 index 000000000..cfca7f494 --- /dev/null +++ b/app/backend/approaches/gpt_direct_approach.py @@ -0,0 +1,152 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +import json +import re +import logging +import urllib.parse +from datetime import datetime, timedelta +from typing import Any, Sequence + +import openai +from approaches.approach import Approach + +from text import nonewlines +from datetime import datetime, timedelta + +from text import nonewlines + +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, +# then constructs a prompt with them, and then uses OpenAI to generate +# an completion (answer) with that prompt. + +class GPTDirectApproach(Approach): + + # Chat roles + SYSTEM = "system" + USER = "user" + ASSISTANT = "assistant" + + system_message_chat_conversation = """You are an Azure OpenAI Completion system. Your persona is {systemPersona} who helps users interact with a Large Language Model. {response_length_prompt} + User persona is {userPersona}. You are having a conversation with a user and you need to provide a response. + + {follow_up_questions_prompt} + {injected_prompt} + + """ + follow_up_questions_prompt_content = """ + Generate three very brief follow-up questions that the user would likely ask next about their previous chat context. Use triple angle brackets to reference the questions, e.g. <<>>. Try not to repeat questions that have already been asked. + Only generate questions and do not generate any text before or after the questions, such as 'Next Questions' + """ + + query_prompt_template = """Below is a history of the conversation so far, and a new question asked by the user that needs to be answered. + Generate a search query based on the conversation and the new question. Treat each search term as an individual keyword. Do not combine terms in quotes or brackets. + Do not include cited sources e.g info or doc in the search query terms. + Do not include any text inside [] or <<<>>> in the search query terms. + Do not include any special characters like '+'. + If the question is not in {query_term_language}, translate the question to {query_term_language} before generating the search query. + If you cannot generate a search query, return just the number 0. + """ + + #Few Shot prompting for Keyword Search Query + query_prompt_few_shots = [ + {'role' : USER, 'content' : 'What are the future plans for public transportation development?' }, + {'role' : ASSISTANT, 'content' : 'Future plans for public transportation' }, + {'role' : USER, 'content' : 'how much renewable energy was generated last year?' }, + {'role' : ASSISTANT, 'content' : 'Renewable energy generation last year' } + ] + + #Few Shot prompting for Response. This will feed into Chain of thought system message. + response_prompt_few_shots = [ + {'role': USER, 'content': 'What steps are being taken to promote energy conservation?'}, + {'role': USER, '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, + oai_service_name: str, + oai_service_key: str, + chatgpt_deployment: str, + query_term_language: str, + model_name: str, + model_version: str, + azure_openai_endpoint: str + ): + self.chatgpt_deployment = chatgpt_deployment + self.query_term_language = query_term_language + self.chatgpt_token_limit = get_token_limit(model_name) + + openai.api_base = azure_openai_endpoint + openai.api_type = 'azure' + openai.api_key = oai_service_key + + self.model_name = model_name + self.model_version = model_version + + # def run(self, history: list[dict], overrides: dict) -> any: + async def run(self, history: Sequence[dict[str, str]], overrides: dict[str, Any], citation_lookup: dict[str, Any], thought_chain: dict[str, Any]) -> Any: + user_persona = overrides.get("user_persona", "") + system_persona = overrides.get("system_persona", "") + response_length = int(overrides.get("response_length") or 1024) + + user_q = 'Generate response for: ' + history[-1]["user"] + thought_chain["user_query"] = history[-1]["user"] + #Generate the follow up prompt to be sent to the GPT model + follow_up_questions_prompt = ( + self.follow_up_questions_prompt_content + if overrides.get("suggest_followup_questions") + else "" + ) + + system_message = self.system_message_chat_conversation.format( + injected_prompt="", + follow_up_questions_prompt=follow_up_questions_prompt, + response_length_prompt=self.get_response_length_prompt_text( + response_length + ), + userPersona=user_persona, + systemPersona=system_persona, + ) + + #Generate a contextual and content-specific answer using the search results and chat history. + #Added conditional block to use different system messages for different models. + messages = self.get_messages_from_history( + system_message, + self.model_name, + history, + history[-1]["user"] + "\n\n", + self.response_prompt_few_shots, + max_tokens=self.chatgpt_token_limit - 500 + ) + + 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, + n=1 + ) + + #Format the response + msg_to_display = '\n\n'.join([str(message) for message in messages]) + thought_chain["ungrounded_response"] = urllib.parse.unquote(chat_completion.choices[0].message.content) + + return { + "data_points": [], + "answer": f"{urllib.parse.unquote(chat_completion.choices[0].message.content)}", + "thoughts": f"Searched for:
{user_q}

Conversations:
" + msg_to_display.replace('\n', '
'), + "thought_chain": thought_chain, + "work_citation_lookup": {}, + "web_citation_lookup": {} + } + diff --git a/app/backend/approaches/mathassistant.py b/app/backend/approaches/mathassistant.py new file mode 100644 index 000000000..40133e5ff --- /dev/null +++ b/app/backend/approaches/mathassistant.py @@ -0,0 +1,274 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +#Turn warnings off +#from st_pages import Page, show_pages, add_page_title +import warnings +warnings.filterwarnings('ignore') +import os +# import openai +from dotenv import load_dotenv + +#-------------------------------------------------------------------------- +#variables needed for testing +OPENAI_API_TYPE = "azure" +OPENAI_API_VERSION = "2023-06-01-preview" +OPENAI_API_BASE = " " +OPENAI_API_KEY = " " +OPENAI_DEPLOYMENT_NAME = " " +MODEL_NAME = " " +AZURE_OPENAI_ENDPOINT = ' ' +AZURE_OPENAI_SERVICE_KEY = ' ' + +os.environ["OPENAI_API_TYPE"] = OPENAI_API_TYPE +os.environ["OPENAI_API_VERSION"] = OPENAI_API_VERSION + + +load_dotenv() + + +azure_openai_chatgpt_deployment = os.getenv("AZURE_OPENAI_CHATGPT_DEPLOYMENT") + +deployment_name = azure_openai_chatgpt_deployment +OPENAI_DEPLOYMENT_NAME = deployment_name + + +OPENAI_DEPLOYMENT_NAME = azure_openai_chatgpt_deployment +from langchain.chat_models import AzureChatOpenAI +from langchain.schema import HumanMessage +from langchain.agents import initialize_agent, load_tools, AgentType +from langchain.prompts import ChatPromptTemplate + + +model = AzureChatOpenAI( + openai_api_version=OPENAI_API_VERSION , + deployment_name=OPENAI_DEPLOYMENT_NAME) + +#-------------------------------------------------------------------------------------------------------------------------------------------------- +# Addition of custom tools + +#1. Tool to calculate pythagorean theorem + +from langchain.tools import BaseTool +from typing import Optional +from math import sqrt, cos, sin +from typing import Union +desc = ( + "use this tool when you need to calculate the length of a hypotenuse" + "given one or two sides of a triangle and/or an angle (in degrees). " + "To use the tool, you must provide at least two of the following parameters " + "['adjacent_side', 'opposite_side', 'angle']." +) + +class PythagorasTool(BaseTool): + name = "Hypotenuse calculator" + description = desc + + def _run( + self, + adjacent_side: Optional[Union[int, float]] = None, + opposite_side: Optional[Union[int, float]] = None, + angle: Optional[Union[int, float]] = None + ): + # check for the values we have been given + if adjacent_side and opposite_side: + return sqrt(float(adjacent_side)**2 + float(opposite_side)**2) + elif adjacent_side and angle: + return adjacent_side / cos(float(angle)) + elif opposite_side and angle: + return opposite_side / sin(float(angle)) + else: + return "Could not calculate the hypotenuse of the triangle. Need two or more of `adjacent_side`, `opposite_side`, or `angle`." + + def _arun(self, query: str): + raise NotImplementedError("This tool does not support async") + +tools = [PythagorasTool()] + +#________________________________________ + +#2.tool to calculate the area of a circle +from math import pi + + + +class CircumferenceTool(BaseTool): + name = "Circumference calculator" + description = "use this tool when you need to calculate a circumference using the radius of a circle" + + def _run(self, radius: Union[int, float]): + return float(radius)*2.0*pi + + def _arun(self, radius: int): + raise NotImplementedError("This tool does not support async") + + +tools = [CircumferenceTool()] + +#add math module from Lanhgchain + +tools = load_tools(["llm-math","wikipedia"], llm=model) + + +PREFIX = """Act as a math tutor that helps students solve a wide array of mathematical challenges, including arithmetic problems, algebraic equations, geometric proofs, calculus, and statistical analysis, as well as word problems. +Students will ask you math questions. When faced with math-related questions, always refer to your tools first. LLM-Math and wikipedia are tools that can help you solve math problems. +If you cannot find a solution through your tools, then offer explanation or methodologies on how to tackle the problem on your own. + +In handling math queries, try using your tools initially. If no solution is found, then attempt to solve the problem on your own. +""" + + +# # Initialize the agent +zero_shot_agent_math = initialize_agent( + agent=AgentType.ZERO_SHOT_REACT_DESCRIPTION, + tools=tools, + llm=model, + verbose=True, + max_iterations=10, + max_execution_time=120, + handle_parsing_errors=True, + return_intermediate_steps=True, + agent_kwargs={ 'prefix':PREFIX}) + +# Prompt template for Zeroshot agent + +async def stream_agent_responses(question): + zero_shot_agent_math = initialize_agent( + agent="zero-shot-react-description", + tools=tools, + llm=model, + verbose=True, + max_iterations=10, + max_execution_time=120, + handle_parsing_errors=True, + agent_kwargs={ 'prefix':PREFIX} + ) + for chunk in zero_shot_agent_math.stream({"input": question}): + if "actions" in chunk: + for action in chunk["actions"]: + yield f'data: Calling Tool: `{action.tool}` with input `{action.tool_input}`\n\n' + yield f'data: Processing...: {action.log} \n\n' + elif "steps" in chunk: + for step in chunk["steps"]: + yield f'data: Tool Result: `{step.observation}` \n\n' + elif "output" in chunk: + output = f'data: Final Output: `{chunk["output"]}`\n\n' + yield output + yield (f'event: end\ndata: Stream ended\n\n') + return + else: + raise ValueError() + + + +# function to stream agent response +def process_agent_scratch_pad( question): + messages = [] + for chunk in zero_shot_agent_math.stream({"input": question}): + if "actions" in chunk: + for action in chunk["actions"]: + messages.append(f"Calling Tool: `{action.tool}` with input `{action.tool_input}`\n") + messages.append(f'Processing: {action.log} \n') + elif "steps" in chunk: + for step in chunk["steps"]: + messages.append(f"Tool Result: `{step.observation}`\n") + elif "output" in chunk: + messages.append(f'Final Output: {chunk["output"]}') + else: + raise ValueError() + return messages + +#Function to stream final output +def process_agent_response( question): + stream = zero_shot_agent_math.stream({"input": question}) + if stream: + for chunk in stream: + if "output" in chunk: + output = f'Final Output: {chunk["output"]}' + return output + + +#Function to process clues +def generate_response(question): + model = AzureChatOpenAI( + openai_api_version=OPENAI_API_VERSION , + deployment_name=OPENAI_DEPLOYMENT_NAME) + prompt_template = ChatPromptTemplate.from_template(template=prompt) + messages = prompt_template.format_messages( + question=question + ) + response = model(messages) + return response.content + +#prompt for clues + +prompt = """ +Act as a tutor that helps students solve math and arithmetic reasoning questions. +Students will ask you questions. Think step-by-step to reach the answer. Write down each reasoning step. +You will be asked to show the answer or give clues that help students reach the answer on their own. +Always list clues under Clues keyword + +Here are a few example questions with expected answer and clues: + +Question: John has 2 houses. Each house has 3 bedrooms and there are 2 windows in each bedroom. +Each house has 1 kitchen with 2 windows. Also, each house has 5 windows that are not in the bedrooms or kitchens. +How many windows are there in John's houses? +Answer: Each house has 3 bedrooms with 2 windows each, so that's 3 * 2 = 6 windows per house. \ +Each house also has 1 kitchen with 2 windows, so that's 2 * 1 = 2 windows per house. \ +Each house has 5 windows that are not in the bedrooms or kitchens, so that's 5 x 1 = 5 windows per house. \ +In total, each house has 6 + 2 + 5 = 13 windows. \ +Since John has 2 houses, he has a total of 2 * 13 = 26 windows. The answer is 26. +Clues: 1. Find the number of bedroom windows, kitchen windows, and other windows separately \ +2. Add them together to find the total number of windows at each house \ +3. Find the total number of windows for all the houses. + +Question: There are 15 trees in the grove. Grove workers will plant trees in the grove today. After they are done, there will be 21 trees. How many trees did the grove workers plant today? +Answer: There are originally 15 trees. After the workers plant some trees, \ +there are 21 trees. So the workers planted 21 - 15 = 6 trees. The answer is 6.", +Clues: 1. Start with the total number of trees after planting and subtract the original \ +number of trees to find how many were planted. \ +2. Use subtraction to find the difference between the two numbers. + +Question: Leah had 32 chocolates and her sister had 42. If they ate 35, how many pieces do they have left in total? +Answer: Originally, Leah had 32 chocolates. Her sister had 42. \ +So in total they had 32 + 42 = 74. After eating 35, they \ +had 74 - 35 = 39. The answer is 39. +Clues: 1. Start with the total number of chocolates they had. \ +2. Subtract the number of chocolates they ate. + +Question: Find the derivative of f(x) = x^2 with respect to x. +Answer: The derivative of f(x) = x^2 with respect to x is f'(x) = 2x. +Clues: +1. Use the power rule for differentiation: d/dx(x^n) = nx^(n-1). +2. Apply the power rule to each term in the function. + +Question: Find the integral of f(x) = 3x^2 with respect to x. +Answer: The integral of f(x) = 3x^2 with respect to x is F(x) = x^3 + C, where C is the constant of integration. +Clues: +1. Use the power rule for integration: ∫x^n dx = (1/(n+1)) * x^(n+1) + C. +2. Apply the power rule to each term in the function. +3. Add the constant of integration, C, to the result. + +Question: Find the limit of f(x) = (x^2 - 1) / (x - 1) as x approaches 1. +Answer: The limit of f(x) = (x^2 - 1) / (x - 1) as x approaches 1 is 2. +Clues: +1. Try direct substitution first. +2. If direct substitution results in an indeterminate form (0/0 or ∞/∞), try factoring or rationalizing the expression. +3. If factoring or rationalizing doesn't work, try simplifying the expression using algebraic manipulation. + +Question: {question} + +""" + + + + + + + + + + + + + diff --git a/app/backend/approaches/tabulardataassistant.py b/app/backend/approaches/tabulardataassistant.py new file mode 100644 index 000000000..d1c1d1c5c --- /dev/null +++ b/app/backend/approaches/tabulardataassistant.py @@ -0,0 +1,136 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +import base64 +import os +import glob +import re +import warnings +from PIL import Image +import io +import pandas as pd +from langchain.chat_models import ChatOpenAI +from langchain_experimental.agents.agent_toolkits import create_pandas_dataframe_agent +from langchain.agents.agent_types import AgentType +from langchain.chat_models import AzureChatOpenAI +from langchain.agents import load_tools +import matplotlib.pyplot as plt +import tempfile +warnings.filterwarnings('ignore') +from dotenv import load_dotenv + + + +#-------------------------------------------------------------------------- +#variables needed for testing +OPENAI_API_TYPE = "azure" +OPENAI_API_VERSION = "2023-07-01-preview" +# OPENAI_API_VERSION = "2023-06-01-preview" +OPENAI_API_BASE = " " +OPENAI_API_KEY = " " +OPENAI_DEPLOYMENT_NAME = " " +MODEL_NAME = " " +AZURE_OPENAI_ENDPOINT = ' ' +AZURE_OPENAI_SERVICE_KEY = ' ' + +os.environ["OPENAI_API_TYPE"] = OPENAI_API_TYPE +os.environ["OPENAI_API_VERSION"] = OPENAI_API_VERSION + + +load_dotenv() + +#Environment variables when integrated into the app +#_________________________________________________________________________ + + + +azure_openai_chatgpt_deployment = os.getenv("AZURE_OPENAI_CHATGPT_DEPLOYMENT") + + +deployment_name = azure_openai_chatgpt_deployment +OPENAI_DEPLOYMENT_NAME = deployment_name + + +# Page title + + +dffinal = None +pdagent = None +agent_imgs = [] + +def refreshagent(): + global pdagent + pdagent = None +def get_image_data(image_path): + with Image.open(image_path) as img: + img_byte_arr = io.BytesIO() + img.save(img_byte_arr, format='PNG') + img_byte_arr = img_byte_arr.getvalue() + img_base64 = base64.b64encode(img_byte_arr) + return img_base64.decode('utf-8') + +def save_chart(query): + temp_dir = tempfile.gettempdir() + q_s = f""" you are CSV Assistant, you are a dataframe ally. you analyze every row, addressing all queries with unwavering precision. + You DO NOT answer based on subset of dataframe or top 5 or based on head() output. You need to look at all rows and then answer questions. data is case insensitive. + If any charts or graphs or plots were created save them in the {temp_dir} directory + + Remember, you can handle both singular and plural forms of queries. For example: + - If you ask "How many thinkpads do we have?" or "How many thinkpad do we have?", you will address both forms in the same manner. + - Similarly, for other queries involving counts, averages, or any other operations. + + """ + + query += ' . '+ q_s + return query + +def get_images_in_temp(): + temp_dir = tempfile.gettempdir() + image_files = glob.glob(os.path.join(temp_dir, '*.[pjJ][npNP][gG]*')) + image_data = [get_image_data(file) for file in image_files] + + # Delete the files after reading them + for file in image_files: + os.remove(file) + + return image_data + +def save_df(dff): + global dffinal + dffinal = dff + +# function to stream agent response +def process_agent_scratch_pad(question, df): + chat = AzureChatOpenAI( + openai_api_version=OPENAI_API_VERSION, + deployment_name=OPENAI_DEPLOYMENT_NAME) + question = save_chart(question) + pdagent = create_pandas_dataframe_agent(chat, df, verbose=True,handle_parsing_errors=True,agent_type=AgentType.OPENAI_FUNCTIONS) + for chunk in pdagent.stream({"input": question}): + if "actions" in chunk: + for action in chunk["actions"]: + yield f'data: Calling Tool: `{action.tool}` with input `{action.tool_input}`\n' + yield f'data: \nProcessing...: {action.log}\n' + elif "steps" in chunk: + for step in chunk["steps"]: + yield f'data: Tool Result: `{step.observation}` \n\n' + elif "output" in chunk: + output = chunk["output"].replace("\n", "
") + yield f'data: Final Output: {output}\n\n' + yield (f'event: end\ndata: Stream ended\n\n') + return + else: + raise ValueError() + +#Function to stream final output +def process_agent_response(question): + question = save_chart(question) + chat = AzureChatOpenAI( + openai_api_version=OPENAI_API_VERSION, + deployment_name=OPENAI_DEPLOYMENT_NAME) + + pdagent = create_pandas_dataframe_agent(chat, dffinal, verbose=True,handle_parsing_errors=True,agent_type=AgentType.OPENAI_FUNCTIONS) + for chunk in pdagent.stream({"input": question}): + if "output" in chunk: + output = f'Final Output: ```{chunk["output"]}```' + return output \ No newline at end of file diff --git a/app/backend/pyvenv.cfg b/app/backend/pyvenv.cfg index da54a253e..0dc0b53ab 100644 --- a/app/backend/pyvenv.cfg +++ b/app/backend/pyvenv.cfg @@ -1,3 +1,3 @@ home = /workspaces/info-asst/.venv/bin include-system-site-packages = false -version = 3.10.10 +version = 3.10.12 diff --git a/app/backend/requirements.txt b/app/backend/requirements.txt index 518909a03..34684f5ab 100644 --- a/app/backend/requirements.txt +++ b/app/backend/requirements.txt @@ -9,4 +9,17 @@ openai==0.27.0 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 +tiktoken == 0.4.0 +fastapi == 0.109.1 +fastapi-utils == 0.2.1 +uvicorn == 0.23.2 +numexpr == 2.10.0 +langchain-experimental==0.0.49 +microsoft-bing-websearch==1.0.0 +tabulate==0.9.0 +matplotlib==3.8.3 +python-dotenv==1.0.1 +pandas==2.2.1 +python-multipart==0.0.9 +Pillow==10.3.0 +wikipedia==1.4.0 diff --git a/app/enrichment/app.py b/app/enrichment/app.py index 318392118..a3e38d2bd 100644 --- a/app/enrichment/app.py +++ b/app/enrichment/app.py @@ -12,8 +12,6 @@ import base64 import requests import random -from urllib.parse import unquote -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 @@ -28,7 +26,8 @@ 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 +from azure.storage.blob import BlobServiceClient +from urllib.parse import unquote # === ENV Setup === @@ -45,12 +44,11 @@ "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_ENDPOINT": None, "AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME": None, "AZURE_SEARCH_INDEX": None, "AZURE_SEARCH_SERVICE_KEY": None, @@ -59,12 +57,9 @@ "TARGET_EMBEDDINGS_MODEL": None, "EMBEDDING_VECTOR_SIZE": None, "AZURE_SEARCH_SERVICE_ENDPOINT": None, - "AZURE_BLOB_STORAGE_ENDPOINT": None, - "IS_GOV_CLOUD_DEPLOYMENT": None + "AZURE_BLOB_STORAGE_ENDPOINT": None } -str_to_bool = {'true': True, 'false': False} - for key, value in ENV.items(): new_value = os.getenv(key) if new_value is not None: @@ -73,15 +68,11 @@ raise ValueError(f"Environment variable {key} not set") search_creds = AzureKeyCredential(ENV["AZURE_SEARCH_SERVICE_KEY"]) - -if str_to_bool.get(ENV["IS_GOV_CLOUD_DEPLOYMENT"].lower()): - openai.api_base = "https://" + ENV["AZURE_OPENAI_SERVICE"] + ".openai.azure.us/" -else: - openai.api_base = "https://" + ENV["AZURE_OPENAI_SERVICE"] + ".openai.azure.com/" - + +openai.api_base = ENV["AZURE_OPENAI_ENDPOINT"] openai.api_type = "azure" openai.api_key = ENV["AZURE_OPENAI_SERVICE_KEY"] -openai.api_version = "2023-06-01-preview" +openai.api_version = "2023-12-01-preview" class AzOAIEmbedding(object): """A wrapper for a Azure OpenAI Embedding model""" @@ -124,8 +115,6 @@ def encode(self, texts) -> None: ) 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() @@ -265,26 +254,6 @@ def index_sections(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 = [unquote(tags)] - else: - tags_list = [unquote(tag) for tag in 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") def startup_event(): poll_thread = threading.Thread(target=poll_queue_thread) @@ -296,6 +265,34 @@ def poll_queue_thread(): poll_queue() time.sleep(5) +def get_tags(blob_path): + """ Retrieves tags from the upload container blob + """ + # Remove the container prefix + path_parts = blob_path.split('/') + blob_path = '/'.join(path_parts[1:]) + + blob_service_client = BlobServiceClient.from_connection_string(ENV["BLOB_CONNECTION_STRING"]) + # container_client = blob_service_client.get_container_client(ENV["AZURE_BLOB_STORAGE_CONTAINER"]) + blob_client = blob_service_client.get_blob_client( + container=ENV["AZURE_BLOB_STORAGE_UPLOAD_CONTAINER"], + blob=blob_path) + + + # blob_client = container_client.get_blob_client( + # blob_client = container_client.get_blob_client(container_client=container_client, blob=blob_path) + blob_properties = blob_client.get_blob_properties() + tags = blob_properties.metadata.get("tags") + if tags != '' and tags is not None: + if isinstance(tags, str): + tags_list = [unquote(tag.strip()) for tag in tags.split(",")] + else: + tags_list = [unquote(tag.strip()) for tag in tags] + else: + tags_list = [] + return tags_list + + def poll_queue() -> None: """Polls the queue for messages and embeds them""" @@ -329,20 +326,23 @@ def poll_queue() -> None: 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 = [] + + # get tags to apply to the chunk + tag_list = get_tags(blob_path) # 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)}") + statusLog.update_document_state( blob_path, f"Indexing {i+1}/{len(chunks)}", State.INDEXING) + # statusLog.update_document_state( blob_path, f"Indexing {i+1}/{len(chunks)}", State.PROCESSING # open the file and extract the content blob_path_plus_sas = utilities_helper.get_blob_and_sas( ENV["AZURE_BLOB_STORAGE_CONTAINER"] + '/' + chunk.name) @@ -366,12 +366,15 @@ def poll_queue() -> None: 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"]) - + try: + # try first to read the embedding from the chunk, in case it was already created + embedding_data = chunk_dict['contentVector'] + except KeyError: + # create embedding + embedding = embed_texts(target_embeddings_model, [text]) + embedding_data = embedding['data'] + + # Prepare the index schema based representation of the chunk with the embedding index_chunk = {} index_chunk['id'] = statusLog.encode_document_id(chunk.name) index_chunk['processed_datetime'] = f"{chunk_dict['processed_datetime']}+00:00" @@ -389,9 +392,15 @@ def poll_queue() -> None: index_chunk['entities'] = chunk_dict["entities"] index_chunk['key_phrases'] = chunk_dict["key_phrases"] index_chunks.append(index_chunk) + + # write the updated chunk, with embedding to storage in case of failure + chunk_dict['contentVector'] = embedding_data + json_str = json.dumps(chunk_dict, indent=2, ensure_ascii=False) + block_blob_client = blob_service_client.get_blob_client(container=ENV["AZURE_BLOB_STORAGE_CONTAINER"], blob=chunk.name) + block_blob_client.upload_blob(json_str, overwrite=True) i += 1 - # push batch of content to index + # push batch of content to index, rather than each individual chunk if i % 200 == 0: index_sections(index_chunks) index_chunks = [] @@ -424,7 +433,7 @@ def poll_queue() -> None: 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)}.', + statusLog.upsert_document(blob_path, f'Message requeued to embeddings queue, attempt {str(requeue_count)}. Visible in {str(backoff)} seconds. Error: {str(error)}.', StatusClassification.ERROR, State.QUEUED) else: diff --git a/app/enrichment/pyvenv.cfg b/app/enrichment/pyvenv.cfg index d9e892325..f87ec4e4c 100644 --- a/app/enrichment/pyvenv.cfg +++ b/app/enrichment/pyvenv.cfg @@ -1,3 +1,3 @@ home = /workspaces/info-asst/.venv/bin include-system-site-packages = false -version = 3.10.10 \ No newline at end of file +version = 3.10.12 \ No newline at end of file diff --git a/app/frontend/package.json b/app/frontend/package.json index bcff65791..7b4548cc8 100644 --- a/app/frontend/package.json +++ b/app/frontend/package.json @@ -9,27 +9,36 @@ "watch": "tsc && vite build --watch" }, "dependencies": { + "@azure/storage-blob": "^12.13.0", "@fluentui/react": "^8.110.7", "@fluentui/react-icons": "^2.0.195", "@react-spring/web": "^9.7.1", + "classnames": "^2.3.1", "dompurify": "^3.0.1", + "nanoid": "3.3.4", + "papaparse": "^5.4.1", + "prop-types": "15.8.1", "react": "^18.2.0", + "react-bootstrap": "2.7.4", "react-dom": "^18.2.0", + "react-draggable": "^4.4.6", + "react-markdown": "^9.0.1", "react-router-dom": "^6.8.1", - "@azure/storage-blob": "^12.13.0", - "classnames": "^2.3.1", - "nanoid": "3.3.4", - "prop-types": "15.8.1", - "react-bootstrap": "^2.7.4" + "react-switch": "^7.0.0", + "react-table": "^7.8.0", + "remark-gfm": "^3.0.0" }, "devDependencies": { "@types/dompurify": "^2.4.0", + "@types/papaparse": "^5.3.14", "@types/react": "^18.0.27", "@types/react-dom": "^18.0.10", + "@types/react-resizable": "^3.0.7", + "@types/react-sticky": "^6.0.6", "@vitejs/plugin-react": "^4.2.1", + "postcss-nesting": "^11.2.2", "prettier": "^2.8.3", "typescript": "^4.9.3", - "vite": "^5.0.10", - "postcss-nesting": "^11.2.2" + "vite": "^5.0.10" } } diff --git a/app/frontend/src/api/api.ts b/app/frontend/src/api/api.ts index b306755a1..47ff7a49e 100644 --- a/app/frontend/src/api/api.ts +++ b/app/frontend/src/api/api.ts @@ -1,42 +1,25 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. -import { AskRequest, AskResponse, ChatRequest, BlobClientUrlResponse, AllFilesUploadStatus, GetUploadStatusRequest, GetInfoResponse, ActiveCitation, GetWarningBanner, StatusLogEntry, StatusLogResponse, ApplicationTitle, GetTagsResponse } from "./models"; +import { ChatResponse, + ChatRequest, + BlobClientUrlResponse, + AllFilesUploadStatus, + GetUploadStatusRequest, + GetInfoResponse, + ActiveCitation, + GetWarningBanner, + StatusLogEntry, + StatusLogResponse, + ApplicationTitle, + GetTagsResponse, + DeleteItemRequest, + ResubmitItemRequest, + GetFeatureFlagsResponse, + getMaxCSVFileSizeType, + } from "./models"; -export async function askApi(options: AskRequest): Promise { - const response = await fetch("/ask", { - method: "POST", - headers: { - "Content-Type": "application/json" - }, - body: JSON.stringify({ - question: options.question, - approach: options.approach, - overrides: { - semantic_ranker: options.overrides?.semanticRanker, - semantic_captions: options.overrides?.semanticCaptions, - top: options.overrides?.top, - temperature: options.overrides?.temperature, - prompt_template: options.overrides?.promptTemplate, - prompt_template_prefix: options.overrides?.promptTemplatePrefix, - prompt_template_suffix: options.overrides?.promptTemplateSuffix, - exclude_category: options.overrides?.excludeCategory, - user_persona: options.overrides?.userPersona, - system_persona: options.overrides?.systemPersona, - ai_persona: options.overrides?.aiPersona, - } - }) - }); - - const parsedResponse: AskResponse = await response.json(); - if (response.status > 299 || !response.ok) { - throw Error(parsedResponse.error || "Unknown error"); - } - - return parsedResponse; -} - -export async function chatApi(options: ChatRequest): Promise { +export async function chatApi(options: ChatRequest): Promise { const response = await fetch("/chat", { method: "POST", headers: { @@ -55,6 +38,7 @@ export async function chatApi(options: ChatRequest): Promise { prompt_template_suffix: options.overrides?.promptTemplateSuffix, exclude_category: options.overrides?.excludeCategory, suggest_followup_questions: options.overrides?.suggestFollowupQuestions, + byPassRAG: options.overrides?.byPassRAG, user_persona: options.overrides?.userPersona, system_persona: options.overrides?.systemPersona, ai_persona: options.overrides?.aiPersona, @@ -62,11 +46,13 @@ export async function chatApi(options: ChatRequest): Promise { response_temp: options.overrides?.responseTemp, selected_folders: options.overrides?.selectedFolders, selected_tags: options.overrides?.selectedTags - } + }, + citation_lookup: options.citation_lookup, + thought_chain: options.thought_chain }) }); - const parsedResponse: AskResponse = await response.json(); + const parsedResponse: ChatResponse = await response.json(); if (response.status > 299 || !response.ok) { throw Error(parsedResponse.error || "Unknown error"); } @@ -102,7 +88,9 @@ export async function getAllUploadStatus(options: GetUploadStatusRequest): Promi }, body: JSON.stringify({ timeframe: options.timeframe, - state: options.state as string + state: options.state as string, + folder: options.folder as string, + tag: options.tag as string }) }); @@ -114,6 +102,267 @@ export async function getAllUploadStatus(options: GetUploadStatusRequest): Promi return results; } +export async function deleteItem(options: DeleteItemRequest): Promise { + try { + const response = await fetch("/deleteItems", { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ + path: options.path + }) + }); + if (!response.ok) { + // If the response is not ok, throw an error + const errorResponse = await response.json(); + throw new Error(errorResponse.error || "Unknown error"); + } + // If the response is ok, return true + return true; + } catch (error) { + console.error("Error during deleteItem:", error); + return false; + } +} + + +export async function resubmitItem(options: ResubmitItemRequest): Promise { + try { + const response = await fetch("/resubmitItems", { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ + path: options.path + }) + }); + if (!response.ok) { + // If the response is not ok, throw an error + const errorResponse = await response.json(); + throw new Error(errorResponse.error || "Unknown error"); + } + // If the response is ok, return true + return true; + } catch (error) { + console.error("Error during deleteItem:", error); + return false; + } +} + + +export async function getFolders(): Promise { + const response = await fetch("/getfolders", { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ + }) + }); + + const parsedResponse: any = await response.json(); + if (response.status > 299 || !response.ok) { + throw Error(parsedResponse.error || "Unknown error"); + } + // Assuming parsedResponse is the array of strings (folder names) we want + // Check if it's actually an array and contains strings + if (Array.isArray(parsedResponse) && parsedResponse.every(item => typeof item === 'string')) { + return parsedResponse; + } else { + throw new Error("Invalid response format"); + } +} + + +export async function getTags(): Promise { + const response = await fetch("/gettags", { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ + }) + }); + + const parsedResponse: any = await response.json(); + if (response.status > 299 || !response.ok) { + throw Error(parsedResponse.error || "Unknown error"); + } + // Assuming parsedResponse is the array of strings (folder names) we want + // Check if it's actually an array and contains strings + if (Array.isArray(parsedResponse) && parsedResponse.every(item => typeof item === 'string')) { + return parsedResponse; + } else { + throw new Error("Invalid response format"); + } +} + + +export async function getHint(question: string): Promise { + const response = await fetch(`/getHint?question=${encodeURIComponent(question)}`, { + method: "GET", + headers: { + "Content-Type": "application/json" + } + }); + + const parsedResponse: String = await response.json(); + if (response.status > 299 || !response.ok) { + throw Error("Unknown error"); + } + + return parsedResponse; +} + + +export function streamData(question: string): EventSource { + const encodedQuestion = encodeURIComponent(question); + const eventSource = new EventSource(`/stream?question=${encodedQuestion}`); + return eventSource; +} + + +export async function streamTdData(question: string, file: File): Promise { + let lastError; + const formData = new FormData(); + formData.append('csv', file); + + const response = await fetch('/posttd', { + method: 'POST', + body: formData, + }); + + const parsedResponse: String = await response.text(); + if (response.status > 299 || !response.ok) { + throw Error("Unknown error"); + } + + const encodedQuestion = encodeURIComponent(question); + const eventSource = new EventSource(`/tdstream?question=${encodedQuestion}`); + + return eventSource; +} + +export async function getSolve(question: string): Promise { + const response = await fetch(`/getSolve?question=${encodeURIComponent(question)}`, { + method: "GET", + headers: { + "Content-Type": "application/json" + } + }); + + const parsedResponse: String[] = await response.json(); + if (response.status > 299 || !response.ok) { + throw Error("Unknown error"); + } + + return parsedResponse; +} +export async function refresh(): Promise { + const response = await fetch(`/refresh?`, { + method: "POST", + headers: { + "Content-Type": "application/json" + } + }); + + const parsedResponse: String[] = await response.json(); + if (response.status > 299 || !response.ok) { + throw Error("Unknown error"); + } + + return parsedResponse; +} + +export async function getTempImages(): Promise { + const response = await fetch(`/getTempImages`, { + method: "GET", + headers: { + "Content-Type": "application/json" + } + }); + + const parsedResponse: { images: string[] } = await response.json(); + if (response.status > 299 || !response.ok) { + throw Error("Unknown error"); + } + const imgs = parsedResponse.images; + return imgs; +} + +export async function postTd(file: File): Promise { + const formData = new FormData(); + formData.append('csv', file); + + const response = await fetch('/posttd', { + method: 'POST', + body: formData, + }); + + const parsedResponse: String = await response.text(); + if (response.status > 299 || !response.ok) { + throw Error("Unknown error"); + } + + return parsedResponse; +} + +export async function processCsvAgentResponse(question: string, file: File, retries: number = 3): Promise { + let lastError; + + const formData = new FormData(); + formData.append('csv', file); + + const response = await fetch('/posttd', { + method: 'POST', + body: formData, + }); + + const parsedResponse: String = await response.text(); + if (response.status > 299 || !response.ok) { + throw Error("Unknown error"); + } + for (let i = 0; i < retries; i++) { + try { + const response = await fetch(`/process_td_agent_response?question=${encodeURIComponent(question)}`, { + method: "GET", + headers: { + "Content-Type": "application/json" + } + }); + + const parsedResponse: String = await response.json(); + if (response.status > 299 || !response.ok) { + throw Error("Unknown error"); + } + + return parsedResponse; + } catch (error) { + lastError = error; + } + } + + throw lastError; +} + +export async function processAgentResponse(question: string): Promise { + const response = await fetch(`/process_agent_response?question=${encodeURIComponent(question)}`, { + method: "GET", + headers: { + "Content-Type": "application/json" + } + }); + + const parsedResponse: String = await response.json(); + if (response.status > 299 || !response.ok) { + throw Error("Unknown error"); + } + + return parsedResponse; +} + export async function logStatus(status_log_entry: StatusLogEntry): Promise { var response = await fetch("/logstatus", { method: "POST", @@ -169,6 +418,22 @@ export async function getWarningBanner(): Promise { return parsedResponse; } +export async function getMaxCSVFileSize(): Promise { + const response = await fetch("/getMaxCSVFileSize", { + method: "GET", + headers: { + "Content-Type": "application/json" + } + }); + const parsedResponse: getMaxCSVFileSizeType = 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", @@ -220,4 +485,20 @@ export async function getAllTags(): Promise { } var results: GetTagsResponse = {tags: parsedResponse}; return results; +} + +export async function getFeatureFlags(): Promise { + const response = await fetch("/getFeatureFlags", { + method: "GET", + headers: { + "Content-Type": "application/json" + } + }); + const parsedResponse: GetFeatureFlagsResponse = await response.json(); + if (response.status > 299 || !response.ok) { + console.log(response); + throw Error(parsedResponse.error || "Unknown error"); + } + console.log(parsedResponse); + return parsedResponse; } \ No newline at end of file diff --git a/app/frontend/src/api/models.ts b/app/frontend/src/api/models.ts index 6db38afd7..34a13191e 100644 --- a/app/frontend/src/api/models.ts +++ b/app/frontend/src/api/models.ts @@ -1,13 +1,23 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. +export const enum ChatMode { + WorkOnly = 0, + WorkPlusWeb = 1, + Ungrounded = 2 +} + export const enum Approaches { - RetrieveThenRead = "rtr", - ReadRetrieveRead = "rrr", - ReadDecomposeAsk = "rda" + RetrieveThenRead = 0, + ReadRetrieveRead = 1, + ReadDecomposeAsk = 2, + GPTDirect = 3, + ChatWebRetrieveRead = 4, + CompareWorkWithWeb = 5, + CompareWebWithWork = 6 } -export type AskRequestOverrides = { +export type ChatRequestOverrides = { semanticRanker?: boolean; semanticCaptions?: boolean; excludeCategory?: string; @@ -17,6 +27,7 @@ export type AskRequestOverrides = { promptTemplatePrefix?: string; promptTemplateSuffix?: string; suggestFollowupQuestions?: boolean; + byPassRAG?: boolean; userPersona?: string; systemPersona?: string; aiPersona?: string; @@ -26,20 +37,14 @@ export type AskRequestOverrides = { selectedTags?: string; }; -export type AskRequest = { - question: string; - approach: Approaches; - overrides?: AskRequestOverrides; -}; - -export type AskResponse = { +export type ChatResponse = { answer: string; thoughts: string | null; data_points: string[]; - // citation_lookup: {} - // added this for citation bug. aparmar. - citation_lookup: { [key: string]: { citation: string; source_path: string; page_number: string } }; - + approach: Approaches; + thought_chain: { [key: string]: string }; + work_citation_lookup: { [key: string]: { citation: string; source_path: string; page_number: string } }; + web_citation_lookup: { [key: string]: { citation: string; source_path: string; page_number: string } }; error?: string; }; @@ -48,10 +53,18 @@ export type ChatTurn = { bot?: string; }; +export type Citation = { + citation: string; + source_path: string; + page_number: string; // or number, if page_number is intended to be a numeric value + } + export type ChatRequest = { history: ChatTurn[]; approach: Approaches; - overrides?: AskRequestOverrides; + overrides?: ChatRequestOverrides; + citation_lookup: { [key: string]: { citation: string; source_path: string; page_number: string } }; + thought_chain: { [key: string]: string }; }; export type BlobClientUrlResponse = { @@ -67,30 +80,54 @@ export type FileUploadBasicStatus = { start_timestamp: string; state_description: string; state_timestamp: string; + status_updates: StatusUpdates[]; + tags: string; +} + +export type StatusUpdates = { + status: string; + status_timestamp: string; + status_classification: string; } export type AllFilesUploadStatus = { statuses: FileUploadBasicStatus[]; } +export type AllFolders = { + folders: string; +} + export type GetUploadStatusRequest = { timeframe: number; - state: FileState + state: FileState; + folder: string; + tag: string } +export type DeleteItemRequest = { + path: string +} + +export type ResubmitItemRequest = { + path: string +} // These keys need to match case with the defined Enum in the // shared code (functions/shared_code/status_log.py) export const enum FileState { All = "ALL", Processing = "PROCESSING", + Indexing = "INDEXING", Skipped = "SKIPPED", Queued = "QUEUED", Complete = "COMPLETE", - Error = "ERROR" + Error = "ERROR", + THROTTLED = "THROTTLED", + UPLOADED = "UPLOADED", + DELETING = "DELETING", + DELETED = "DELETED" } - - export type GetInfoResponse = { AZURE_OPENAI_SERVICE: string; AZURE_OPENAI_CHATGPT_DEPLOYMENT: string; @@ -123,6 +160,11 @@ export type GetWarningBanner = { error?: string; }; +export type getMaxCSVFileSizeType = { + MAX_CSV_FILE_SIZE: 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 { @@ -135,6 +177,7 @@ export const enum StatusLogClassification { // shared code (functions/shared_code/status_log.py) export const enum StatusLogState { Processing = "Processing", + Indexing = "Indexing", Skipped = "Skipped", Queued = "Queued", Complete = "Complete", @@ -164,4 +207,13 @@ export type ApplicationTitle = { export type GetTagsResponse = { tags: string; error?: string; +} + +export type GetFeatureFlagsResponse = { + ENABLE_WEB_CHAT: boolean; + ENABLE_UNGROUNDED_CHAT: boolean; + ENABLE_MATH_ASSISTANT: boolean; + ENABLE_TABULAR_DATA_ASSISTANT: boolean; + ENABLE_MULTIMEDIA: boolean; + error?: string; } \ No newline at end of file diff --git a/app/frontend/src/components/AnalysisPanel/AnalysisPanel.tsx b/app/frontend/src/components/AnalysisPanel/AnalysisPanel.tsx index 9e71a0400..a290c6867 100644 --- a/app/frontend/src/components/AnalysisPanel/AnalysisPanel.tsx +++ b/app/frontend/src/components/AnalysisPanel/AnalysisPanel.tsx @@ -6,11 +6,13 @@ import { Pivot, PivotItem, Text } from "@fluentui/react"; import { Label } from '@fluentui/react/lib/Label'; import { Separator } from '@fluentui/react/lib/Separator'; import DOMPurify from "dompurify"; +import ReactMarkdown from 'react-markdown'; +import remarkGfm from 'remark-gfm' import styles from "./AnalysisPanel.module.css"; import { SupportingContent } from "../SupportingContent"; -import { AskResponse, ActiveCitation, getCitationObj } from "../../api"; +import { ChatResponse, ActiveCitation, getCitationObj } from "../../api"; import { AnalysisPanelTabs } from "./AnalysisPanelTabs"; interface Props { @@ -21,16 +23,18 @@ interface Props { sourceFile: string | undefined; pageNumber: string | undefined; citationHeight: string; - answer: AskResponse; + answer: ChatResponse; } const pivotItemDisabledStyle = { disabled: true, style: { color: "grey" } }; export const AnalysisPanel = ({ answer, activeTab, activeCitation, sourceFile, pageNumber, citationHeight, className, onActiveTabChanged }: Props) => { const [activeCitationObj, setActiveCitationObj] = useState(); + const [markdownContent, setMarkdownContent] = useState(''); + const [plainTextContent, setPlainTextContent] = useState(''); const isDisabledThoughtProcessTab: boolean = !answer.thoughts; - const isDisabledSupportingContentTab: boolean = !answer.data_points.length; + const isDisabledSupportingContentTab: boolean = !answer.data_points?.length; const isDisabledCitationTab: boolean = !activeCitation; // the first split on ? separates the file from the sas token, then the second split on . separates the file extension const sourceFileExt: any = sourceFile?.split("?")[0].split(".").pop(); @@ -51,6 +55,39 @@ export const AnalysisPanel = ({ answer, activeTab, activeCitation, sourceFile, p fetchActiveCitationObj(); }, [activeCitation]); + useEffect(() => { + if (!sourceFile) { + return; + } + const fetchMarkdownContent = async () => { + try { + const response = await fetch(sourceFile!); + const content = await response.text(); + setMarkdownContent(content); + } catch (error) { + console.error('Error fetching Markdown content:', error); + } + }; + + fetchMarkdownContent(); + }, [sourceFile]); + + useEffect(() => { + const fetchPlainTextContent = async () => { + try { + const response = await fetch(sourceFile!); + const content = await response.text(); + setPlainTextContent(content); + } catch (error) { + console.error('Error fetching plain text content:', error); + } + }; + + if (["json", "txt", "xml"].includes(sourceFileExt)) { + fetchPlainTextContent(); + } + }, [sourceFile, sourceFileExt]); + return ( - { activeCitationObj === undefined ? ( + {activeCitationObj === undefined ? ( Loading... ) : (
- Metadata - {activeCitationObj.file_name} - {activeCitationObj.file_uri} - {activeCitationObj.title} - {activeCitationObj.section} - {activeCitationObj.pages?.join(",")} - {activeCitationObj.token_count} - Content - {activeCitationObj.content} + Metadata + {activeCitationObj.file_name} + {activeCitationObj.file_uri} + {activeCitationObj.title} + {activeCitationObj.section} + {activeCitationObj.pages?.join(",")} + {activeCitationObj.token_count} + Content + {activeCitationObj.content}
)}
- { sourceFileExt === "pdf" ? ( - //use object tag for pdfs because iframe does not support page numbers + {["docx", "xlsx", "pptx"].includes(sourceFileExt) ? ( + // Treat other Office formats like "xlsx" for the Office Online Viewer +