├── .gitignore ├── CONTRIBUTING ├── LICENSE ├── README.md ├── README_en.md ├── assets ├── approach.pptx ├── chat-sample.png ├── overview_en.png ├── overview_ja.png ├── permission-sample.png ├── sequence_en.png └── sequence_ja.png └── src ├── backend ├── .env-sample ├── app.py ├── approaches │ ├── __init__.py │ ├── approach.py │ └── chatreadretrieveread.py ├── core │ ├── __init__.py │ ├── authentication.py │ ├── graphclientbuilder.py │ ├── messagebuilder.py │ └── modelhelper.py ├── gunicorn.conf.py ├── main.py ├── requirements.in ├── requirements.txt └── text.py └── frontend ├── .vite └── deps_temp_2b074880 │ └── package.json ├── index.html ├── package-lock.json ├── package.json ├── public └── favicon.ico ├── src ├── api │ ├── api.ts │ ├── index.ts │ └── models.ts ├── assets │ ├── github.svg │ └── search.svg ├── authConfig.ts ├── components │ ├── AnalysisPanel │ │ ├── AnalysisPanel.module.css │ │ ├── AnalysisPanel.tsx │ │ ├── AnalysisPanelTabs.tsx │ │ └── index.tsx │ ├── Answer │ │ ├── Answer.module.css │ │ ├── Answer.tsx │ │ ├── AnswerError.tsx │ │ ├── AnswerIcon.tsx │ │ ├── AnswerLoading.tsx │ │ ├── AnswerParser.tsx │ │ └── index.ts │ ├── ClearChatButton │ │ ├── ClearChatButton.module.css │ │ ├── ClearChatButton.tsx │ │ └── index.tsx │ ├── Example │ │ ├── Example.module.css │ │ ├── Example.tsx │ │ ├── ExampleList.tsx │ │ └── index.tsx │ ├── LoginButton │ │ ├── LoginButton.module.css │ │ ├── LoginButton.tsx │ │ └── index.tsx │ ├── QuestionInput │ │ ├── QuestionInput.module.css │ │ ├── QuestionInput.tsx │ │ └── index.ts │ ├── SettingsButton │ │ ├── SettingsButton.module.css │ │ ├── SettingsButton.tsx │ │ └── index.tsx │ ├── SupportingContent │ │ ├── SupportingContent.module.css │ │ ├── SupportingContent.tsx │ │ ├── SupportingContentParser.ts │ │ └── index.ts │ ├── TokenClaimsDisplay │ │ ├── TokenClaimsDisplay.tsx │ │ └── index.tsx │ └── UserChatMessage │ │ ├── UserChatMessage.module.css │ │ ├── UserChatMessage.tsx │ │ └── index.ts ├── index.css ├── index.tsx ├── pages │ ├── NoPage.tsx │ ├── chat │ │ ├── Chat.module.css │ │ └── Chat.tsx │ └── layout │ │ ├── Layout.module.css │ │ └── Layout.tsx └── vite-env.d.ts ├── tsconfig.json └── vite.config.ts /.gitignore: -------------------------------------------------------------------------------- 1 | # Azure az webapp deployment details 2 | .azure 3 | *_env 4 | 5 | # Byte-compiled / optimized / DLL files 6 | __pycache__/ 7 | *.py[cod] 8 | *$py.class 9 | 10 | # C extensions 11 | *.so 12 | 13 | # Distribution / packaging 14 | .Python 15 | build/ 16 | develop-eggs/ 17 | dist/ 18 | downloads/ 19 | eggs/ 20 | .eggs/ 21 | lib/ 22 | lib64/ 23 | parts/ 24 | sdist/ 25 | var/ 26 | wheels/ 27 | share/python-wheels/ 28 | *.egg-info/ 29 | .installed.cfg 30 | *.egg 31 | MANIFEST 32 | 33 | # PyInstaller 34 | # Usually these files are written by a python script from a template 35 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 36 | *.manifest 37 | *.spec 38 | 39 | # Installer logs 40 | pip-log.txt 41 | pip-delete-this-directory.txt 42 | 43 | # Unit test / coverage reports 44 | htmlcov/ 45 | .tox/ 46 | .nox/ 47 | .coverage 48 | .coverage.* 49 | .cache 50 | nosetests.xml 51 | coverage.xml 52 | *.cover 53 | *.py,cover 54 | .hypothesis/ 55 | .pytest_cache/ 56 | cover/ 57 | 58 | # Translations 59 | *.mo 60 | *.pot 61 | 62 | # Django stuff: 63 | *.log 64 | local_settings.py 65 | db.sqlite3 66 | db.sqlite3-journal 67 | 68 | # Flask stuff: 69 | instance/ 70 | .webassets-cache 71 | 72 | # Scrapy stuff: 73 | .scrapy 74 | 75 | # Sphinx documentation 76 | docs/_build/ 77 | 78 | # PyBuilder 79 | .pybuilder/ 80 | target/ 81 | 82 | # Jupyter Notebook 83 | .ipynb_checkpoints 84 | 85 | # IPython 86 | profile_default/ 87 | ipython_config.py 88 | 89 | # pyenv 90 | # For a library or package, you might want to ignore these files since the code is 91 | # intended to run in multiple environments; otherwise, check them in: 92 | # .python-version 93 | 94 | # pipenv 95 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 96 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 97 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 98 | # install all needed dependencies. 99 | #Pipfile.lock 100 | 101 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 102 | __pypackages__/ 103 | 104 | # Celery stuff 105 | celerybeat-schedule 106 | celerybeat.pid 107 | 108 | # SageMath parsed files 109 | *.sage.py 110 | 111 | # Environments 112 | .env 113 | .venv 114 | env/ 115 | venv/ 116 | ENV/ 117 | env.bak/ 118 | venv.bak/ 119 | 120 | # Spyder project settings 121 | .spyderproject 122 | .spyproject 123 | 124 | # Rope project settings 125 | .ropeproject 126 | 127 | # mkdocs documentation 128 | /site 129 | 130 | # mypy 131 | .mypy_cache/ 132 | .dmypy.json 133 | dmypy.json 134 | 135 | # Pyre type checker 136 | .pyre/ 137 | 138 | # pytype static type analyzer 139 | .pytype/ 140 | 141 | # Cython debug symbols 142 | cython_debug/ 143 | 144 | # NPM 145 | npm-debug.log* 146 | node_modules 147 | static/ 148 | .DS_Store 149 | -------------------------------------------------------------------------------- /CONTRIBUTING: -------------------------------------------------------------------------------- 1 | The project welcomes contributions and proposals. Tasks listed in the issues mean they are not yet started unless assigned. You can assign tasks to yourself and contribute to development. After development, please create a pull request. 2 | If you have a request that is not listed in the issues, you can create a new issue. 3 | 4 | このプロジェクトは貢献と提案を歓迎します。 5 | Issueに掲載されているタスクはアサインされていない限り未着手を意味します。タスクをご自身にアサインして開発に貢献する事ができます。 6 | 開発後はPull Requestを作成してください。 7 | Issueに掲載されていない内容でリクエストがある場合はIssueを作成することができます。 -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 07JP27 4 | Copyright (c) 2023 Azure Samples 5 | 6 | This software includes modifications made by 07JP27 to the original software licensed under the MIT License. 7 | Modified portions of this software is RAG process using Microsoft Graph. 8 | 9 | Permission is hereby granted, free of charge, to any person obtaining a copy 10 | of this software and associated documentation files (the "Software"), to deal 11 | in the Software without restriction, including without limitation the rights 12 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | copies of the Software, and to permit persons to whom the Software is 14 | furnished to do so, subject to the following conditions: 15 | 16 | The above copyright notice and this permission notice shall be included in all 17 | copies or substantial portions of the Software. 18 | 19 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 25 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [English](./README_en.md) 2 | 3 | # Microsoft Search API RAG サンプルアプリ 4 | 5 | ![](./assets/chat-sample.png) 6 | 7 | このサンプルは[Azure-Samples/azure-search-openai-demo](https://github.com/Azure-Samples/azure-search-openai-demo)をベースに開発されています。 8 | 9 | > **Note** 10 | > Note:このサンプルで使用されているMicrosoft Graph SDK for Pythonは現在[プレビュー段階](https://learn.microsoft.com/ja-jp/graph/sdks/sdks-overview#supported-languages)です。 11 | 12 | 13 | ## 概要 14 | 15 | 16 | ### 機能 17 | - Microsoft 365内のドキュメントやサイト、Teamsの投稿などを基にしたLLMによるチャット形式の内部ナレッジ検索 18 | - Microsoft 365でも使用されるMicrosoft Search APIを使用したシンプルかつ高精度なRAGアーキテクチャ 19 | - [On-Behalf-Of フロー](https://learn.microsoft.com/ja-jp/entra/identity-platform/v2-oauth2-on-behalf-of-flow)を使用した元データに付与されたユーザーごとの権限に応じた検索 20 | ![権限が異なるファイルを別々のユーザーが同じ入力で検索した結果](./assets/permission-sample.png) 21 | 22 | ### 技術概要 23 | アーキテクチャ 24 | ![](./assets/overview_ja.png) 25 | 26 | シーケンス 27 | ![](./assets/sequence_ja.png) 28 | 29 | ### 現時点での制限 30 | - Citationタブでの参考文書の参照は、ブラウザにMicrosoft Edgeのみで動作します。その他のブラウザはiframeによる認証情報の伝播の制限により動作しません。 (https://github.com/07JP27/azureopenai-internal-microsoft-search/issues/12) 31 | - 現在はStreamingモードを実装していません。(https://github.com/07JP27/azureopenai-internal-microsoft-search/issues/9) 32 | 33 | ## セットアップ方法 34 | ### 前提条件 35 | - このリポジトリをクローンまたはダウンロード 36 | - Azure OpenAI ServiceまたはOpenAIの準備 37 | - Azure OpenAI Service:GPT-35-turboまたはGPT-4のデプロイ 38 | - OpenAI:APIキーを取得 39 | - ローカル実行を行う場合は以下の環境がローカルマシンに準備されていること 40 | - Python 41 | - Node.js 42 | - Azure CLI 43 | 44 | ### 1.アプリケーション登録 45 | 1. [Azure Portal](https://portal.azure.com/)にログインします。 46 | 1. [Microsoft Entra ID](https://portal.azure.com/#view/Microsoft_AAD_IAM/ActiveDirectoryMenuBlade/~/Overview) > アプリの登録 の順に選択していき、アプリ登録一覧画面で「新規登録」を選択します。 47 | 1. 「名前」を入力します。(例:ChatGPT-GraphSearch) 48 | 1. 「サポートされているアカウントの種類」で「この組織ディレクトリのみに含まれるアカウント」を選択 49 | 1. 「リダイレクトURI」でプラットフォームを「シングルページアプリケーション(SPA)」、URIを「http://localhost:5173/redirect」に設定します。 50 | 1. 「登録」をクリックします。 51 | 1. 登録されたアプリ登録に移動し、ブレードメニューの「APIのアクセス許可」にアクセスします。 52 | 1. 「アクセス許可の追加」から「Microsoft Graph」>「委任されたアクセス許可」の順に選択していき「Files.Read.All」と「Sites.Read.All」にチェックを入れて「アクセス許可の追加」を選択します。 53 | 1. アクセス許可一覧に選択したアクセス許可がリストアップされていることを確認します。 54 | 1. ブレードメニューから「APIの公開」を選択して「アプリケーション ID の URI」の「追加」をクリックします。 55 | 1. 表示される「api://{UUID}」の状態のまま「保存」を選択します。 56 | 1. 同ページの「Scopeの追加」を選択します。 57 | 1. スコープ名に「access_as_user」と入力してその直下に「api://{UUID}/access_as_user」と表示されるようにするします。 58 | 1. 同意できる対象で「管理者とユーザー」を選択します。 59 | 1. そのほかの表示名や説明は任意の文章(=初回ログイン時に委任を求める画面で表示される内容)を入力して「スコープの追加」を選択します。 60 | 1. ブレードメニューから「証明書とシークレット」を選択します。「新しいクライアントシークレット」を選択してクライアントシークレットを任意の説明と期間で追加します。 61 | 1. 作成されたシークレットの「値」をメモ帳などにコピーします。**シークレットの値は作成直後しか表示されません。メモせずに画面遷移をすると2度と表示できないのでご注意ください。** 62 | 1. ブレードメニューから「概要」を選択して表示される次の情報をメモ帳などにコピーします。 63 | - ディレクトリ(テナント)ID 64 | - アプリケーション(クライアント)ID 65 | 66 | ### 2. プロンプト調整 67 | 用途に応じて以下のファイルの`system_message_chat_conversation`、`query_prompt_template`、`query_prompt_few_shots`に記述されているプロンプトを調整します。 68 | https://github.com/07JP27/azureopenai-internal-microsoft-search/blob/52053b6c672a32899b5361ae3510dbe0c40693c6/src/backend/approaches/chatreadretrieveread.py#L29 69 | 70 | ### 3.ローカル実行 71 | 1. 対象のAzure OpneAI Serviceのアクセス制御でローカル実行ユーザーにRBAC「Cognitive Services OpenAI User」ロールを付与します。**すでに共同作成者がついている場合でも必ず別途付与してください** 72 | 1. ターミナルで`az login`を実行してAzure OpenAI ServiceのリソースのRBACに登録したアカウントでAzureにログインします。 73 | 1. ターミナルなどでクローンしたファイルの「src/backend」に移動して「pip install -r requirements.txt」を実行します。パッケージのインストールが完了するまでしばらく待ちます。 74 | 1. 別ターミナルなどを開きクローンしたファイルの「src/frontend」に移動して「npm install」を実行します。パッケージのインストールが完了するまでしばらく待ちます。 75 | 1. 「src/backend」内に.envファイルを作成して[.env-sample](./src/backend/.env-sample)に記載されている内容をコピーします。 76 | 1. それぞれの環境変数にメモした情報などを入力します。 77 | 1. 「src/backend」を開いているターミナルで「quart run」を実行します。 78 | 1. 「src/frontend」を開いているターミナルで「npm run dev」を実行します。 79 | 1. ブラウザで「http://localhost:5173/」にアクセスします。 80 | 1. 画面右上の「Login」ボタンをクリックして、アプリ登録を行ったディレクトリのユーザーアカウントでログインします。ログインに成功したら「Login」と表示されていた部分にユーザーのUPNが表示されます。 81 | 1. 入力エリアに質問を入力してチャットを開始します。 82 | 83 | ### 4.Azureへのデプロイ 84 | TBW 85 | 86 | 87 | -------------------------------------------------------------------------------- /README_en.md: -------------------------------------------------------------------------------- 1 | [Japanese](./README.md) 2 | 3 | # Microsoft Search API RAG Sample App 4 | 5 | ![](./assets/chat-sample.png) 6 | 7 | This sample is developed based on [Azure-Samples/azure-search-openai-demo](https://github.com/Azure-Samples/azure-search-openai-demo). 8 | 9 | > **Note** 10 | > Note: The Microsoft Graph SDK for Python used in this sample is currently in [preview](https://learn.microsoft.com/en-us/graph/sdks/sdks-overview#supported-languages). 11 | 12 | ## Overview 13 | 14 | 15 | ### Features 16 | - Chat-based internal knowledge search using LLM based on documents, sites within Microsoft 365, and Teams posts 17 | - Simple and highly accurate RAG architecture using the Microsoft Search API also used in Microsoft 365 18 | - Search based on user permissions using the [On-Behalf-Of Flow](https://learn.microsoft.com/en-us/entra/identity-platform/v2-oauth2-on-behalf-of-flow) 19 | 20 | ### Technical Overview 21 | Architecture 22 | ![](./assets/overview_en.png) 23 | 24 | Sequence 25 | ![](./assets/sequence_en.png) 26 | 27 | ### Current Limitations 28 | - Referencing reference documents in the Citation tab only works in the Microsoft Edge browser due to restrictions on propagating authentication information via iframe. Other browsers do not work (https://github.com/07JP27/azureopenai-internal-microsoft-search/issues/12). 29 | - Streaming mode is not currently implemented (https://github.com/07JP27/azureopenai-internal-microsoft-search/issues/9). 30 | 31 | ## Setup 32 | ### Prerequisites 33 | - Clone or download this repository 34 | - Azure OpenAI Service or preparation for OpenAI 35 | - Azure OpenAI Service: Deploy GPT-3.5-turbo or GPT-4 36 | - OpenAI: Obtain API key 37 | - Ensure the following environment is prepared on the local machine for local execution 38 | - Python 39 | - Node.js 40 | 41 | ### 1. Application Registration 42 | 1. Log in to [Azure Portal](https://portal.azure.com/). 43 | 1. Select [Microsoft Entra ID](https://portal.azure.com/#view/Microsoft_AAD_IAM/ActiveDirectoryMenuBlade/~/Overview) > App registrations in order, and choose "New registration" on the App registrations page. 44 | 1. Enter a "Name" (e.g., ChatGPT-GraphSearch). 45 | 1. Select "Accounts in this organizational directory only" under "Supported account types." 46 | 1. Set the platform to "Single-page application (SPA)" and set the URI to "http://localhost:5173/redirect" under "Redirect URI." 47 | 1. Click "Register." 48 | 1. Go to the registered app registration, and access "API permissions" in the blade menu. 49 | 1. Select "Add a permission" and choose "Microsoft Graph" > "Delegated permissions" in order. Check "Files.Read.All" and "Sites.Read.All," then select "Add permissions." 50 | 1. Confirm that the selected permissions are listed in the permissions list. 51 | 1. Select "Expose an API" from the blade menu and click "Add a scope" for "Application ID URI." 52 | 1. Keep the state of the displayed "api://{UUID}" and select "Save." 53 | 1. Select "Add a scope" on the same page. 54 | 1. Enter "access_as_user" in the scope name, and make sure "api://{UUID}/access_as_user" is displayed directly below it. 55 | 1. Choose "Admins and users" for the consentable audience. 56 | 1. Enter any text for the display name and description (i.e., the content displayed on the screen requesting delegation during the first login), and select "Add scope." 57 | 1. Select "Certificates & secrets" from the blade menu. Choose "New client secret," add a client secret with any description and duration. 58 | 1. Copy the value of the created secret to Notepad or another tool. **Note that the secret value is only displayed immediately after creation. Be careful not to navigate away from the screen without saving it.** 59 | 1. Select "Overview" from the blade menu and copy the following information displayed to Notepad or another tool. 60 | - Directory (tenant) ID 61 | - Application (client) ID 62 | 63 | ### 2. Adjust Prompts 64 | Adjust the prompts described in `system_message_chat_conversation`, `query_prompt_template`, and `query_prompt_few_shots` in the following file according to your needs: 65 | https://github.com/07JP27/azureopenai-internal-microsoft-search/blob/52053b6c672a32899b5361ae3510dbe0c40693c6/src/backend/approaches/chatreadretrieveread.py#L29 66 | 67 | ### 3. Local Execution 68 | 1. Move to "src/backend" of the cloned files in the terminal and execute "pip install -r requirements.txt." Wait for the package installation to complete. 69 | 1. Open another terminal or similar, move to "src/frontend" of the cloned files, and execute "npm install." Wait for the package installation to complete. 70 | 1. Create an .env file in "src/backend" and copy the contents from [`.env-sample`](./src/backend/.env-sample). 71 | 1. Enter the information saved in Notepad or other tools for each environment variable. 72 | 1. In the terminal with "src/backend" open, execute "quart run." 73 | 1. In the terminal with "src/frontend" open, execute "npm run dev." 74 | 1. Access "http://localhost:5173/" in the browser. 75 | 1. Click the "Login" button in the upper right corner of the screen and log in with the user account of the directory where the app registration was performed. Once logged in successfully, the user's UPN will be displayed in the part where "Login" was shown. 76 | 1. Enter a question in the input area and start the chat. 77 | 78 | ### 4. Deployment to Azure 79 | TBW -------------------------------------------------------------------------------- /assets/approach.pptx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/07JP27/azureopenai-internal-microsoft-search/185860d4aa7e1c026e3799b9b9102ed256c28d71/assets/approach.pptx -------------------------------------------------------------------------------- /assets/chat-sample.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/07JP27/azureopenai-internal-microsoft-search/185860d4aa7e1c026e3799b9b9102ed256c28d71/assets/chat-sample.png -------------------------------------------------------------------------------- /assets/overview_en.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/07JP27/azureopenai-internal-microsoft-search/185860d4aa7e1c026e3799b9b9102ed256c28d71/assets/overview_en.png -------------------------------------------------------------------------------- /assets/overview_ja.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/07JP27/azureopenai-internal-microsoft-search/185860d4aa7e1c026e3799b9b9102ed256c28d71/assets/overview_ja.png -------------------------------------------------------------------------------- /assets/permission-sample.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/07JP27/azureopenai-internal-microsoft-search/185860d4aa7e1c026e3799b9b9102ed256c28d71/assets/permission-sample.png -------------------------------------------------------------------------------- /assets/sequence_en.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/07JP27/azureopenai-internal-microsoft-search/185860d4aa7e1c026e3799b9b9102ed256c28d71/assets/sequence_en.png -------------------------------------------------------------------------------- /assets/sequence_ja.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/07JP27/azureopenai-internal-microsoft-search/185860d4aa7e1c026e3799b9b9102ed256c28d71/assets/sequence_ja.png -------------------------------------------------------------------------------- /src/backend/.env-sample: -------------------------------------------------------------------------------- 1 | # Using Model type 2 | AZURE_OPENAI_CHATGPT_MODEL = "gpt-35-turbo" 3 | 4 | # [Option]Used with Azure OpenAI deployments 5 | AZURE_OPENAI_SERVICE = "{your AOAI service name}" 6 | AZURE_OPENAI_CHATGPT_DEPLOYMENT = "{your AOAI deployment name}" 7 | 8 | # [Option]Used with OpenAI 9 | OPENAI_API_KEY = "{your OpenAI API key}" 10 | OPENAI_ORGANIZATION = "{your OpenAI organization if needed}" 11 | 12 | # Auth settings 13 | AZURE_USE_AUTHENTICATION = "true" 14 | AZURE_SERVER_APP_ID = "{your applicaiton id copied from app registration on Azure portal}" 15 | AZURE_SERVER_APP_SECRET ="{your secret copied from app registration on Azure portal}" 16 | AZURE_CLIENT_APP_ID = "{your applicaiton id copied from app registration on Azure portal}" 17 | AZURE_TENANT_ID = "{your tenant id copied from app registration on Azure portal}" 18 | TOKEN_CACHE_PATH =None 19 | -------------------------------------------------------------------------------- /src/backend/app.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | import time 4 | from pathlib import Path 5 | 6 | import openai 7 | from azure.identity.aio import DefaultAzureCredential 8 | from azure.monitor.opentelemetry import configure_azure_monitor 9 | from opentelemetry.instrumentation.aiohttp_client import AioHttpClientInstrumentor 10 | from opentelemetry.instrumentation.asgi import OpenTelemetryMiddleware 11 | from quart import ( 12 | Blueprint, 13 | Quart, 14 | current_app, 15 | jsonify, 16 | make_response, 17 | request, 18 | send_from_directory, 19 | ) 20 | from quart_cors import cors 21 | 22 | from approaches.chatreadretrieveread import ChatReadRetrieveReadApproach 23 | from core.authentication import AuthenticationHelper 24 | 25 | CONFIG_OPENAI_TOKEN = "openai_token" 26 | CONFIG_CREDENTIAL = "azure_credential" 27 | CONFIG_CHAT_APPROACH = "chat_approach" 28 | CONFIG_AUTH_CLIENT = "auth_client" 29 | 30 | bp = Blueprint("routes", __name__, static_folder="static") 31 | 32 | 33 | @bp.route("/") 34 | async def index(): 35 | return await bp.send_static_file("index.html") 36 | 37 | 38 | # Empty page is recommended for login redirect to work. 39 | # See https://github.com/AzureAD/microsoft-authentication-library-for-js/blob/dev/lib/msal-browser/docs/initialization.md#redirecturi-considerations for more information 40 | @bp.route("/redirect") 41 | async def redirect(): 42 | return "" 43 | 44 | @bp.route("/favicon.ico") 45 | async def favicon(): 46 | return await bp.send_static_file("favicon.ico") 47 | 48 | @bp.route("/assets/") 49 | async def assets(path): 50 | return await send_from_directory(Path(__file__).resolve().parent / "static" / "assets", path) 51 | 52 | @bp.route("/chat", methods=["POST"]) 53 | async def chat(): 54 | if not request.is_json: 55 | return jsonify({"error": "request must be json"}), 415 56 | request_json = await request.get_json() 57 | context = request_json.get("context", {}) 58 | auth_helper = current_app.config[CONFIG_AUTH_CLIENT] 59 | context["obo_token"] = auth_helper.get_token_auth_header(request.headers) 60 | try: 61 | approach = current_app.config[CONFIG_CHAT_APPROACH] 62 | result = await approach.run( 63 | request_json["messages"], 64 | stream=request_json.get("stream", False), 65 | context=context, 66 | session_state=request_json.get("session_state"), 67 | ) 68 | if isinstance(result, dict): 69 | return jsonify(result) 70 | else: 71 | response = await make_response(format_as_ndjson(result)) 72 | response.timeout = None # type: ignore 73 | return response 74 | except Exception as e: 75 | logging.exception("Exception in /chat") 76 | return jsonify({"error": str(e)}), 500 77 | 78 | 79 | # Send MSAL.js settings to the client UI 80 | @bp.route("/auth_setup", methods=["GET"]) 81 | def auth_setup(): 82 | auth_helper = current_app.config[CONFIG_AUTH_CLIENT] 83 | return jsonify(auth_helper.get_auth_setup_for_client()) 84 | 85 | 86 | @bp.before_request 87 | async def ensure_openai_token(): 88 | if openai.api_type != "azure_ad": 89 | return 90 | openai_token = current_app.config[CONFIG_OPENAI_TOKEN] 91 | if openai_token.expires_on < time.time() + 60: 92 | openai_token = await current_app.config[CONFIG_CREDENTIAL].get_token( 93 | "https://cognitiveservices.azure.com/.default" 94 | ) 95 | current_app.config[CONFIG_OPENAI_TOKEN] = openai_token 96 | openai.api_key = openai_token.token 97 | 98 | 99 | @bp.before_app_serving 100 | async def setup_clients(): 101 | # Shared by all OpenAI deployments 102 | OPENAI_HOST = os.getenv("OPENAI_HOST", "azure") 103 | OPENAI_CHATGPT_MODEL = os.getenv("AZURE_OPENAI_CHATGPT_MODEL") 104 | 105 | # Used with Azure OpenAI deployments 106 | AZURE_OPENAI_SERVICE = os.getenv("AZURE_OPENAI_SERVICE") 107 | AZURE_OPENAI_CHATGPT_DEPLOYMENT = os.getenv("AZURE_OPENAI_CHATGPT_DEPLOYMENT") 108 | 109 | # Used only with non-Azure OpenAI deployments 110 | OPENAI_API_KEY = os.getenv("OPENAI_API_KEY") 111 | OPENAI_ORGANIZATION = os.getenv("OPENAI_ORGANIZATION") 112 | 113 | # Auth Infomation 114 | AZURE_USE_AUTHENTICATION = os.getenv("AZURE_USE_AUTHENTICATION", "").lower() == "true" 115 | AZURE_SERVER_APP_ID = os.getenv("AZURE_SERVER_APP_ID") 116 | AZURE_SERVER_APP_SECRET = os.getenv("AZURE_SERVER_APP_SECRET") 117 | AZURE_CLIENT_APP_ID = os.getenv("AZURE_CLIENT_APP_ID") 118 | AZURE_TENANT_ID = os.getenv("AZURE_TENANT_ID") 119 | TOKEN_CACHE_PATH = os.getenv("TOKEN_CACHE_PATH") 120 | 121 | # Use the current user identity to authenticate with Azure OpenAI, Cognitive Search and Blob Storage (no secrets needed, 122 | # just use 'az login' locally, and managed identity when deployed on Azure). If you need to use keys, use separate AzureKeyCredential instances with the 123 | # keys for each service 124 | # If you encounter a blocking error during a DefaultAzureCredential resolution, you can exclude the problematic credential by using a parameter (ex. exclude_shared_token_cache_credential=True) 125 | azure_credential = DefaultAzureCredential(exclude_shared_token_cache_credential=True) 126 | 127 | # Set up authentication helper 128 | auth_helper = AuthenticationHelper( 129 | use_authentication=AZURE_USE_AUTHENTICATION, 130 | server_app_id=AZURE_SERVER_APP_ID, 131 | server_app_secret=AZURE_SERVER_APP_SECRET, 132 | client_app_id=AZURE_CLIENT_APP_ID, 133 | tenant_id=AZURE_TENANT_ID, 134 | token_cache_path=TOKEN_CACHE_PATH, 135 | ) 136 | 137 | # Used by the OpenAI SDK 138 | if OPENAI_HOST == "azure": 139 | openai.api_type = "azure_ad" 140 | openai.api_base = f"https://{AZURE_OPENAI_SERVICE}.openai.azure.com" 141 | openai.api_version = "2023-07-01-preview" 142 | openai_token = await azure_credential.get_token("https://cognitiveservices.azure.com/.default") 143 | openai.api_key = openai_token.token 144 | current_app.config[CONFIG_OPENAI_TOKEN] = openai_token 145 | else: 146 | openai.api_type = "openai" 147 | openai.api_key = OPENAI_API_KEY 148 | openai.organization = OPENAI_ORGANIZATION 149 | 150 | current_app.config["TENANT_ID"] = AZURE_TENANT_ID 151 | current_app.config["CLIENT_ID"] = AZURE_SERVER_APP_ID 152 | current_app.config["APP_SECRET"] = AZURE_SERVER_APP_SECRET 153 | current_app.config[CONFIG_CREDENTIAL] = azure_credential 154 | current_app.config[CONFIG_AUTH_CLIENT] = auth_helper 155 | current_app.config[CONFIG_CHAT_APPROACH] = ChatReadRetrieveReadApproach( 156 | OPENAI_HOST, 157 | AZURE_OPENAI_CHATGPT_DEPLOYMENT, 158 | OPENAI_CHATGPT_MODEL, 159 | ) 160 | 161 | 162 | def create_app(): 163 | if os.getenv("APPLICATIONINSIGHTS_CONNECTION_STRING"): 164 | configure_azure_monitor() 165 | AioHttpClientInstrumentor().instrument() 166 | app = Quart(__name__) 167 | app.register_blueprint(bp) 168 | app.asgi_app = OpenTelemetryMiddleware(app.asgi_app) # type: ignore[method-assign] 169 | 170 | # Level should be one of https://docs.python.org/3/library/logging.html#logging-levels 171 | default_level = "INFO" # In development, log more verbosely 172 | if os.getenv("WEBSITE_HOSTNAME"): # In production, don't log as heavily 173 | default_level = "WARNING" 174 | logging.basicConfig(level=os.getenv("APP_LOG_LEVEL", default_level)) 175 | 176 | if allowed_origin := os.getenv("ALLOWED_ORIGIN"): 177 | app.logger.info("CORS enabled for %s", allowed_origin) 178 | cors(app, allow_origin=allowed_origin, allow_methods=["GET", "POST"]) 179 | return app 180 | -------------------------------------------------------------------------------- /src/backend/approaches/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/07JP27/azureopenai-internal-microsoft-search/185860d4aa7e1c026e3799b9b9102ed256c28d71/src/backend/approaches/__init__.py -------------------------------------------------------------------------------- /src/backend/approaches/approach.py: -------------------------------------------------------------------------------- 1 | from abc import ABC 2 | from typing import Any, AsyncGenerator, Optional, Union 3 | 4 | from core.authentication import AuthenticationHelper 5 | 6 | 7 | class Approach(ABC): 8 | def build_filter(self, overrides: dict[str, Any], auth_claims: dict[str, Any]) -> Optional[str]: 9 | exclude_category = overrides.get("exclude_category") or None 10 | security_filter = AuthenticationHelper.build_security_filters(overrides, auth_claims) 11 | filters = [] 12 | if exclude_category: 13 | filters.append("category ne '{}'".format(exclude_category.replace("'", "''"))) 14 | if security_filter: 15 | filters.append(security_filter) 16 | return None if len(filters) == 0 else " and ".join(filters) 17 | 18 | async def run( 19 | self, messages: list[dict], stream: bool = False, session_state: Any = None, context: dict[str, Any] = {} 20 | ) -> Union[dict[str, Any], AsyncGenerator[dict[str, Any], None]]: 21 | raise NotImplementedError 22 | -------------------------------------------------------------------------------- /src/backend/approaches/chatreadretrieveread.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | from typing import Any, AsyncGenerator, Optional, Union 4 | 5 | import aiohttp 6 | import openai 7 | from msgraph.generated.search.query.query_post_request_body import QueryPostRequestBody 8 | from msgraph.generated.models.search_request import SearchRequest 9 | from msgraph.generated.models.entity_type import EntityType 10 | from msgraph.generated.models.search_query import SearchQuery 11 | from approaches.approach import Approach 12 | from core.messagebuilder import MessageBuilder 13 | from core.modelhelper import get_token_limit 14 | from core.graphclientbuilder import GraphClientBuilder 15 | 16 | class ChatReadRetrieveReadApproach(Approach): 17 | # Chat roles 18 | SYSTEM = "system" 19 | USER = "user" 20 | ASSISTANT = "assistant" 21 | 22 | NO_RESPONSE = "0" 23 | 24 | """ 25 | Simple retrieve-then-read implementation, using the Cognitive Search and OpenAI APIs directly. It first retrieves 26 | top documents from search, then constructs a prompt with them, and then uses OpenAI to generate an completion 27 | (answer) with that prompt. 28 | """ 29 | system_message_chat_conversation = """Assistant helps the company employees with their healthcare plan questions, and questions about the employee handbook. Be brief in your answers. 30 | Answer ONLY with the facts listed in the list of sources below. If there isn't enough information below, say you don't know. Do not generate answers that don't use the sources below. If asking a clarifying question to the user would help, ask the question. 31 | For tabular information return it as an html table. Do not return markdown format. If the question is not in English, answer in the language used in the question. 32 | Each source has a name followed by colon and the actual information, always include the source name for each fact you use in the response. Use square brackets to reference the source, e.g. [info1.txt]. Don't combine sources, list each source separately, e.g. [info1.txt][info2.pdf]. 33 | {follow_up_questions_prompt} 34 | {injected_prompt} 35 | """ 36 | follow_up_questions_prompt_content = """Generate three very brief follow-up questions that the user would likely ask next about their healthcare plan and employee handbook. 37 | Use double angle brackets to reference the questions, e.g. <>. 38 | Try not to repeat questions that have already been asked. 39 | Only generate questions and do not generate any text before or after the questions, such as 'Next Questions'""" 40 | 41 | query_prompt_template = """Below is a history of previous conversations and new questions from users that need to be searched and answered in the knowledge base about the company. 42 | You have access to the Microsoft Search index, which contains over 100 documents. 43 | Generate a search query based on the conversation and the new question. 44 | Do not include the name of the cited file or document (e.g. info.txt or doc.pdf) in the search query term. 45 | Only display search terms, do not output quotation marks, etc. 46 | Do not include text in [] or <>> in search query terms. 47 | Do not include special characters such as []. 48 | If the question is not in English, generating the search query in the language used in the question. 49 | If you cannot generate a search query, return only the number 0. 50 | """ 51 | query_prompt_few_shots = [ 52 | {"role": USER, "content": "私のヘルスプランについて教えてください。"}, 53 | {"role": ASSISTANT, "content": "利用可能 ヘルスプラン"}, 54 | {"role": USER, "content": "私のプランには有酸素運動は含まれていますか?"}, 55 | {"role": ASSISTANT, "content": "ヘルスプラン 有酸素運動 適用範囲"}, 56 | ] 57 | 58 | def __init__( 59 | self, 60 | openai_host: str, 61 | chatgpt_deployment: Optional[str], # Not needed for non-Azure OpenAI 62 | chatgpt_model: str, 63 | ): 64 | self.openai_host = openai_host 65 | self.chatgpt_deployment = chatgpt_deployment 66 | self.chatgpt_model = chatgpt_model 67 | self.chatgpt_token_limit = get_token_limit(chatgpt_model) 68 | 69 | async def run_simple_chat( 70 | self, 71 | history: list[dict[str, str]], 72 | obo_token, 73 | should_stream: bool = False, 74 | ) -> tuple: 75 | # Step.1 ユーザーの入力からクエリを作成する 76 | original_user_query = history[-1]["content"] 77 | user_query_request = "Generate search query for: " + original_user_query 78 | 79 | query_messages = self.get_messages_from_history( 80 | system_prompt=self.query_prompt_template, 81 | model_id=self.chatgpt_model, 82 | history=history, 83 | user_content=user_query_request, 84 | max_tokens=self.chatgpt_token_limit - len(user_query_request), 85 | few_shots=self.query_prompt_few_shots, 86 | ) 87 | 88 | chatgpt_args = {"deployment_id": self.chatgpt_deployment} if self.openai_host == "azure" else {} 89 | chat_completion = await openai.ChatCompletion.acreate( 90 | **chatgpt_args, 91 | model=self.chatgpt_model, 92 | messages=query_messages, 93 | temperature=0.0, 94 | max_tokens=100, # Setting too low risks malformed JSON, setting too high may affect performance 95 | n=1 96 | ) 97 | 98 | generated_query = chat_completion["choices"][0]["message"]["content"] 99 | 100 | if generated_query == self.NO_RESPONSE: 101 | # TODO: クエリがない場合は通常の会話をする 102 | query_not_found_msg ={ 103 | 'choices':[ 104 | { 105 | 'message':{ 106 | 'role':"assistant", 107 | 'content':"あなたの入力には知りたいことが含まれていないようです。何について調べますか?" 108 | } 109 | } 110 | ] 111 | } 112 | return ({}, query_not_found_msg) 113 | 114 | #print("Generated_query:"+generated_query) 115 | 116 | # Step2. クエリを使ってGraphを検索する 117 | client = GraphClientBuilder().get_client(obo_token) 118 | 119 | request_body = QueryPostRequestBody( 120 | requests=[ 121 | SearchRequest( 122 | entity_types=[EntityType.ListItem], 123 | query=SearchQuery( 124 | query_string=generated_query 125 | ), 126 | size=1 #取得するページのサイズ。いっぱい取得してもtoken上限で使わないので1でいい 127 | ) 128 | ] 129 | ) 130 | 131 | search_result = await client.search.query.post(body = request_body) 132 | 133 | #search_resultがない場合は、クエリ生成したクエリを返す 134 | if search_result.value[0].hits_containers[0].total == 0: 135 | source_not_found_msg ={ 136 | 'choices':[ 137 | { 138 | 'message':{ 139 | 'role':"assistant", 140 | 'content':f"「{generated_query}」で検索しましたが、情報源を見つけられませんでした。" 141 | } 142 | } 143 | ] 144 | } 145 | return ({}, source_not_found_msg) 146 | 147 | #print(search_result) 148 | #ここではsummaryをソースにしているが、文章量によってはコンテンツ別にデータを取ったほうがいいかもしれない 149 | results = [ 150 | hit.resource.id + ": " + hit.summary 151 | for hit in search_result.value[0].hits_containers[0].hits 152 | ] 153 | content = "\n".join(results) 154 | 155 | citaion_source = [ 156 | { 157 | "id": hit.resource.id, 158 | "web_url": hit.resource.web_url, 159 | "hit_id": hit.hit_id, 160 | "name": hit.resource.name or hit.resource.web_url.split("/")[-1] 161 | } for hit in search_result.value[0].hits_containers[0].hits 162 | ] 163 | 164 | # Step3. Graphから取得した結果をから回答を生成する 165 | response_token_limit = 1024 166 | messages_token_limit = self.chatgpt_token_limit - response_token_limit 167 | answer_messages = self.get_messages_from_history( 168 | system_prompt=self.system_message_chat_conversation, 169 | model_id=self.chatgpt_model, 170 | history=history, 171 | user_content=original_user_query + "\n\nSources:\n" + content, 172 | max_tokens=messages_token_limit, 173 | ) 174 | 175 | extra_info = { 176 | "data_points": citaion_source, 177 | } 178 | 179 | chat_coroutine = await openai.ChatCompletion.acreate( 180 | **chatgpt_args, 181 | model=self.chatgpt_model, 182 | messages=answer_messages, 183 | temperature=0, 184 | max_tokens=response_token_limit, 185 | n=1, 186 | stream=should_stream, 187 | ) 188 | 189 | return (extra_info, chat_coroutine) 190 | 191 | 192 | async def run_without_streaming( 193 | self, 194 | history: list[dict[str, str]], 195 | overrides: dict[str, Any], 196 | obo_token, 197 | session_state: Any = None, 198 | ) -> dict[str, Any]: 199 | extra_info, chat_coroutine = await self.run_simple_chat( 200 | history, obo_token, should_stream=False 201 | ) 202 | 203 | #extra_info, chat_coroutine = await self.run_until_final_call( 204 | # history, overrides, auth_claims, should_stream=False 205 | #) 206 | chat_resp = dict(chat_coroutine) 207 | chat_resp["choices"][0]["context"] = extra_info 208 | chat_resp["choices"][0]["session_state"] = session_state 209 | return chat_resp 210 | 211 | async def run_with_streaming( 212 | self, 213 | history: list[dict[str, str]], 214 | overrides: dict[str, Any], 215 | obo_token, 216 | session_state: Any = None, 217 | ) -> AsyncGenerator[dict, None]: 218 | extra_info, chat_coroutine = await self.run_simple_chat( 219 | history, overrides, should_stream=True 220 | ) 221 | yield { 222 | "choices": [ 223 | { 224 | "delta": {"role": self.ASSISTANT}, 225 | "context": extra_info, 226 | "session_state": session_state, 227 | "finish_reason": None, 228 | "index": 0, 229 | } 230 | ], 231 | "object": "chat.completion.chunk", 232 | } 233 | 234 | async for event in await chat_coroutine: 235 | # "2023-07-01-preview" API version has a bug where first response has empty choices 236 | if event["choices"]: 237 | yield event 238 | 239 | async def run( 240 | self, messages: list[dict], stream: bool = False, session_state: Any = None, context: dict[str, Any] = {} 241 | ) -> Union[dict[str, Any], AsyncGenerator[dict[str, Any], None]]: 242 | overrides = context.get("overrides", {}) 243 | obo_token = context.get("obo_token", {}) 244 | if stream is False: 245 | # Workaround for: https://github.com/openai/openai-python/issues/371 246 | async with aiohttp.ClientSession() as s: 247 | openai.aiosession.set(s) 248 | response = await self.run_without_streaming(messages, overrides,obo_token, session_state) 249 | return response 250 | else: 251 | return self.run_with_streaming(messages, overrides, obo_token, session_state) 252 | 253 | def get_messages_from_history( 254 | self, 255 | system_prompt: str, 256 | model_id: str, 257 | history: list[dict[str, str]], 258 | user_content: str, 259 | max_tokens: int, 260 | few_shots=[], 261 | ) -> list: 262 | message_builder = MessageBuilder(system_prompt, model_id) 263 | 264 | # 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. 265 | for shot in few_shots: 266 | message_builder.append_message(shot.get("role"), shot.get("content")) 267 | 268 | append_index = len(few_shots) + 1 269 | 270 | message_builder.append_message(self.USER, user_content, index=append_index) 271 | total_token_count = message_builder.count_tokens_for_message(message_builder.messages[-1]) 272 | 273 | newest_to_oldest = list(reversed(history[:-1])) 274 | for message in newest_to_oldest: 275 | potential_message_count = message_builder.count_tokens_for_message(message) 276 | if (total_token_count + potential_message_count) > max_tokens: 277 | logging.debug("Reached max tokens of %d, history will be truncated", max_tokens) 278 | break 279 | message_builder.append_message(message["role"], message["content"], index=append_index) 280 | total_token_count += potential_message_count 281 | return message_builder.messages 282 | 283 | def get_search_query(self, chat_completion: dict[str, Any], user_query: str): 284 | response_message = chat_completion["choices"][0]["message"] 285 | if function_call := response_message.get("function_call"): 286 | if function_call["name"] == "search_sources": 287 | arg = json.loads(function_call["arguments"]) 288 | search_query = arg.get("search_query", self.NO_RESPONSE) 289 | if search_query != self.NO_RESPONSE: 290 | return search_query 291 | elif query_text := response_message.get("content"): 292 | if query_text.strip() != self.NO_RESPONSE: 293 | return query_text 294 | return user_query 295 | 296 | 297 | 298 | '''参考元コード 299 | async def run_until_final_call( 300 | self, 301 | history: list[dict[str, str]], 302 | overrides: dict[str, Any], 303 | auth_claims: dict[str, Any], 304 | should_stream: bool = False, 305 | ) -> tuple: 306 | # content = clientからのリクエストボディのmessages→ユーザーからの最新の入力を取得している 307 | original_user_query = history[-1]["content"] 308 | # 検索クエリを作るためのリクエストを作成 309 | user_query_request = "Generate search query for: " + original_user_query 310 | 311 | 312 | # Doc検索のためのファンクションを定義 313 | functions = [ 314 | { 315 | "name": "search_sources", 316 | "description": "Retrieve sources from the Azure Cognitive Search index", 317 | "parameters": { 318 | "type": "object", 319 | "properties": { 320 | "search_query": { 321 | "type": "string", 322 | "description": "Query string to retrieve documents from azure search eg: 'Health care plan'", 323 | } 324 | }, 325 | "required": ["search_query"], 326 | }, 327 | } 328 | ] 329 | 330 | # STEP 1: チャット履歴と最後の質問に基づいて、最適化されたキーワード検索クエリを生成します。 331 | # システムプロンプトにクエリ生成用のテンプレートをセットしてクエリを生成するためのメッセージリストを生成 332 | messages = self.get_messages_from_history( 333 | system_prompt=self.query_prompt_template, 334 | model_id=self.chatgpt_model, 335 | history=history, 336 | user_content=user_query_request, 337 | max_tokens=self.chatgpt_token_limit - len(user_query_request), 338 | few_shots=self.query_prompt_few_shots, 339 | ) 340 | 341 | chatgpt_args = {"deployment_id": self.chatgpt_deployment} if self.openai_host == "azure" else {} 342 | chat_completion = await openai.ChatCompletion.acreate( 343 | **chatgpt_args, 344 | model=self.chatgpt_model, 345 | messages=messages, 346 | temperature=0.0, 347 | max_tokens=100, # Setting too low risks malformed JSON, setting too high may affect performance 348 | n=1, 349 | functions=functions, 350 | function_call="auto", 351 | ) 352 | 353 | # GPTから得られた結果(chat_completion)からFunction Calling用の引数またはGPTの返信そのものを使用してクエリを取得する。クエリが生成できなかった場合(chat_completion=0)は、ユーザーの質問をそのままクエリとする。 354 | query_text = self.get_search_query(chat_completion, original_user_query) 355 | 356 | # STEP 2: GPTに最適化されたクエリで検索インデックスから関連文書を取得する。 357 | follow_up_questions_prompt = ( 358 | self.follow_up_questions_prompt_content if overrides.get("suggest_followup_questions") else "" 359 | ) 360 | 361 | # STEP 3: Generate a contextual and content specific answer using the search results and chat history 362 | # Allow client to replace the entire prompt, or to inject into the exiting prompt using >>> 363 | prompt_override = overrides.get("prompt_template") 364 | if prompt_override is None: 365 | system_message = self.system_message_chat_conversation.format( 366 | injected_prompt="", follow_up_questions_prompt=follow_up_questions_prompt 367 | ) 368 | elif prompt_override.startswith(">>>"): 369 | system_message = self.system_message_chat_conversation.format( 370 | injected_prompt=prompt_override[3:] + "\n", follow_up_questions_prompt=follow_up_questions_prompt 371 | ) 372 | else: 373 | system_message = prompt_override.format(follow_up_questions_prompt=follow_up_questions_prompt) 374 | 375 | response_token_limit = 1024 376 | messages_token_limit = self.chatgpt_token_limit - response_token_limit 377 | messages = self.get_messages_from_history( 378 | system_prompt=system_message, 379 | model_id=self.chatgpt_model, 380 | history=history, 381 | # Model does not handle lengthy system messages well. Moving sources to latest user conversation to solve follow up questions prompt. 382 | #Document 1: This is the content of document 1. 383 | #Document 2: This is the content of document 2. 384 | user_content=original_user_query + "\n\nSources:\n" + content, 385 | max_tokens=messages_token_limit, 386 | ) 387 | msg_to_display = "\n\n".join([str(message) for message in messages]) 388 | 389 | #「sourcepage_field」フィールドには、検索結果が見つかったページまたはドキュメントの名前が含まれており、「content_field」フィールドには、検索結果の実際のコンテンツが含まれています。 390 | #サンプルresults = [ 391 | # "Document 1: This is the content of document 1.", 392 | # "Document 2: This is the content of document 2." 393 | #] 394 | extra_info = { 395 | "data_points": results, 396 | "thoughts": f"Searched for:
{query_text}

Conversations:
" 397 | + msg_to_display.replace("\n", "
"), 398 | } 399 | 400 | chat_coroutine = openai.ChatCompletion.acreate( 401 | **chatgpt_args, 402 | model=self.chatgpt_model, 403 | messages=messages, 404 | temperature=overrides.get("temperature") or 0.7, 405 | max_tokens=response_token_limit, 406 | n=1, 407 | stream=should_stream, 408 | ) 409 | return (extra_info, chat_coroutine) 410 | ''' 411 | -------------------------------------------------------------------------------- /src/backend/core/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/07JP27/azureopenai-internal-microsoft-search/185860d4aa7e1c026e3799b9b9102ed256c28d71/src/backend/core/__init__.py -------------------------------------------------------------------------------- /src/backend/core/authentication.py: -------------------------------------------------------------------------------- 1 | # Refactored from https://github.com/Azure-Samples/ms-identity-python-on-behalf-of 2 | 3 | import logging 4 | import os 5 | from tempfile import TemporaryDirectory 6 | from typing import Any, Optional 7 | 8 | from msal import ConfidentialClientApplication 9 | from msal_extensions import ( 10 | FilePersistence, 11 | PersistedTokenCache, 12 | build_encrypted_persistence, 13 | ) 14 | 15 | 16 | # AuthError is raised when the authentication token sent by the client UI cannot be parsed or there is an authentication error accessing the graph API 17 | class AuthError(Exception): 18 | def __init__(self, error, status_code): 19 | self.error = error 20 | self.status_code = status_code 21 | 22 | 23 | class AuthenticationHelper: 24 | scope: str = "https://graph.microsoft.com/.default" 25 | 26 | def __init__( 27 | self, 28 | use_authentication: bool, 29 | server_app_id: Optional[str], 30 | server_app_secret: Optional[str], 31 | client_app_id: Optional[str], 32 | tenant_id: Optional[str], 33 | token_cache_path: Optional[str] = None, 34 | ): 35 | self.use_authentication = use_authentication 36 | self.server_app_id = server_app_id 37 | self.server_app_secret = server_app_secret 38 | self.client_app_id = client_app_id 39 | self.tenant_id = tenant_id 40 | self.authority = f"https://login.microsoftonline.com/{tenant_id}" 41 | 42 | if self.use_authentication: 43 | self.token_cache_path = token_cache_path 44 | if not self.token_cache_path: 45 | self.temporary_directory = TemporaryDirectory() 46 | self.token_cache_path = os.path.join(self.temporary_directory.name, "token_cache.bin") 47 | try: 48 | persistence = build_encrypted_persistence(location=self.token_cache_path) 49 | except Exception: 50 | logging.exception("Encryption unavailable. Opting in to plain text.") 51 | persistence = FilePersistence(location=self.token_cache_path) 52 | self.confidential_client = ConfidentialClientApplication( 53 | server_app_id, 54 | authority=self.authority, 55 | client_credential=server_app_secret, 56 | token_cache=PersistedTokenCache(persistence), 57 | ) 58 | 59 | def get_auth_setup_for_client(self) -> dict[str, Any]: 60 | # returns MSAL.js settings used by the client app 61 | return { 62 | "useLogin": self.use_authentication, # Whether or not login elements are enabled on the UI 63 | "msalConfig": { 64 | "auth": { 65 | "clientId": self.client_app_id, # Client app id used for login 66 | "authority": self.authority, # Directory to use for login https://learn.microsoft.com/azure/active-directory/develop/msal-client-application-configuration#authority 67 | "redirectUri": "/redirect", # Points to window.location.origin. You must register this URI on Azure Portal/App Registration. 68 | "postLogoutRedirectUri": "/", # Indicates the page to navigate after logout. 69 | "navigateToLoginRequestUrl": False, # If "true", will navigate back to the original request location before processing the auth code response. 70 | }, 71 | "cache": { 72 | "cacheLocation": "sessionStorage", 73 | "storeAuthStateInCookie": False, 74 | }, # Configures cache location. "sessionStorage" is more secure, but "localStorage" gives you SSO between tabs. # Set this to "true" if you are having issues on IE11 or Edge 75 | }, 76 | "loginRequest": { 77 | # Scopes you add here will be prompted for user consent during sign-in. 78 | # By default, MSAL.js will add OIDC scopes (openid, profile, email) to any login request. 79 | # For more information about OIDC scopes, visit: 80 | # https://docs.microsoft.com/azure/active-directory/develop/v2-permissions-and-consent#openid-connect-scopes 81 | "scopes": [".default"], 82 | # Uncomment the following line to cause a consent dialog to appear on every login 83 | # For more information, please visit https://learn.microsoft.com/azure/active-directory/develop/v2-oauth2-auth-code-flow#request-an-authorization-code 84 | # "prompt": "consent" 85 | }, 86 | "tokenRequest": { 87 | "scopes": [f"api://{self.server_app_id}/access_as_user"], 88 | }, 89 | } 90 | 91 | @staticmethod 92 | def get_token_auth_header(headers: dict) -> str: 93 | # Obtains the Access Token from the Authorization Header 94 | auth = headers.get("Authorization", None) 95 | if not auth: 96 | raise AuthError( 97 | {"code": "authorization_header_missing", "description": "Authorization header is expected"}, 401 98 | ) 99 | 100 | parts = auth.split() 101 | 102 | if parts[0].lower() != "bearer": 103 | raise AuthError( 104 | {"code": "invalid_header", "description": "Authorization header must start with Bearer"}, 401 105 | ) 106 | elif len(parts) == 1: 107 | raise AuthError({"code": "invalid_header", "description": "Token not found"}, 401) 108 | elif len(parts) > 2: 109 | raise AuthError({"code": "invalid_header", "description": "Authorization header must be Bearer token"}, 401) 110 | 111 | token = parts[1] 112 | return token -------------------------------------------------------------------------------- /src/backend/core/graphclientbuilder.py: -------------------------------------------------------------------------------- 1 | from msgraph import GraphServiceClient 2 | from azure.identity.aio import OnBehalfOfCredential 3 | from quart import current_app 4 | import asyncio 5 | 6 | 7 | class GraphClientBuilder: 8 | def get_client(self, obo_token: str, scopes=['https://graph.microsoft.com/.default']): 9 | credential = OnBehalfOfCredential( 10 | tenant_id=current_app.config["TENANT_ID"] , 11 | client_id=current_app.config["CLIENT_ID"], 12 | client_secret=current_app.config["APP_SECRET"], 13 | user_assertion=obo_token) 14 | graph_client = GraphServiceClient(credential, scopes) 15 | return graph_client 16 | -------------------------------------------------------------------------------- /src/backend/core/messagebuilder.py: -------------------------------------------------------------------------------- 1 | import unicodedata 2 | 3 | from .modelhelper import num_tokens_from_messages 4 | 5 | 6 | class MessageBuilder: 7 | """ 8 | A class for building and managing messages in a chat conversation. 9 | Attributes: 10 | message (list): A list of dictionaries representing chat messages. 11 | model (str): The name of the ChatGPT model. 12 | token_count (int): The total number of tokens in the conversation. 13 | Methods: 14 | __init__(self, system_content: str, chatgpt_model: str): Initializes the MessageBuilder instance. 15 | append_message(self, role: str, content: str, index: int = 1): Appends a new message to the conversation. 16 | """ 17 | 18 | def __init__(self, system_content: str, chatgpt_model: str): 19 | self.messages = [{"role": "system", "content": self.normalize_content(system_content)}] 20 | self.model = chatgpt_model 21 | 22 | def append_message(self, role: str, content: str, index: int = 1): 23 | self.messages.insert(index, {"role": role, "content": self.normalize_content(content)}) 24 | 25 | def count_tokens_for_message(self, message: dict[str, str]): 26 | return num_tokens_from_messages(message, self.model) 27 | 28 | def normalize_content(self, content: str): 29 | return unicodedata.normalize("NFC", content) 30 | -------------------------------------------------------------------------------- /src/backend/core/modelhelper.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import tiktoken 4 | 5 | # Ref: https://learn.microsoft.com/ja-jp/azure/ai-services/openai/concepts/models?utm_source=chatgpt.com&tabs=python-secure%2Cglobal-standard%2Cstandard-chat-completions#gpt-4-and-gpt-4-turbo-models 6 | MODELS_2_TOKEN_LIMITS = { 7 | "gpt-35-turbo": 4000, 8 | "gpt-3.5-turbo": 4000, 9 | "gpt-35-turbo-16k": 16000, 10 | "gpt-3.5-turbo-16k": 16000, 11 | "gpt-4o": 128000, 12 | "gpt-4": 8100, 13 | "gpt-4-32k": 32000, 14 | } 15 | 16 | AOAI_2_OAI = {"gpt-35-turbo": "gpt-3.5-turbo", "gpt-35-turbo-16k": "gpt-3.5-turbo-16k"} 17 | 18 | 19 | def get_token_limit(model_id: str) -> int: 20 | if model_id not in MODELS_2_TOKEN_LIMITS: 21 | raise ValueError("Expected model gpt-35-turbo and above") 22 | return MODELS_2_TOKEN_LIMITS[model_id] 23 | 24 | 25 | def num_tokens_from_messages(message: dict[str, str], model: str) -> int: 26 | """ 27 | Calculate the number of tokens required to encode a message. 28 | Args: 29 | message (dict): The message to encode, represented as a dictionary. 30 | model (str): The name of the model to use for encoding. 31 | Returns: 32 | int: The total number of tokens required to encode the message. 33 | Example: 34 | message = {'role': 'user', 'content': 'Hello, how are you?'} 35 | model = 'gpt-3.5-turbo' 36 | num_tokens_from_messages(message, model) 37 | output: 11 38 | """ 39 | encoding = tiktoken.encoding_for_model(get_oai_chatmodel_tiktok(model)) 40 | num_tokens = 2 # For "role" and "content" keys 41 | for key, value in message.items(): 42 | num_tokens += len(encoding.encode(value)) 43 | return num_tokens 44 | 45 | 46 | def get_oai_chatmodel_tiktok(aoaimodel: str) -> str: 47 | message = "Expected Azure OpenAI ChatGPT model name" 48 | if aoaimodel == "" or aoaimodel is None: 49 | raise ValueError(message) 50 | if aoaimodel not in AOAI_2_OAI and aoaimodel not in MODELS_2_TOKEN_LIMITS: 51 | raise ValueError(message) 52 | return AOAI_2_OAI.get(aoaimodel) or aoaimodel 53 | -------------------------------------------------------------------------------- /src/backend/gunicorn.conf.py: -------------------------------------------------------------------------------- 1 | import multiprocessing 2 | 3 | max_requests = 1000 4 | max_requests_jitter = 50 5 | log_file = "-" 6 | bind = "0.0.0.0" 7 | 8 | timeout = 230 9 | # https://learn.microsoft.com/en-us/troubleshoot/azure/app-service/web-apps-performance-faqs#why-does-my-request-time-out-after-230-seconds 10 | 11 | num_cpus = multiprocessing.cpu_count() 12 | workers = (num_cpus * 2) + 1 13 | worker_class = "uvicorn.workers.UvicornWorker" 14 | -------------------------------------------------------------------------------- /src/backend/main.py: -------------------------------------------------------------------------------- 1 | import os 2 | from app import create_app 3 | from dotenv import load_dotenv 4 | 5 | load_dotenv() 6 | app = create_app() 7 | -------------------------------------------------------------------------------- /src/backend/requirements.in: -------------------------------------------------------------------------------- 1 | azure-identity 2 | quart 3 | quart-cors 4 | openai[datalib] 5 | tiktoken 6 | azure-search-documents==11.4.0b6 7 | azure-storage-blob 8 | uvicorn[standard] 9 | aiohttp 10 | azure-monitor-opentelemetry 11 | opentelemetry-instrumentation-asgi 12 | opentelemetry-instrumentation-requests 13 | opentelemetry-instrumentation-aiohttp-client 14 | msal 15 | msal-extensions 16 | msgraph-sdk==1.0.0a14 -------------------------------------------------------------------------------- /src/backend/requirements.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile with Python 3.11 3 | # by the following command: 4 | # 5 | # pip-compile requirements.in 6 | # 7 | aiofiles==23.2.1 8 | # via quart 9 | aiohttp==3.8.5 10 | # via 11 | # -r requirements.in 12 | # openai 13 | aiosignal==1.3.1 14 | # via aiohttp 15 | anyio==3.7.1 16 | # via watchfiles 17 | asgiref==3.7.2 18 | # via opentelemetry-instrumentation-asgi 19 | async-timeout==4.0.3 20 | # via aiohttp 21 | attrs==23.1.0 22 | # via aiohttp 23 | azure-common==1.1.28 24 | # via azure-search-documents 25 | azure-core==1.29.4 26 | # via 27 | # azure-core-tracing-opentelemetry 28 | # azure-identity 29 | # azure-monitor-opentelemetry 30 | # azure-monitor-opentelemetry-exporter 31 | # azure-search-documents 32 | # azure-storage-blob 33 | # msrest 34 | azure-core-tracing-opentelemetry==1.0.0b11 35 | # via azure-monitor-opentelemetry 36 | azure-identity==1.14.0 37 | # via -r requirements.in 38 | azure-monitor-opentelemetry==1.0.0 39 | # via -r requirements.in 40 | azure-monitor-opentelemetry-exporter==1.0.0b17 41 | # via azure-monitor-opentelemetry 42 | azure-search-documents==11.4.0b6 43 | # via -r requirements.in 44 | azure-storage-blob==12.18.2 45 | # via -r requirements.in 46 | blinker==1.6.2 47 | # via 48 | # flask 49 | # quart 50 | certifi==2023.7.22 51 | # via 52 | # msrest 53 | # requests 54 | cffi==1.16.0 55 | # via cryptography 56 | charset-normalizer==3.3.0 57 | # via 58 | # aiohttp 59 | # requests 60 | click==8.1.7 61 | # via 62 | # flask 63 | # quart 64 | # uvicorn 65 | cryptography==41.0.4 66 | # via 67 | # azure-identity 68 | # azure-storage-blob 69 | # msal 70 | # pyjwt 71 | deprecated==1.2.14 72 | # via opentelemetry-api 73 | et-xmlfile==1.1.0 74 | # via openpyxl 75 | fixedint==0.1.6 76 | # via azure-monitor-opentelemetry-exporter 77 | flask==3.0.0 78 | # via quart 79 | frozenlist==1.4.0 80 | # via 81 | # aiohttp 82 | # aiosignal 83 | h11==0.14.0 84 | # via 85 | # hypercorn 86 | # uvicorn 87 | # wsproto 88 | h2==4.1.0 89 | # via hypercorn 90 | hpack==4.0.0 91 | # via h2 92 | httptools==0.6.0 93 | # via uvicorn 94 | hypercorn==0.14.4 95 | # via quart 96 | hyperframe==6.0.1 97 | # via h2 98 | idna==3.4 99 | # via 100 | # anyio 101 | # requests 102 | # yarl 103 | importlib-metadata==6.8.0 104 | # via opentelemetry-api 105 | isodate==0.6.1 106 | # via 107 | # azure-search-documents 108 | # azure-storage-blob 109 | # msrest 110 | itsdangerous==2.1.2 111 | # via 112 | # flask 113 | # quart 114 | jinja2==3.1.2 115 | # via 116 | # flask 117 | # quart 118 | markupsafe==2.1.3 119 | # via 120 | # jinja2 121 | # quart 122 | # werkzeug 123 | msal==1.24.1 124 | # via 125 | # -r requirements.in 126 | # azure-identity 127 | # msal-extensions 128 | msal-extensions==1.0.0 129 | # via 130 | # -r requirements.in 131 | # azure-identity 132 | msrest==0.7.1 133 | # via azure-monitor-opentelemetry-exporter 134 | multidict==6.0.4 135 | # via 136 | # aiohttp 137 | # yarl 138 | numpy==1.26.0 139 | # via 140 | # openai 141 | # pandas 142 | # pandas-stubs 143 | oauthlib==3.2.2 144 | # via requests-oauthlib 145 | openai[datalib]==0.28.1 146 | # via -r requirements.in 147 | openpyxl==3.1.2 148 | # via openai 149 | opentelemetry-api==1.20.0 150 | # via 151 | # azure-core-tracing-opentelemetry 152 | # azure-monitor-opentelemetry-exporter 153 | # opentelemetry-instrumentation 154 | # opentelemetry-instrumentation-aiohttp-client 155 | # opentelemetry-instrumentation-asgi 156 | # opentelemetry-instrumentation-dbapi 157 | # opentelemetry-instrumentation-django 158 | # opentelemetry-instrumentation-fastapi 159 | # opentelemetry-instrumentation-flask 160 | # opentelemetry-instrumentation-psycopg2 161 | # opentelemetry-instrumentation-requests 162 | # opentelemetry-instrumentation-urllib 163 | # opentelemetry-instrumentation-urllib3 164 | # opentelemetry-instrumentation-wsgi 165 | # opentelemetry-sdk 166 | opentelemetry-instrumentation==0.41b0 167 | # via 168 | # opentelemetry-instrumentation-aiohttp-client 169 | # opentelemetry-instrumentation-asgi 170 | # opentelemetry-instrumentation-dbapi 171 | # opentelemetry-instrumentation-django 172 | # opentelemetry-instrumentation-fastapi 173 | # opentelemetry-instrumentation-flask 174 | # opentelemetry-instrumentation-psycopg2 175 | # opentelemetry-instrumentation-requests 176 | # opentelemetry-instrumentation-urllib 177 | # opentelemetry-instrumentation-urllib3 178 | # opentelemetry-instrumentation-wsgi 179 | opentelemetry-instrumentation-aiohttp-client==0.41b0 180 | # via -r requirements.in 181 | opentelemetry-instrumentation-asgi==0.41b0 182 | # via 183 | # -r requirements.in 184 | # opentelemetry-instrumentation-fastapi 185 | opentelemetry-instrumentation-dbapi==0.41b0 186 | # via opentelemetry-instrumentation-psycopg2 187 | opentelemetry-instrumentation-django==0.41b0 188 | # via azure-monitor-opentelemetry 189 | opentelemetry-instrumentation-fastapi==0.41b0 190 | # via azure-monitor-opentelemetry 191 | opentelemetry-instrumentation-flask==0.41b0 192 | # via azure-monitor-opentelemetry 193 | opentelemetry-instrumentation-psycopg2==0.41b0 194 | # via azure-monitor-opentelemetry 195 | opentelemetry-instrumentation-requests==0.41b0 196 | # via 197 | # -r requirements.in 198 | # azure-monitor-opentelemetry 199 | opentelemetry-instrumentation-urllib==0.41b0 200 | # via azure-monitor-opentelemetry 201 | opentelemetry-instrumentation-urllib3==0.41b0 202 | # via azure-monitor-opentelemetry 203 | opentelemetry-instrumentation-wsgi==0.41b0 204 | # via 205 | # opentelemetry-instrumentation-django 206 | # opentelemetry-instrumentation-flask 207 | opentelemetry-resource-detector-azure==0.1.0 208 | # via azure-monitor-opentelemetry 209 | opentelemetry-sdk==1.20.0 210 | # via 211 | # azure-monitor-opentelemetry-exporter 212 | # opentelemetry-resource-detector-azure 213 | opentelemetry-semantic-conventions==0.41b0 214 | # via 215 | # opentelemetry-instrumentation-aiohttp-client 216 | # opentelemetry-instrumentation-asgi 217 | # opentelemetry-instrumentation-dbapi 218 | # opentelemetry-instrumentation-django 219 | # opentelemetry-instrumentation-fastapi 220 | # opentelemetry-instrumentation-flask 221 | # opentelemetry-instrumentation-requests 222 | # opentelemetry-instrumentation-urllib 223 | # opentelemetry-instrumentation-urllib3 224 | # opentelemetry-instrumentation-wsgi 225 | # opentelemetry-sdk 226 | opentelemetry-util-http==0.41b0 227 | # via 228 | # opentelemetry-instrumentation-aiohttp-client 229 | # opentelemetry-instrumentation-asgi 230 | # opentelemetry-instrumentation-django 231 | # opentelemetry-instrumentation-fastapi 232 | # opentelemetry-instrumentation-flask 233 | # opentelemetry-instrumentation-requests 234 | # opentelemetry-instrumentation-urllib 235 | # opentelemetry-instrumentation-urllib3 236 | # opentelemetry-instrumentation-wsgi 237 | packaging==23.1 238 | # via opentelemetry-instrumentation-flask 239 | pandas==2.1.1 240 | # via openai 241 | pandas-stubs==2.1.1.230928 242 | # via openai 243 | portalocker==2.8.2 244 | # via msal-extensions 245 | priority==2.0.0 246 | # via hypercorn 247 | pycparser==2.21 248 | # via cffi 249 | pyjwt[crypto]==2.8.0 250 | # via msal 251 | python-dateutil==2.8.2 252 | # via pandas 253 | python-dotenv==1.0.0 254 | # via uvicorn 255 | pytz==2023.3.post1 256 | # via pandas 257 | pyyaml==6.0.1 258 | # via uvicorn 259 | quart==0.19.3 260 | # via 261 | # -r requirements.in 262 | # quart-cors 263 | quart-cors==0.7.0 264 | # via -r requirements.in 265 | regex==2023.8.8 266 | # via tiktoken 267 | requests==2.31.0 268 | # via 269 | # azure-core 270 | # msal 271 | # msrest 272 | # openai 273 | # requests-oauthlib 274 | # tiktoken 275 | requests-oauthlib==1.3.1 276 | # via msrest 277 | six==1.16.0 278 | # via 279 | # azure-core 280 | # isodate 281 | # python-dateutil 282 | sniffio==1.3.0 283 | # via anyio 284 | tiktoken==0.8.0 285 | # via -r requirements.in 286 | tqdm==4.66.1 287 | # via openai 288 | types-pytz==2023.3.1.1 289 | # via pandas-stubs 290 | typing-extensions==4.8.0 291 | # via 292 | # azure-core 293 | # azure-storage-blob 294 | # opentelemetry-sdk 295 | tzdata==2023.3 296 | # via pandas 297 | urllib3==2.0.7 298 | # via requests 299 | uvicorn==0.23.2 300 | # via -r requirements.in 301 | # uvloop==0.17.0 302 | # via uvicorn 303 | watchfiles==0.20.0 304 | # via uvicorn 305 | websockets==11.0.3 306 | # via uvicorn 307 | werkzeug==3.0.0 308 | # via 309 | # flask 310 | # quart 311 | wrapt==1.15.0 312 | # via 313 | # deprecated 314 | # opentelemetry-instrumentation 315 | # opentelemetry-instrumentation-aiohttp-client 316 | # opentelemetry-instrumentation-dbapi 317 | # opentelemetry-instrumentation-urllib3 318 | wsproto==1.2.0 319 | # via hypercorn 320 | yarl==1.9.2 321 | # via aiohttp 322 | zipp==3.17.0 323 | # via importlib-metadata 324 | 325 | # The following packages are considered to be unsafe in a requirements file: 326 | # setuptools 327 | 328 | microsoft-kiota-abstractions==0.8.5 329 | msgraph-sdk==1.0.0a15 -------------------------------------------------------------------------------- /src/backend/text.py: -------------------------------------------------------------------------------- 1 | def nonewlines(s: str) -> str: 2 | return s.replace("\n", " ").replace("\r", " ") 3 | -------------------------------------------------------------------------------- /src/frontend/.vite/deps_temp_2b074880/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "module" 3 | } 4 | -------------------------------------------------------------------------------- /src/frontend/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | GPT + Enterprise data | Sample 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "frontend", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "engines": { 7 | "node": ">=14.0.0" 8 | }, 9 | "scripts": { 10 | "dev": "vite", 11 | "build": "tsc && vite build", 12 | "preview": "vite preview" 13 | }, 14 | "dependencies": { 15 | "@azure/msal-react": "^2.0.4", 16 | "@azure/msal-browser": "^3.1.0", 17 | "@fluentui/react": "^8.112.3", 18 | "@fluentui/react-components": "^9.35.0", 19 | "@fluentui/react-icons": "^2.0.220", 20 | "@react-spring/web": "^9.7.3", 21 | "dompurify": "^3.0.6", 22 | "react": "^18.2.0", 23 | "react-dom": "^18.2.0", 24 | "react-router-dom": "^6.16.0", 25 | "ndjson-readablestream": "^1.0.7", 26 | "scheduler": "^0.20.2" 27 | }, 28 | "devDependencies": { 29 | "@types/dompurify": "^3.0.3", 30 | "@types/react": "^18.2.28", 31 | "@types/react-dom": "^18.2.13", 32 | "@vitejs/plugin-react": "^4.1.0", 33 | "prettier": "^3.0.3", 34 | "typescript": "^5.2.2", 35 | "vite": "^4.4.11" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/frontend/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/07JP27/azureopenai-internal-microsoft-search/185860d4aa7e1c026e3799b9b9102ed256c28d71/src/frontend/public/favicon.ico -------------------------------------------------------------------------------- /src/frontend/src/api/api.ts: -------------------------------------------------------------------------------- 1 | const BACKEND_URI = ""; 2 | 3 | import { ChatAppResponse, ChatAppResponseOrError, ChatAppRequest } from "./models"; 4 | import { useLogin } from "../authConfig"; 5 | 6 | function getHeaders(idToken: string | undefined): Record { 7 | var headers : Record = { 8 | "Content-Type": "application/json" 9 | }; 10 | // If using login, add the id token of the logged in account as the authorization 11 | if (useLogin) { 12 | if (idToken) { 13 | headers["Authorization"] = `Bearer ${idToken}` 14 | } 15 | } 16 | 17 | return headers; 18 | } 19 | 20 | export async function askApi(request: ChatAppRequest, idToken: string | undefined): Promise { 21 | const response = await fetch(`${BACKEND_URI}/ask`, { 22 | method: "POST", 23 | headers: getHeaders(idToken), 24 | body: JSON.stringify(request) 25 | }); 26 | 27 | const parsedResponse: ChatAppResponseOrError = await response.json(); 28 | if (response.status > 299 || !response.ok) { 29 | throw Error(parsedResponse.error || "Unknown error"); 30 | } 31 | 32 | return parsedResponse as ChatAppResponse; 33 | } 34 | 35 | export async function chatApi(request: ChatAppRequest, idToken: string | undefined): Promise { 36 | return await fetch(`${BACKEND_URI}/chat`, { 37 | method: "POST", 38 | headers: getHeaders(idToken), 39 | body: JSON.stringify(request) 40 | }); 41 | } 42 | 43 | export function getCitationFilePath(citation: string): string { 44 | return `${BACKEND_URI}/content/${citation}`; 45 | } 46 | -------------------------------------------------------------------------------- /src/frontend/src/api/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./api"; 2 | export * from "./models"; 3 | -------------------------------------------------------------------------------- /src/frontend/src/api/models.ts: -------------------------------------------------------------------------------- 1 | export const enum RetrievalMode { 2 | Hybrid = "hybrid", 3 | Vectors = "vectors", 4 | Text = "text" 5 | } 6 | 7 | export type ChatAppRequestOverrides = { 8 | retrieval_mode?: RetrievalMode; 9 | semantic_ranker?: boolean; 10 | semantic_captions?: boolean; 11 | exclude_category?: string; 12 | top?: number; 13 | temperature?: number; 14 | prompt_template?: string; 15 | prompt_template_prefix?: string; 16 | prompt_template_suffix?: string; 17 | suggest_followup_questions?: boolean; 18 | use_oid_security_filter?: boolean; 19 | use_groups_security_filter?: boolean; 20 | }; 21 | 22 | export type ResponseMessage = { 23 | content: string; 24 | role: string; 25 | } 26 | 27 | export type ResponseContext = { 28 | thoughts: string | null; 29 | data_points: DataPoint[]; 30 | } 31 | 32 | export type DataPoint = { 33 | id: string; 34 | name: string; 35 | web_url: string; 36 | hit_id: string; 37 | } 38 | 39 | export type ResponseChoice = { 40 | index: number; 41 | message: ResponseMessage; 42 | context: ResponseContext; 43 | session_state: any; 44 | }; 45 | 46 | export type ChatAppResponseOrError = { 47 | choices?: ResponseChoice[]; 48 | error?: string; 49 | }; 50 | 51 | export type ChatAppResponse = { 52 | choices: ResponseChoice[]; 53 | }; 54 | 55 | export type ChatAppRequestContext = { 56 | overrides?: ChatAppRequestOverrides; 57 | } 58 | 59 | export type ChatAppRequest = { 60 | messages: ResponseMessage[]; 61 | context?: ChatAppRequestContext; 62 | stream?: boolean; 63 | session_state: any; 64 | }; 65 | -------------------------------------------------------------------------------- /src/frontend/src/assets/github.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/frontend/src/assets/search.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/frontend/src/authConfig.ts: -------------------------------------------------------------------------------- 1 | // Refactored from https://github.com/Azure-Samples/ms-identity-javascript-react-tutorial/blob/main/1-Authentication/1-sign-in/SPA/src/authConfig.js 2 | 3 | import { AuthenticationResult, IPublicClientApplication } from "@azure/msal-browser"; 4 | 5 | interface AuthSetup { 6 | // Set to true if login elements should be shown in the UI 7 | useLogin: boolean; 8 | /** 9 | * Configuration object to be passed to MSAL instance on creation. 10 | * For a full list of MSAL.js configuration parameters, visit: 11 | * https://github.com/AzureAD/microsoft-authentication-library-for-js/blob/dev/lib/msal-browser/docs/configuration.md 12 | */ 13 | msalConfig: { 14 | auth: { 15 | clientId: string, // Client app id used for login 16 | authority: string, // Directory to use for login https://learn.microsoft.com/azure/active-directory/develop/msal-client-application-configuration#authority 17 | redirectUri: string, // Points to window.location.origin. You must register this URI on Azure Portal/App Registration. 18 | postLogoutRedirectUri: string, // Indicates the page to navigate after logout. 19 | navigateToLoginRequestUrl: boolean // If "true", will navigate back to the original request location before processing the auth code response. 20 | }, 21 | cache: { 22 | cacheLocation: string, // Configures cache location. "sessionStorage" is more secure, but "localStorage" gives you SSO between tabs. 23 | storeAuthStateInCookie: boolean // Set this to "true" if you are having issues on IE11 or Edge 24 | } 25 | }, 26 | loginRequest: { 27 | /** 28 | * Scopes you add here will be prompted for user consent during sign-in. 29 | * By default, MSAL.js will add OIDC scopes (openid, profile, email) to any login request. 30 | * For more information about OIDC scopes, visit: 31 | * https://docs.microsoft.com/azure/active-directory/develop/v2-permissions-and-consent#openid-connect-scopes 32 | */ 33 | scopes: Array 34 | }, 35 | tokenRequest: { 36 | scopes: Array 37 | } 38 | } 39 | 40 | // Fetch the auth setup JSON data from the API if not already cached 41 | async function fetchAuthSetup(): Promise { 42 | const response = await fetch('/auth_setup'); 43 | if (!response.ok) { 44 | throw new Error(`auth setup response was not ok: ${response.status}`); 45 | } 46 | return await response.json(); 47 | } 48 | 49 | const authSetup = await fetchAuthSetup(); 50 | 51 | export const useLogin = authSetup.useLogin; 52 | 53 | /** 54 | * Configuration object to be passed to MSAL instance on creation. 55 | * For a full list of MSAL.js configuration parameters, visit: 56 | * https://github.com/AzureAD/microsoft-authentication-library-for-js/blob/dev/lib/msal-browser/docs/configuration.md 57 | */ 58 | export const msalConfig = authSetup.msalConfig; 59 | 60 | /** 61 | * Scopes you add here will be prompted for user consent during sign-in. 62 | * By default, MSAL.js will add OIDC scopes (openid, profile, email) to any login request. 63 | * For more information about OIDC scopes, visit: 64 | * https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-permissions-and-consent#openid-connect-scopes 65 | */ 66 | export const loginRequest = authSetup.loginRequest; 67 | 68 | const tokenRequest = authSetup.tokenRequest; 69 | 70 | // Build an absolute redirect URI using the current window's location and the relative redirect URI from auth setup 71 | export const getRedirectUri = () => { 72 | return window.location.origin + authSetup.msalConfig.auth.redirectUri 73 | } 74 | 75 | // Get an access token for use with the API server. 76 | // ID token received when logging in may not be used for this purpose because it has the incorrect audience 77 | export const getToken = (client: IPublicClientApplication): Promise => { 78 | return client.acquireTokenSilent({ 79 | ...tokenRequest, 80 | redirectUri: getRedirectUri() 81 | }) 82 | .catch((error) => { 83 | console.log(error); 84 | return undefined; 85 | }) 86 | } 87 | -------------------------------------------------------------------------------- /src/frontend/src/components/AnalysisPanel/AnalysisPanel.module.css: -------------------------------------------------------------------------------- 1 | .thoughtProcess { 2 | font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New", monospace; 3 | word-wrap: break-word; 4 | padding-top: 12px; 5 | padding-bottom: 12px; 6 | } 7 | -------------------------------------------------------------------------------- /src/frontend/src/components/AnalysisPanel/AnalysisPanel.tsx: -------------------------------------------------------------------------------- 1 | import { Pivot, PivotItem } from "@fluentui/react"; 2 | import DOMPurify from "dompurify"; 3 | 4 | import styles from "./AnalysisPanel.module.css"; 5 | 6 | import { SupportingContent } from "../SupportingContent"; 7 | import { ChatAppResponse } from "../../api"; 8 | import { AnalysisPanelTabs } from "./AnalysisPanelTabs"; 9 | 10 | interface Props { 11 | className: string; 12 | activeTab: AnalysisPanelTabs; 13 | onActiveTabChanged: (tab: AnalysisPanelTabs) => void; 14 | activeCitation: string | undefined; 15 | activeFileName: string | undefined; 16 | activeWebURL: string | undefined; 17 | citationHeight: string; 18 | answer: ChatAppResponse; 19 | } 20 | 21 | const pivotItemDisabledStyle = { disabled: true, style: { color: "grey" } }; 22 | 23 | export const AnalysisPanel = ({ answer, activeTab, activeCitation, activeFileName, activeWebURL, citationHeight, className, onActiveTabChanged }: Props) => { 24 | const isDisabledThoughtProcessTab: boolean = !answer.choices[0].context.thoughts; 25 | const isDisabledSupportingContentTab: boolean = !answer.choices[0].context.data_points.length; 26 | const isDisabledCitationTab: boolean = !activeCitation; 27 | 28 | const sanitizedThoughts = DOMPurify.sanitize(answer.choices[0].context.thoughts!); 29 | 30 | const switchIframeURLByFileType = () => { 31 | if (!activeFileName) { 32 | return '' 33 | } 34 | 35 | const pathWithoutLastSegment = activeWebURL?.split('/').slice(0, -2).join('/'); 36 | 37 | const fileExtension = activeFileName.split('.').pop()?.toLowerCase(); 38 | switch (fileExtension) { 39 | case 'pptx': 40 | return `https://m365x52168024.sharepoint.com/_layouts/15/Doc.aspx?sourcedoc={${activeCitation}}&action=embedview&wdAr=1.7777777777777777`; 41 | case 'xlsx': 42 | return `${pathWithoutLastSegment}/_layouts/15/Doc.aspx?sourcedoc={${activeCitation}}&action=embedview&wdAllowInteractivity=False&wdDownloadButton=True&wdInConfigurator=True&wdInConfigurator=True` 43 | case 'docx': 44 | return `${pathWithoutLastSegment}/_layouts/15/Doc.aspx?sourcedoc={${activeCitation}}&action=embedview` 45 | case 'pdf': 46 | return `${pathWithoutLastSegment}/_layouts/15/embed.aspx?UniqueId=${activeCitation}` 47 | default: 48 | // もう少し良い方法があるかも 49 | return `${pathWithoutLastSegment}/_layouts/15/embed.aspx?UniqueId=${activeCitation}` 50 | } 51 | } 52 | 53 | 54 | return ( 55 | pivotItem && onActiveTabChanged(pivotItem.props.itemKey! as AnalysisPanelTabs)} 59 | > 60 | 65 |
66 |
67 | 72 | 73 | 74 | 79 | 94 | 95 |
96 | ); 97 | }; 98 | -------------------------------------------------------------------------------- /src/frontend/src/components/AnalysisPanel/AnalysisPanelTabs.tsx: -------------------------------------------------------------------------------- 1 | export enum AnalysisPanelTabs { 2 | ThoughtProcessTab = "thoughtProcess", 3 | SupportingContentTab = "supportingContent", 4 | CitationTab = "citation" 5 | } 6 | -------------------------------------------------------------------------------- /src/frontend/src/components/AnalysisPanel/index.tsx: -------------------------------------------------------------------------------- 1 | export * from "./AnalysisPanel"; 2 | export * from "./AnalysisPanelTabs"; 3 | -------------------------------------------------------------------------------- /src/frontend/src/components/Answer/Answer.module.css: -------------------------------------------------------------------------------- 1 | .answerContainer { 2 | padding: 20px; 3 | background: rgb(249, 249, 249); 4 | border-radius: 8px; 5 | box-shadow: 0px 2px 4px rgba(0, 0, 0, 0.14), 0px 0px 2px rgba(0, 0, 0, 0.12); 6 | outline: transparent solid 1px; 7 | } 8 | 9 | .answerLogo { 10 | font-size: 28px; 11 | } 12 | 13 | .answerText { 14 | font-size: 16px; 15 | font-weight: 400; 16 | line-height: 22px; 17 | padding-top: 16px; 18 | padding-bottom: 16px; 19 | white-space: pre-line; 20 | } 21 | 22 | .answerText table { 23 | border-collapse: collapse; 24 | } 25 | 26 | .answerText td, 27 | .answerText th { 28 | border: 1px solid; 29 | padding: 5px; 30 | } 31 | 32 | .selected { 33 | outline: 2px solid rgba(115, 118, 225, 1); 34 | } 35 | 36 | .citationLearnMore { 37 | margin-right: 5px; 38 | font-weight: 600; 39 | line-height: 24px; 40 | } 41 | 42 | .citation { 43 | font-weight: 500; 44 | line-height: 24px; 45 | text-align: center; 46 | border-radius: 4px; 47 | padding: 0px 8px; 48 | background: #d1dbfa; 49 | color: #123bb6; 50 | text-decoration: none; 51 | cursor: pointer; 52 | } 53 | 54 | .citation:hover { 55 | text-decoration: underline; 56 | } 57 | 58 | .followupQuestionsList { 59 | margin-top: 10px; 60 | } 61 | 62 | .followupQuestionLearnMore { 63 | margin-right: 5px; 64 | font-weight: 600; 65 | line-height: 24px; 66 | } 67 | 68 | .followupQuestion { 69 | font-weight: 600; 70 | line-height: 24px; 71 | text-align: center; 72 | border-radius: 4px; 73 | padding: 0px 8px; 74 | background: #e8ebfa; 75 | color: black; 76 | font-style: italic; 77 | text-decoration: none; 78 | cursor: pointer; 79 | } 80 | 81 | .supContainer { 82 | text-decoration: none; 83 | cursor: pointer; 84 | } 85 | 86 | .supContainer:hover { 87 | text-decoration: underline; 88 | } 89 | 90 | sup { 91 | position: relative; 92 | display: inline-flex; 93 | align-items: center; 94 | justify-content: center; 95 | font-size: 10px; 96 | font-weight: 600; 97 | vertical-align: top; 98 | top: -1; 99 | margin: 0px 2px; 100 | min-width: 14px; 101 | height: 14px; 102 | border-radius: 3px; 103 | background: #d1dbfa; 104 | color: #123bb6; 105 | text-decoration-color: transparent; 106 | outline: transparent solid 1px; 107 | cursor: pointer; 108 | } 109 | 110 | .retryButton { 111 | width: fit-content; 112 | } 113 | 114 | @keyframes loading { 115 | 0% { 116 | content: ""; 117 | } 118 | 25% { 119 | content: "."; 120 | } 121 | 50% { 122 | content: ".."; 123 | } 124 | 75% { 125 | content: "..."; 126 | } 127 | 100% { 128 | content: ""; 129 | } 130 | } 131 | 132 | .loadingdots::after { 133 | content: ""; 134 | animation: loading 1s infinite; 135 | } 136 | -------------------------------------------------------------------------------- /src/frontend/src/components/Answer/Answer.tsx: -------------------------------------------------------------------------------- 1 | import { useMemo } from "react"; 2 | import { Stack, IconButton } from "@fluentui/react"; 3 | import DOMPurify from "dompurify"; 4 | 5 | import styles from "./Answer.module.css"; 6 | 7 | import { ChatAppResponse, getCitationFilePath } from "../../api"; 8 | import { parseAnswerToHtml, getSourceInfomation } from "./AnswerParser"; 9 | import { AnswerIcon } from "./AnswerIcon"; 10 | 11 | interface Props { 12 | answer: ChatAppResponse; 13 | isSelected?: boolean; 14 | isStreaming: boolean; 15 | onCitationClicked: (hit_id: string, web_url: string, file_name: string) => void; 16 | onThoughtProcessClicked: () => void; 17 | onSupportingContentClicked: () => void; 18 | onFollowupQuestionClicked?: (question: string) => void; 19 | showFollowupQuestions?: boolean; 20 | } 21 | 22 | export const Answer = ({ 23 | answer, 24 | isSelected, 25 | isStreaming, 26 | onCitationClicked, 27 | onThoughtProcessClicked, 28 | onSupportingContentClicked, 29 | onFollowupQuestionClicked, 30 | showFollowupQuestions 31 | }: Props) => { 32 | const messageContent = answer.choices[0].message.content; 33 | const parsedAnswer = useMemo(() => parseAnswerToHtml(messageContent, isStreaming, onCitationClicked ), [answer]); 34 | 35 | const sanitizedAnswerHtml = DOMPurify.sanitize(parsedAnswer.answerHtml); 36 | 37 | return ( 38 | 39 | 40 | 41 | 42 |
43 | onThoughtProcessClicked()} 49 | disabled={!answer.choices[0].context.thoughts?.length} 50 | /> 51 | onSupportingContentClicked()} 57 | disabled={!answer.choices[0].context.data_points?.length} 58 | /> 59 |
60 |
61 |
62 | 63 | 64 |
65 |
66 | 67 | {!!parsedAnswer.citations.length && ( 68 | 69 | 70 | 引用: 71 | {parsedAnswer.citations.map((x, i) => { 72 | const source = getSourceInfomation(answer.choices[0].context.data_points ,x); 73 | return ( 74 | onCitationClicked(source.hit_id, source.web_url, source.name)}> 75 | {`${++i}. ${source.name}`} 76 | 77 | ); 78 | })} 79 | 80 | 81 | )} 82 | 83 | {!!parsedAnswer.followupQuestions.length && showFollowupQuestions && onFollowupQuestionClicked && ( 84 | 85 | 86 | Follow-up questions: 87 | {parsedAnswer.followupQuestions.map((x, i) => { 88 | return ( 89 | onFollowupQuestionClicked(x)}> 90 | {`${x}`} 91 | 92 | ); 93 | })} 94 | 95 | 96 | )} 97 |
98 | ); 99 | }; 100 | -------------------------------------------------------------------------------- /src/frontend/src/components/Answer/AnswerError.tsx: -------------------------------------------------------------------------------- 1 | import { Stack, PrimaryButton } from "@fluentui/react"; 2 | import { ErrorCircle24Regular } from "@fluentui/react-icons"; 3 | 4 | import styles from "./Answer.module.css"; 5 | 6 | interface Props { 7 | error: string; 8 | onRetry: () => void; 9 | } 10 | 11 | export const AnswerError = ({ error, onRetry }: Props) => { 12 | return ( 13 | 14 | 22 | ); 23 | }; 24 | -------------------------------------------------------------------------------- /src/frontend/src/components/Answer/AnswerIcon.tsx: -------------------------------------------------------------------------------- 1 | import { Sparkle28Filled } from "@fluentui/react-icons"; 2 | 3 | export const AnswerIcon = () => { 4 | return