diff --git a/.github/workflows/CAdeploy.yml b/.github/workflows/CAdeploy.yml new file mode 100644 index 000000000..9dc156edc --- /dev/null +++ b/.github/workflows/CAdeploy.yml @@ -0,0 +1,128 @@ +name: CI-Validate Deployment-Client Advisor + +on: + push: + branches: + - main + paths: + - 'ClientAdvisor/**' + +jobs: + deploy: + runs-on: ubuntu-latest + + steps: + - name: Checkout Code + uses: actions/checkout@v3 + + - name: Setup Azure CLI + run: | + curl -sL https://aka.ms/InstallAzureCLIDeb | sudo bash + az --version # Verify installation + + - name: Login to Azure + run: | + az login --service-principal -u ${{ secrets.AZURE_CLIENT_ID }} -p ${{ secrets.AZURE_CLIENT_SECRET }} --tenant ${{ secrets.AZURE_TENANT_ID }} + + - name: Install Bicep CLI + run: az bicep install + + - name: Generate Resource Group Name + id: generate_rg_name + run: | + echo "Generating a unique resource group name..." + TIMESTAMP=$(date +%Y%m%d%H%M%S) + COMMON_PART="pslautomationCli" + UNIQUE_RG_NAME="${COMMON_PART}${TIMESTAMP}" + echo "RESOURCE_GROUP_NAME=${UNIQUE_RG_NAME}" >> $GITHUB_ENV + echo "Generated RESOURCE_GROUP_PREFIX: ${UNIQUE_RG_NAME}" + + - name: Check and Create Resource Group + id: check_create_rg + run: | + echo "RESOURCE_GROUP: ${{ env.RESOURCE_GROUP_NAME }}" + set -e + echo "Checking if resource group exists..." + rg_exists=$(az group exists --name ${{ env.RESOURCE_GROUP_NAME }}) + if [ "$rg_exists" = "false" ]; then + echo "Resource group does not exist. Creating..." + az group create --name ${{ env.RESOURCE_GROUP_NAME }} --location uksouth || { echo "Error creating resource group"; exit 1; } + else + echo "Resource group already exists." + fi + + - name: Generate Unique Solution Prefix + id: generate_solution_prefix + run: | + set -e + COMMON_PART="pslc" + TIMESTAMP=$(date +%s) + UPDATED_TIMESTAMP=$(echo $TIMESTAMP | tail -c 3) + UNIQUE_SOLUTION_PREFIX="${COMMON_PART}${UPDATED_TIMESTAMP}" + echo "SOLUTION_PREFIX=${UNIQUE_SOLUTION_PREFIX}" >> $GITHUB_ENV + echo "Generated SOLUTION_PREFIX: ${UNIQUE_SOLUTION_PREFIX}" + + - name: Deploy Bicep Template + id: deploy + run: | + set -e + az deployment group create \ + --resource-group ${{ env.RESOURCE_GROUP_NAME }} \ + --template-file ClientAdvisor/Deployment/bicep/main.bicep \ + --parameters solutionPrefix=${{ env.SOLUTION_PREFIX }} cosmosLocation=eastus2 + + - name: Update PowerBI URL + if: success() + run: | + set -e + + COMMON_PART="-app-service" + application_name="${{ env.SOLUTION_PREFIX }}${COMMON_PART}" + echo "Updating application: $application_name" + + # Log the Power BI URL being set + echo "Setting Power BI URL: ${{ vars.VITE_POWERBI_EMBED_URL }}" + + # Update the application settings + az webapp config appsettings set --name "$application_name" --resource-group "${{ env.RESOURCE_GROUP_NAME }}" --settings VITE_POWERBI_EMBED_URL="${{ vars.VITE_POWERBI_EMBED_URL }}" + + # Restart the web app + az webapp restart --resource-group "${{ env.RESOURCE_GROUP_NAME }}" --name "$application_name" + + echo "Power BI URL updated successfully for application: $application_name." + + - name: Delete Bicep Deployment + if: success() + run: | + set -e + echo "Checking if resource group exists..." + rg_exists=$(az group exists --name ${{ env.RESOURCE_GROUP_NAME }}) + if [ "$rg_exists" = "true" ]; then + echo "Resource group exist. Cleaning..." + az group delete \ + --name ${{ env.RESOURCE_GROUP_NAME }} \ + --yes \ + --no-wait + echo "Resource group deleted... ${{ env.RESOURCE_GROUP_NAME }}" + else + echo "Resource group does not exists." + fi + + - name: Send Notification on Failure + if: failure() + run: | + RUN_URL="https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}" + + # Construct the email body + EMAIL_BODY=$(cat <Dear Team,

We would like to inform you that the Client Advisor Automation process has encountered an issue and has failed to complete successfully.

Build URL: ${RUN_URL}
${OUTPUT}

Please investigate the matter at your earliest convenience.

Best regards,
Your Automation Team

" + } + EOF + ) + + # Send the notification + curl -X POST "${{ secrets.LOGIC_APP_URL }}" \ + -H "Content-Type: application/json" \ + -d "$EMAIL_BODY" || echo "Failed to send notification" + diff --git a/.github/workflows/RAdeploy.yml b/.github/workflows/RAdeploy.yml new file mode 100644 index 000000000..61bdf0e71 --- /dev/null +++ b/.github/workflows/RAdeploy.yml @@ -0,0 +1,105 @@ +name: CI-Validate Deployment-Research Assistant + +on: + push: + branches: + - main + paths: + - 'ResearchAssistant/**' + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - name: Checkout Code + uses: actions/checkout@v3 + + - name: Setup Azure CLI + run: | + curl -sL https://aka.ms/InstallAzureCLIDeb | sudo bash + az --version # Verify installation + + - name: Login to Azure + run: | + az login --service-principal -u ${{ secrets.AZURE_CLIENT_ID }} -p ${{ secrets.AZURE_CLIENT_SECRET }} --tenant ${{ secrets.AZURE_TENANT_ID }} + + - name: Install Bicep CLI + run: az bicep install + + - name: Generate Resource Group Name + id: generate_rg_name + run: | + echo "Generating a unique resource group name..." + TIMESTAMP=$(date +%Y%m%d%H%M%S) + COMMON_PART="pslautomationRes" + UNIQUE_RG_NAME="${COMMON_PART}${TIMESTAMP}" + echo "RESOURCE_GROUP_NAME=${UNIQUE_RG_NAME}" >> $GITHUB_ENV + echo "Generated Resource_GROUP_PREFIX: ${UNIQUE_RG_NAME}" + + - name: Check and Create Resource Group + id: check_create_rg + run: | + set -e + echo "Checking if resource group exists..." + rg_exists=$(az group exists --name ${{ env.RESOURCE_GROUP_NAME }}) + if [ "$rg_exists" = "false" ]; then + echo "Resource group does not exist. Creating..." + az group create --name ${{ env.RESOURCE_GROUP_NAME }} --location eastus2 || { echo "Error creating resource group"; exit 1; } + else + echo "Resource group already exists." + fi + + - name: Generate Unique Solution Prefix + id: generate_solution_prefix + run: | + set -e + COMMON_PART="pslr" + TIMESTAMP=$(date +%s) + UPDATED_TIMESTAMP=$(echo $TIMESTAMP | tail -c 3) + UNIQUE_SOLUTION_PREFIX="${COMMON_PART}${UPDATED_TIMESTAMP}" + echo "SOLUTION_PREFIX=${UNIQUE_SOLUTION_PREFIX}" >> $GITHUB_ENV + echo "Generated SOLUTION_PREFIX: ${UNIQUE_SOLUTION_PREFIX}" + + - name: Deploy Bicep Template + id: deploy + run: | + set -e + az deployment group create \ + --resource-group ${{ env.RESOURCE_GROUP_NAME }} \ + --template-file ResearchAssistant/Deployment/bicep/main.bicep \ + --parameters solutionPrefix=${{ env.SOLUTION_PREFIX }} + + - name: Delete Bicep Deployment + if: success() + run: | + set -e + echo "Checking if resource group exists..." + rg_exists=$(az group exists --name ${{ env.RESOURCE_GROUP_NAME }}) + if [ "$rg_exists" = "true" ]; then + echo "Resource group exist. Cleaning..." + az group delete \ + --name ${{ env.RESOURCE_GROUP_NAME }} \ + --yes \ + --no-wait + echo "Resource group deleted... ${{ env.RESOURCE_GROUP_NAME }}" + else + echo "Resource group does not exists." + fi + + - name: Send Notification on Failure + if: failure() + run: | + RUN_URL="https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}" + + # Construct the email body + EMAIL_BODY=$(cat <Dear Team,

We would like to inform you that the Research Assistant Automation process has encountered an issue and has failed to complete successfully.

Build URL: ${RUN_URL}
${OUTPUT}

Please investigate the matter at your earliest convenience.

Best regards,
Your Automation Team

" + } + EOF + ) + + # Send the notification + curl -X POST "${{ secrets.LOGIC_APP_URL }}" \ + -H "Content-Type: application/json" \ + -d "$EMAIL_BODY" || echo "Failed to send notification" \ No newline at end of file diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 000000000..5f6ba6220 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,94 @@ +# For most projects, this workflow file will not need changing; you simply need +# to commit it to your repository. +# +# You may wish to alter this file to override the set of languages analyzed, +# or to provide custom queries or build logic. +# +# ******** NOTE ******** +# We have attempted to detect the languages in your repository. Please check +# the `language` matrix defined below to confirm you have the correct set of +# supported CodeQL languages. +# +name: "CodeQL Advanced" + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + schedule: + - cron: '22 13 * * 0' + +jobs: + analyze: + name: Analyze (${{ matrix.language }}) + # Runner size impacts CodeQL analysis time. To learn more, please see: + # - https://gh.io/recommended-hardware-resources-for-running-codeql + # - https://gh.io/supported-runners-and-hardware-resources + # - https://gh.io/using-larger-runners (GitHub.com only) + # Consider using larger runners or machines with greater resources for possible analysis time improvements. + runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }} + permissions: + # required for all workflows + security-events: write + + # required to fetch internal or private CodeQL packs + packages: read + + # only required for workflows in private repositories + actions: read + contents: read + + strategy: + fail-fast: false + matrix: + include: + - language: javascript-typescript + build-mode: none + - language: python + build-mode: none + # CodeQL supports the following values keywords for 'language': 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'swift' + # Use `c-cpp` to analyze code written in C, C++ or both + # Use 'java-kotlin' to analyze code written in Java, Kotlin or both + # Use 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both + # To learn more about changing the languages that are analyzed or customizing the build mode for your analysis, + # see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning. + # If you are analyzing a compiled language, you can modify the 'build-mode' for that language to customize how + # your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: ${{ matrix.language }} + build-mode: ${{ matrix.build-mode }} + # If you wish to specify custom queries, you can do so here or in a config file. + # By default, queries listed here will override any specified in a config file. + # Prefix the list here with "+" to use these queries and those in the config file. + + # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs + # queries: security-extended,security-and-quality + + # If the analyze step fails for one of the languages you are analyzing with + # "We were unable to automatically build your code", modify the matrix above + # to set the build mode to "manual" for that language. Then modify this step + # to build your code. + # ℹ️ Command-line programs to run using the OS shell. + # πŸ“š See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun + - if: matrix.build-mode == 'manual' + shell: bash + run: | + echo 'If you are using a "manual" build mode for one or more of the' \ + 'languages you are analyzing, replace this with the commands to build' \ + 'your code, for example:' + echo ' make bootstrap' + echo ' make release' + exit 1 + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 + with: + category: "/language:${{matrix.language}}" diff --git a/.github/workflows/pylint.yml b/.github/workflows/pylint.yml new file mode 100644 index 000000000..989f73871 --- /dev/null +++ b/.github/workflows/pylint.yml @@ -0,0 +1,22 @@ +name: Pylint + +on: [push] + +jobs: + build: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.11"] + steps: + - uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v3 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r ClientAdvisor/App/requirements.txt + - name: Run flake8 + run: flake8 --config=ClientAdvisor/App/.flake8 ClientAdvisor/App diff --git a/.github/workflows/test_client_advisor.yml b/.github/workflows/test_client_advisor.yml new file mode 100644 index 000000000..f9a29716b --- /dev/null +++ b/.github/workflows/test_client_advisor.yml @@ -0,0 +1,55 @@ +name: Tests + +on: + push: + branches: main + # Trigger on changes in these specific paths + paths: + - 'ClientAdvisor/**' + pull_request: + branches: main + types: + - opened + - ready_for_review + - reopened + - synchronize + paths: + - 'ClientAdvisor/**' + +jobs: + test_client_advisor: + + name: Client Advisor Tests + runs-on: ubuntu-latest + # The if condition ensures that this job only runs if changes are in the ClientAdvisor folder + + steps: + - uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + - name: Install Backend Dependencies + run: | + cd ClientAdvisor/App + python -m pip install -r requirements.txt + python -m pip install coverage pytest-cov + + - name: Set up Node.js + uses: actions/setup-node@v3 + with: + node-version: '20' + - name: Install Frontend Dependencies + run: | + cd ClientAdvisor/App/frontend + npm install + - name: Run Frontend Tests with Coverage + run: | + cd ClientAdvisor/App/frontend + npm run test -- --coverage + - uses: actions/upload-artifact@v4 + with: + name: client-advisor-frontend-coverage + path: | + ClientAdvisor/App/frontend/coverage/ + ClientAdvisor/App/frontend/coverage/lcov-report/ \ No newline at end of file diff --git a/.github/workflows/test_research_assistant.yml b/.github/workflows/test_research_assistant.yml new file mode 100644 index 000000000..ec31819ba --- /dev/null +++ b/.github/workflows/test_research_assistant.yml @@ -0,0 +1,54 @@ +name: Tests + +on: + push: + branches: main + # Trigger on changes in these specific paths + paths: + - 'ResearchAssistant/**' + pull_request: + branches: main + types: + - opened + - ready_for_review + - reopened + - synchronize + paths: + - 'ResearchAssistant/**' + +jobs: + test_research_assistant: + name: Research Assistant Tests + runs-on: ubuntu-latest + # The if condition ensures that this job only runs if changes are in the ResearchAssistant folder + + steps: + - uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + - name: Install Backend Dependencies + run: | + cd ResearchAssistant/App + python -m pip install -r requirements.txt + python -m pip install coverage pytest-cov + + - name: Set up Node.js + uses: actions/setup-node@v3 + with: + node-version: '20' + - name: Install Frontend Dependencies + run: | + cd ResearchAssistant/App/frontend + npm install + - name: Run Frontend Tests with Coverage + run: | + cd ResearchAssistant/App/frontend + npm run test -- --coverage + - uses: actions/upload-artifact@v4 + with: + name: research-assistant-frontend-coverage + path: | + ResearchAssistant/App/frontend/coverage/ + ResearchAssistant/App/frontend/coverage/lcov-report/ \ No newline at end of file diff --git a/ClientAdvisor/App/.flake8 b/ClientAdvisor/App/.flake8 new file mode 100644 index 000000000..c462975ac --- /dev/null +++ b/ClientAdvisor/App/.flake8 @@ -0,0 +1,4 @@ +[flake8] +max-line-length = 88 +extend-ignore = E501, E203 +exclude = .venv, frontend, \ No newline at end of file diff --git a/ClientAdvisor/App/.gitignore b/ClientAdvisor/App/.gitignore index cf6d66c97..bb12c4b8b 100644 --- a/ClientAdvisor/App/.gitignore +++ b/ClientAdvisor/App/.gitignore @@ -17,6 +17,7 @@ lib/ .venv frontend/node_modules +frontend/coverage .env # static .azure/ diff --git a/ClientAdvisor/App/app.py b/ClientAdvisor/App/app.py index ff5647552..944d119a3 100644 --- a/ClientAdvisor/App/app.py +++ b/ClientAdvisor/App/app.py @@ -7,7 +7,6 @@ import httpx import time import requests -import pymssql from types import SimpleNamespace from db import get_connection from quart import ( @@ -18,23 +17,20 @@ request, send_from_directory, render_template, - session ) + # from quart.sessions import SecureCookieSessionInterface from openai import AsyncAzureOpenAI from azure.identity.aio import DefaultAzureCredential, get_bearer_token_provider from backend.auth.auth_utils import get_authenticated_user_details, get_tenantid from backend.history.cosmosdbservice import CosmosConversationClient -# from flask import Flask -# from flask_cors import CORS -import secrets + from backend.utils import ( format_as_ndjson, format_stream_response, generateFilterString, parse_multi_columns, - format_non_streaming_response, convert_to_pf_format, format_pf_non_streaming_response, ) @@ -297,6 +293,7 @@ async def assets(path): VITE_POWERBI_EMBED_URL = os.environ.get("VITE_POWERBI_EMBED_URL") + def should_use_data(): global DATASOURCE_TYPE if AZURE_SEARCH_SERVICE and AZURE_SEARCH_INDEX: @@ -762,16 +759,18 @@ def prepare_model_args(request_body, request_headers): messages.append({"role": message["role"], "content": message["content"]}) user_json = None - if (MS_DEFENDER_ENABLED): + if MS_DEFENDER_ENABLED: authenticated_user_details = get_authenticated_user_details(request_headers) tenantId = get_tenantid(authenticated_user_details.get("client_principal_b64")) - conversation_id = request_body.get("conversation_id", None) + conversation_id = request_body.get("conversation_id", None) user_args = { - "EndUserId": authenticated_user_details.get('user_principal_id'), - "EndUserIdType": 'Entra', + "EndUserId": authenticated_user_details.get("user_principal_id"), + "EndUserIdType": "Entra", "EndUserTenantId": tenantId, "ConversationId": conversation_id, - "SourceIp": request_headers.get('X-Forwarded-For', request_headers.get('Remote-Addr', '')), + "SourceIp": request_headers.get( + "X-Forwarded-For", request_headers.get("Remote-Addr", "") + ), } user_json = json.dumps(user_args) @@ -831,6 +830,7 @@ def prepare_model_args(request_body, request_headers): return model_args + async def promptflow_request(request): try: headers = { @@ -864,70 +864,78 @@ async def promptflow_request(request): logging.error(f"An error occurred while making promptflow_request: {e}") - async def send_chat_request(request_body, request_headers): filtered_messages = [] messages = request_body.get("messages", []) for message in messages: - if message.get("role") != 'tool': + if message.get("role") != "tool": filtered_messages.append(message) - - request_body['messages'] = filtered_messages + + request_body["messages"] = filtered_messages model_args = prepare_model_args(request_body, request_headers) try: azure_openai_client = init_openai_client() - raw_response = await azure_openai_client.chat.completions.with_raw_response.create(**model_args) + raw_response = ( + await azure_openai_client.chat.completions.with_raw_response.create( + **model_args + ) + ) response = raw_response.parse() - apim_request_id = raw_response.headers.get("apim-request-id") + apim_request_id = raw_response.headers.get("apim-request-id") except Exception as e: logging.exception("Exception in send_chat_request") raise e return response, apim_request_id + async def complete_chat_request(request_body, request_headers): if USE_PROMPTFLOW and PROMPTFLOW_ENDPOINT and PROMPTFLOW_API_KEY: response = await promptflow_request(request_body) history_metadata = request_body.get("history_metadata", {}) return format_pf_non_streaming_response( - response, history_metadata, PROMPTFLOW_RESPONSE_FIELD_NAME, PROMPTFLOW_CITATIONS_FIELD_NAME + response, + history_metadata, + PROMPTFLOW_RESPONSE_FIELD_NAME, + PROMPTFLOW_CITATIONS_FIELD_NAME, ) elif USE_AZUREFUNCTION: request_body = await request.get_json() - client_id = request_body.get('client_id') + client_id = request_body.get("client_id") print(request_body) if client_id is None: return jsonify({"error": "No client ID provided"}), 400 # client_id = '10005' print("Client ID in complete_chat_request: ", client_id) - answer = "Sample response from Azure Function" - # Construct the URL of your Azure Function endpoint - function_url = STREAMING_AZUREFUNCTION_ENDPOINT - - request_headers = { - 'Content-Type': 'application/json', - # 'Authorization': 'Bearer YOUR_TOKEN_HERE' # if applicable - } + # answer = "Sample response from Azure Function" + # Construct the URL of your Azure Function endpoint + # function_url = STREAMING_AZUREFUNCTION_ENDPOINT + + # request_headers = { + # "Content-Type": "application/json", + # # 'Authorization': 'Bearer YOUR_TOKEN_HERE' # if applicable + # } # print(request_body.get("messages")[-1].get("content")) # print(request_body) query = request_body.get("messages")[-1].get("content") - print("Selected ClientId:", client_id) # print("Selected ClientName:", selected_client_name) # endpoint = STREAMING_AZUREFUNCTION_ENDPOINT + '?query=' + query + ' - for Client ' + selected_client_name + ':::' + selected_client_id - endpoint = STREAMING_AZUREFUNCTION_ENDPOINT + '?query=' + query + ':::' + client_id + endpoint = ( + STREAMING_AZUREFUNCTION_ENDPOINT + "?query=" + query + ":::" + client_id + ) print("Endpoint: ", endpoint) - query_response = '' + query_response = "" try: - with requests.get(endpoint,stream=True) as r: + with requests.get(endpoint, stream=True) as r: for line in r.iter_lines(chunk_size=10): # query_response += line.decode('utf-8') - query_response = query_response + '\n' + line.decode('utf-8') + query_response = query_response + "\n" + line.decode("utf-8") # print(line.decode('utf-8')) except Exception as e: print(format_as_ndjson({"error" + str(e)})) @@ -940,11 +948,9 @@ async def complete_chat_request(request_body, request_headers): "model": "", "created": 0, "object": "", - "choices": [{ - "messages": [] - }], + "choices": [{"messages": []}], "apim-request-id": "", - 'history_metadata': history_metadata + "history_metadata": history_metadata, } response["id"] = str(uuid.uuid4()) @@ -952,76 +958,84 @@ async def complete_chat_request(request_body, request_headers): response["created"] = int(time.time()) response["object"] = "extensions.chat.completion.chunk" # response["apim-request-id"] = headers.get("apim-request-id") - response["choices"][0]["messages"].append({ - "role": "assistant", - "content": query_response - }) - + response["choices"][0]["messages"].append( + {"role": "assistant", "content": query_response} + ) return response + async def stream_chat_request(request_body, request_headers): if USE_AZUREFUNCTION: history_metadata = request_body.get("history_metadata", {}) function_url = STREAMING_AZUREFUNCTION_ENDPOINT - apim_request_id = '' - - client_id = request_body.get('client_id') + apim_request_id = "" + + client_id = request_body.get("client_id") if client_id is None: return jsonify({"error": "No client ID provided"}), 400 query = request_body.get("messages")[-1].get("content") + query = query.strip() async def generate(): - deltaText = '' - #async for completionChunk in response: + deltaText = "" + # async for completionChunk in response: timeout = httpx.Timeout(10.0, read=None) - async with httpx.AsyncClient(verify=False,timeout=timeout) as client: # verify=False for development purposes - query_url = function_url + '?query=' + query + ':::' + client_id - async with client.stream('GET', query_url) as response: + async with httpx.AsyncClient( + verify=False, timeout=timeout + ) as client: # verify=False for development purposes + query_url = function_url + "?query=" + query + ":::" + client_id + async with client.stream("GET", query_url) as response: async for chunk in response.aiter_text(): - deltaText = '' + deltaText = "" deltaText = chunk completionChunk1 = { "id": "", "model": "", "created": 0, "object": "", - "choices": [{ - "messages": [], - "delta": {} - }], + "choices": [{"messages": [], "delta": {}}], "apim-request-id": "", - 'history_metadata': history_metadata + "history_metadata": history_metadata, } completionChunk1["id"] = str(uuid.uuid4()) completionChunk1["model"] = AZURE_OPENAI_MODEL_NAME completionChunk1["created"] = int(time.time()) completionChunk1["object"] = "extensions.chat.completion.chunk" - completionChunk1["apim-request-id"] = request_headers.get("apim-request-id") - completionChunk1["choices"][0]["messages"].append({ - "role": "assistant", - "content": deltaText - }) + completionChunk1["apim-request-id"] = request_headers.get( + "apim-request-id" + ) + completionChunk1["choices"][0]["messages"].append( + {"role": "assistant", "content": deltaText} + ) completionChunk1["choices"][0]["delta"] = { "role": "assistant", - "content": deltaText + "content": deltaText, } - completionChunk2 = json.loads(json.dumps(completionChunk1), object_hook=lambda d: SimpleNamespace(**d)) - yield format_stream_response(completionChunk2, history_metadata, apim_request_id) + completionChunk2 = json.loads( + json.dumps(completionChunk1), + object_hook=lambda d: SimpleNamespace(**d), + ) + yield format_stream_response( + completionChunk2, history_metadata, apim_request_id + ) return generate() - + else: - response, apim_request_id = await send_chat_request(request_body, request_headers) + response, apim_request_id = await send_chat_request( + request_body, request_headers + ) history_metadata = request_body.get("history_metadata", {}) - + async def generate(): async for completionChunk in response: - yield format_stream_response(completionChunk, history_metadata, apim_request_id) + yield format_stream_response( + completionChunk, history_metadata, apim_request_id + ) return generate() - async def conversation_internal(request_body, request_headers): @@ -1060,15 +1074,15 @@ def get_frontend_settings(): except Exception as e: logging.exception("Exception in /frontend_settings") return jsonify({"error": str(e)}), 500 - -## Conversation History API ## + +# Conversation History API # @bp.route("/history/generate", methods=["POST"]) async def add_conversation(): authenticated_user = get_authenticated_user_details(request_headers=request.headers) user_id = authenticated_user["user_principal_id"] - ## check request for conversation_id + # check request for conversation_id request_json = await request.get_json() conversation_id = request_json.get("conversation_id", None) @@ -1089,8 +1103,8 @@ async def add_conversation(): history_metadata["title"] = title history_metadata["date"] = conversation_dict["createdAt"] - ## Format the incoming message object in the "chat/completions" messages format - ## then write it to the conversation history in cosmos + # Format the incoming message object in the "chat/completions" messages format + # then write it to the conversation history in cosmos messages = request_json["messages"] if len(messages) > 0 and messages[-1]["role"] == "user": createdMessageValue = await cosmos_conversation_client.create_message( @@ -1126,7 +1140,7 @@ async def update_conversation(): authenticated_user = get_authenticated_user_details(request_headers=request.headers) user_id = authenticated_user["user_principal_id"] - ## check request for conversation_id + # check request for conversation_id request_json = await request.get_json() conversation_id = request_json.get("conversation_id", None) @@ -1140,8 +1154,8 @@ async def update_conversation(): if not conversation_id: raise Exception("No conversation_id found") - ## Format the incoming message object in the "chat/completions" messages format - ## then write it to the conversation history in cosmos + # Format the incoming message object in the "chat/completions" messages format + # then write it to the conversation history in cosmos messages = request_json["messages"] if len(messages) > 0 and messages[-1]["role"] == "assistant": if len(messages) > 1 and messages[-2].get("role", None) == "tool": @@ -1178,7 +1192,7 @@ async def update_message(): user_id = authenticated_user["user_principal_id"] cosmos_conversation_client = init_cosmosdb_client() - ## check request for message_id + # check request for message_id request_json = await request.get_json() message_id = request_json.get("message_id", None) message_feedback = request_json.get("message_feedback", None) @@ -1189,7 +1203,7 @@ async def update_message(): if not message_feedback: return jsonify({"error": "message_feedback is required"}), 400 - ## update the message in cosmos + # update the message in cosmos updated_message = await cosmos_conversation_client.update_message_feedback( user_id, message_id, message_feedback ) @@ -1220,11 +1234,11 @@ async def update_message(): @bp.route("/history/delete", methods=["DELETE"]) async def delete_conversation(): - ## get the user id from the request headers + # get the user id from the request headers authenticated_user = get_authenticated_user_details(request_headers=request.headers) user_id = authenticated_user["user_principal_id"] - ## check request for conversation_id + # check request for conversation_id request_json = await request.get_json() conversation_id = request_json.get("conversation_id", None) @@ -1232,20 +1246,16 @@ async def delete_conversation(): if not conversation_id: return jsonify({"error": "conversation_id is required"}), 400 - ## make sure cosmos is configured + # make sure cosmos is configured cosmos_conversation_client = init_cosmosdb_client() if not cosmos_conversation_client: raise Exception("CosmosDB is not configured or not working") - ## delete the conversation messages from cosmos first - deleted_messages = await cosmos_conversation_client.delete_messages( - conversation_id, user_id - ) + # delete the conversation messages from cosmos first + await cosmos_conversation_client.delete_messages(conversation_id, user_id) - ## Now delete the conversation - deleted_conversation = await cosmos_conversation_client.delete_conversation( - user_id, conversation_id - ) + # Now delete the conversation + await cosmos_conversation_client.delete_conversation(user_id, conversation_id) await cosmos_conversation_client.cosmosdb_client.close() @@ -1269,12 +1279,12 @@ async def list_conversations(): authenticated_user = get_authenticated_user_details(request_headers=request.headers) user_id = authenticated_user["user_principal_id"] - ## make sure cosmos is configured + # make sure cosmos is configured cosmos_conversation_client = init_cosmosdb_client() if not cosmos_conversation_client: raise Exception("CosmosDB is not configured or not working") - ## get the conversations from cosmos + # get the conversations from cosmos conversations = await cosmos_conversation_client.get_conversations( user_id, offset=offset, limit=25 ) @@ -1282,7 +1292,7 @@ async def list_conversations(): if not isinstance(conversations, list): return jsonify({"error": f"No conversations for {user_id} were found"}), 404 - ## return the conversation ids + # return the conversation ids return jsonify(conversations), 200 @@ -1292,23 +1302,23 @@ async def get_conversation(): authenticated_user = get_authenticated_user_details(request_headers=request.headers) user_id = authenticated_user["user_principal_id"] - ## check request for conversation_id + # check request for conversation_id request_json = await request.get_json() conversation_id = request_json.get("conversation_id", None) if not conversation_id: return jsonify({"error": "conversation_id is required"}), 400 - ## make sure cosmos is configured + # make sure cosmos is configured cosmos_conversation_client = init_cosmosdb_client() if not cosmos_conversation_client: raise Exception("CosmosDB is not configured or not working") - ## get the conversation object and the related messages from cosmos + # get the conversation object and the related messages from cosmos conversation = await cosmos_conversation_client.get_conversation( user_id, conversation_id ) - ## return the conversation id and the messages in the bot frontend format + # return the conversation id and the messages in the bot frontend format if not conversation: return ( jsonify( @@ -1324,7 +1334,7 @@ async def get_conversation(): user_id, conversation_id ) - ## format the messages in the bot frontend format + # format the messages in the bot frontend format messages = [ { "id": msg["id"], @@ -1345,19 +1355,19 @@ async def rename_conversation(): authenticated_user = get_authenticated_user_details(request_headers=request.headers) user_id = authenticated_user["user_principal_id"] - ## check request for conversation_id + # check request for conversation_id request_json = await request.get_json() conversation_id = request_json.get("conversation_id", None) if not conversation_id: return jsonify({"error": "conversation_id is required"}), 400 - ## make sure cosmos is configured + # make sure cosmos is configured cosmos_conversation_client = init_cosmosdb_client() if not cosmos_conversation_client: raise Exception("CosmosDB is not configured or not working") - ## get the conversation from cosmos + # get the conversation from cosmos conversation = await cosmos_conversation_client.get_conversation( user_id, conversation_id ) @@ -1371,7 +1381,7 @@ async def rename_conversation(): 404, ) - ## update the title + # update the title title = request_json.get("title", None) if not title: return jsonify({"error": "title is required"}), 400 @@ -1386,13 +1396,13 @@ async def rename_conversation(): @bp.route("/history/delete_all", methods=["DELETE"]) async def delete_all_conversations(): - ## get the user id from the request headers + # get the user id from the request headers authenticated_user = get_authenticated_user_details(request_headers=request.headers) user_id = authenticated_user["user_principal_id"] # get conversations for user try: - ## make sure cosmos is configured + # make sure cosmos is configured cosmos_conversation_client = init_cosmosdb_client() if not cosmos_conversation_client: raise Exception("CosmosDB is not configured or not working") @@ -1405,13 +1415,13 @@ async def delete_all_conversations(): # delete each conversation for conversation in conversations: - ## delete the conversation messages from cosmos first - deleted_messages = await cosmos_conversation_client.delete_messages( + # delete the conversation messages from cosmos first + await cosmos_conversation_client.delete_messages( conversation["id"], user_id ) - ## Now delete the conversation - deleted_conversation = await cosmos_conversation_client.delete_conversation( + # Now delete the conversation + await cosmos_conversation_client.delete_conversation( user_id, conversation["id"] ) await cosmos_conversation_client.cosmosdb_client.close() @@ -1431,11 +1441,11 @@ async def delete_all_conversations(): @bp.route("/history/clear", methods=["POST"]) async def clear_messages(): - ## get the user id from the request headers + # get the user id from the request headers authenticated_user = get_authenticated_user_details(request_headers=request.headers) user_id = authenticated_user["user_principal_id"] - ## check request for conversation_id + # check request for conversation_id request_json = await request.get_json() conversation_id = request_json.get("conversation_id", None) @@ -1443,15 +1453,13 @@ async def clear_messages(): if not conversation_id: return jsonify({"error": "conversation_id is required"}), 400 - ## make sure cosmos is configured + # make sure cosmos is configured cosmos_conversation_client = init_cosmosdb_client() if not cosmos_conversation_client: raise Exception("CosmosDB is not configured or not working") - ## delete the conversation messages from cosmos - deleted_messages = await cosmos_conversation_client.delete_messages( - conversation_id, user_id - ) + # delete the conversation messages from cosmos + await cosmos_conversation_client.delete_messages(conversation_id, user_id) return ( jsonify( @@ -1510,7 +1518,7 @@ async def ensure_cosmos(): async def generate_title(conversation_messages): - ## make sure the messages are sorted by _ts descending + # make sure the messages are sorted by _ts descending title_prompt = 'Summarize the conversation so far into a 4-word or less title. Do not use any quotation marks or punctuation. Respond with a json object in the format {{"title": string}}. Do not include any other commentary or description.' messages = [ @@ -1527,34 +1535,36 @@ async def generate_title(conversation_messages): title = json.loads(response.choices[0].message.content)["title"] return title - except Exception as e: + except Exception: return messages[-2]["content"] - -@bp.route("/api/pbi", methods=['GET']) + + +@bp.route("/api/pbi", methods=["GET"]) def get_pbiurl(): return VITE_POWERBI_EMBED_URL - -@bp.route("/api/users", methods=['GET']) + + +@bp.route("/api/users", methods=["GET"]) def get_users(): - conn = None + conn = None try: conn = get_connection() cursor = conn.cursor() sql_stmt = """ - SELECT - ClientId, - Client, - Email, + SELECT + ClientId, + Client, + Email, FORMAT(AssetValue, 'N0') AS AssetValue, ClientSummary, CAST(LastMeeting AS DATE) AS LastMeetingDate, FORMAT(CAST(LastMeeting AS DATE), 'dddd MMMM d, yyyy') AS LastMeetingDateFormatted, -      FORMAT(LastMeeting, 'hh:mm tt') AS LastMeetingStartTime, - FORMAT(LastMeetingEnd, 'hh:mm tt') AS LastMeetingEndTime, +       FORMAT(LastMeeting, 'HH:mm ') AS LastMeetingStartTime, + FORMAT(LastMeetingEnd, 'HH:mm') AS LastMeetingEndTime, CAST(NextMeeting AS DATE) AS NextMeetingDate, FORMAT(CAST(NextMeeting AS DATE), 'dddd MMMM d, yyyy') AS NextMeetingFormatted, - FORMAT(NextMeeting, 'hh:mm tt') AS NextMeetingStartTime, - FORMAT(NextMeetingEnd, 'hh:mm tt') AS NextMeetingEndTime + FORMAT(NextMeeting, 'HH:mm') AS NextMeetingStartTime, + FORMAT(NextMeetingEnd, 'HH:mm') AS NextMeetingEndTime FROM ( SELECT ca.ClientId, Client, Email, AssetValue, ClientSummary, LastMeeting, LastMeetingEnd, NextMeeting, NextMeetingEnd FROM ( @@ -1573,7 +1583,7 @@ def get_users(): JOIN ClientSummaries cs ON c.ClientId = cs.ClientId ) ca JOIN ( - SELECT cm.ClientId, + SELECT cm.ClientId, MAX(CASE WHEN StartTime < GETDATE() THEN StartTime END) AS LastMeeting, DATEADD(MINUTE, 30, MAX(CASE WHEN StartTime < GETDATE() THEN StartTime END)) AS LastMeetingEnd, MIN(CASE WHEN StartTime > GETDATE() AND StartTime < GETDATE() + 7 THEN StartTime END) AS NextMeeting, @@ -1589,22 +1599,26 @@ def get_users(): rows = cursor.fetchall() if len(rows) <= 6: - #update ClientMeetings,Assets,Retirement tables sample data to current date + # update ClientMeetings,Assets,Retirement tables sample data to current date cursor = conn.cursor() - cursor.execute("""select DATEDIFF(d,CAST(max(StartTime) AS Date),CAST(GETDATE() AS Date)) + 3 as ndays from ClientMeetings""") + cursor.execute( + """select DATEDIFF(d,CAST(max(StartTime) AS Date),CAST(GETDATE() AS Date)) + 3 as ndays from ClientMeetings""" + ) rows = cursor.fetchall() for row in rows: - ndays = row['ndays'] - sql_stmt1 = f'UPDATE ClientMeetings SET StartTime = dateadd(day,{ndays},StartTime), EndTime = dateadd(day,{ndays},EndTime)' + ndays = row["ndays"] + sql_stmt1 = f"UPDATE ClientMeetings SET StartTime = dateadd(day,{ndays},StartTime), EndTime = dateadd(day,{ndays},EndTime)" cursor.execute(sql_stmt1) conn.commit() - nmonths = int(ndays/30) + nmonths = int(ndays / 30) if nmonths > 0: - sql_stmt1 = f'UPDATE Assets SET AssetDate = dateadd(MONTH,{nmonths},AssetDate)' + sql_stmt1 = ( + f"UPDATE Assets SET AssetDate = dateadd(MONTH,{nmonths},AssetDate)" + ) cursor.execute(sql_stmt1) conn.commit() - - sql_stmt1 = f'UPDATE Retirement SET StatusDate = dateadd(MONTH,{nmonths},StatusDate)' + + sql_stmt1 = f"UPDATE Retirement SET StatusDate = dateadd(MONTH,{nmonths},StatusDate)" cursor.execute(sql_stmt1) conn.commit() @@ -1616,29 +1630,29 @@ def get_users(): for row in rows: # print(row) user = { - 'ClientId': row['ClientId'], - 'ClientName': row['Client'], - 'ClientEmail': row['Email'], - 'AssetValue': row['AssetValue'], - 'NextMeeting': row['NextMeetingFormatted'], - 'NextMeetingTime': row['NextMeetingStartTime'], - 'NextMeetingEndTime': row['NextMeetingEndTime'], - 'LastMeeting': row['LastMeetingDateFormatted'], - 'LastMeetingStartTime': row['LastMeetingStartTime'], - 'LastMeetingEndTime': row['LastMeetingEndTime'], - 'ClientSummary': row['ClientSummary'] - } + "ClientId": row["ClientId"], + "ClientName": row["Client"], + "ClientEmail": row["Email"], + "AssetValue": row["AssetValue"], + "NextMeeting": row["NextMeetingFormatted"], + "NextMeetingTime": row["NextMeetingStartTime"], + "NextMeetingEndTime": row["NextMeetingEndTime"], + "LastMeeting": row["LastMeetingDateFormatted"], + "LastMeetingStartTime": row["LastMeetingStartTime"], + "LastMeetingEndTime": row["LastMeetingEndTime"], + "ClientSummary": row["ClientSummary"], + } users.append(user) # print(users) - + return jsonify(users) - - + except Exception as e: print("Exception occurred:", e) return str(e), 500 finally: if conn: conn.close() - + + app = create_app() diff --git a/ClientAdvisor/App/backend/auth/auth_utils.py b/ClientAdvisor/App/backend/auth/auth_utils.py index 3a97e610a..31e01dff7 100644 --- a/ClientAdvisor/App/backend/auth/auth_utils.py +++ b/ClientAdvisor/App/backend/auth/auth_utils.py @@ -2,38 +2,41 @@ import json import logging + def get_authenticated_user_details(request_headers): user_object = {} - ## check the headers for the Principal-Id (the guid of the signed in user) + # check the headers for the Principal-Id (the guid of the signed in user) if "X-Ms-Client-Principal-Id" not in request_headers.keys(): - ## if it's not, assume we're in development mode and return a default user + # if it's not, assume we're in development mode and return a default user from . import sample_user + raw_user_object = sample_user.sample_user else: - ## if it is, get the user details from the EasyAuth headers - raw_user_object = {k:v for k,v in request_headers.items()} + # if it is, get the user details from the EasyAuth headers + raw_user_object = {k: v for k, v in request_headers.items()} - user_object['user_principal_id'] = raw_user_object.get('X-Ms-Client-Principal-Id') - user_object['user_name'] = raw_user_object.get('X-Ms-Client-Principal-Name') - user_object['auth_provider'] = raw_user_object.get('X-Ms-Client-Principal-Idp') - user_object['auth_token'] = raw_user_object.get('X-Ms-Token-Aad-Id-Token') - user_object['client_principal_b64'] = raw_user_object.get('X-Ms-Client-Principal') - user_object['aad_id_token'] = raw_user_object.get('X-Ms-Token-Aad-Id-Token') + user_object["user_principal_id"] = raw_user_object.get("X-Ms-Client-Principal-Id") + user_object["user_name"] = raw_user_object.get("X-Ms-Client-Principal-Name") + user_object["auth_provider"] = raw_user_object.get("X-Ms-Client-Principal-Idp") + user_object["auth_token"] = raw_user_object.get("X-Ms-Token-Aad-Id-Token") + user_object["client_principal_b64"] = raw_user_object.get("X-Ms-Client-Principal") + user_object["aad_id_token"] = raw_user_object.get("X-Ms-Token-Aad-Id-Token") return user_object + def get_tenantid(client_principal_b64): - tenant_id = '' - if client_principal_b64: + tenant_id = "" + if client_principal_b64: try: # Decode the base64 header to get the JSON string decoded_bytes = base64.b64decode(client_principal_b64) - decoded_string = decoded_bytes.decode('utf-8') + decoded_string = decoded_bytes.decode("utf-8") # Convert the JSON string1into a Python dictionary user_info = json.loads(decoded_string) # Extract the tenant ID - tenant_id = user_info.get('tid') # 'tid' typically holds the tenant ID + tenant_id = user_info.get("tid") # 'tid' typically holds the tenant ID except Exception as ex: logging.exception(ex) - return tenant_id \ No newline at end of file + return tenant_id diff --git a/ClientAdvisor/App/backend/auth/sample_user.py b/ClientAdvisor/App/backend/auth/sample_user.py index 0b10d9ab5..9353bcc1b 100644 --- a/ClientAdvisor/App/backend/auth/sample_user.py +++ b/ClientAdvisor/App/backend/auth/sample_user.py @@ -1,39 +1,39 @@ sample_user = { - "Accept": "*/*", - "Accept-Encoding": "gzip, deflate, br", - "Accept-Language": "en", - "Client-Ip": "22.222.222.2222:64379", - "Content-Length": "192", - "Content-Type": "application/json", - "Cookie": "AppServiceAuthSession=/AuR5ENU+pmpoN3jnymP8fzpmVBgphx9uPQrYLEWGcxjIITIeh8NZW7r3ePkG8yBcMaItlh1pX4nzg5TFD9o2mxC/5BNDRe/uuu0iDlLEdKecROZcVRY7QsFdHLjn9KB90Z3d9ZeLwfVIf0sZowWJt03BO5zKGB7vZgL+ofv3QY3AaYn1k1GtxSE9HQWJpWar7mOA64b7Lsy62eY3nxwg3AWDsP3/rAta+MnDCzpdlZMFXcJLj+rsCppW+w9OqGhKQ7uCs03BPeon3qZOdmE8cOJW3+i96iYlhneNQDItHyQqEi1CHbBTSkqwpeOwWP4vcwGM22ynxPp7YFyiRw/X361DGYy+YkgYBkXq1AEIDZ44BCBz9EEaEi0NU+m6yUOpNjEaUtrJKhQywcM2odojdT4XAY+HfTEfSqp0WiAkgAuE/ueCu2JDOfvxGjCgJ4DGWCoYdOdXAN1c+MenT4OSvkMO41YuPeah9qk9ixkJI5s80lv8rUu1J26QF6pstdDkYkAJAEra3RQiiO1eAH7UEb3xHXn0HW5lX8ZDX3LWiAFGOt5DIKxBKFymBKJGzbPFPYjfczegu0FD8/NQPLl2exAX3mI9oy/tFnATSyLO2E8DxwP5wnYVminZOQMjB/I4g3Go14betm0MlNXlUbU1fyS6Q6JxoCNLDZywCoU9Y65UzimWZbseKsXlOwYukCEpuQ5QPT55LuEAWhtYier8LSh+fvVUsrkqKS+bg0hzuoX53X6aqUr7YB31t0Z2zt5TT/V3qXpdyD8Xyd884PqysSkJYa553sYx93ETDKSsfDguanVfn2si9nvDpvUWf6/R02FmQgXiaaaykMgYyIuEmE77ptsivjH3hj/MN4VlePFWokcchF4ciqqzonmICmjEHEx5zpjU2Kwa+0y7J5ROzVVygcnO1jH6ZKDy9bGGYL547bXx/iiYBYqSIQzleOAkCeULrGN2KEHwckX5MpuRaqTpoxdZH9RJv0mIWxbDA0kwGsbMICQd0ZODBkPUnE84qhzvXInC+TL7MbutPEnGbzgxBAS1c2Ct4vxkkjykOeOxTPxqAhxoefwUfIwZZax6A9LbeYX2bsBpay0lScHcA==", - "Disguised-Host": "your_app_service.azurewebsites.net", - "Host": "your_app_service.azurewebsites.net", - "Max-Forwards": "10", - "Origin": "https://your_app_service.azurewebsites.net", - "Referer": "https://your_app_service.azurewebsites.net/", - "Sec-Ch-Ua": "\"Microsoft Edge\";v=\"113\", \"Chromium\";v=\"113\", \"Not-A.Brand\";v=\"24\"", - "Sec-Ch-Ua-Mobile": "?0", - "Sec-Ch-Ua-Platform": "\"Windows\"", - "Sec-Fetch-Dest": "empty", - "Sec-Fetch-Mode": "cors", - "Sec-Fetch-Site": "same-origin", - "Traceparent": "00-24e9a8d1b06f233a3f1714845ef971a9-3fac69f81ca5175c-00", - "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.0.0 Safari/537.36 Edg/113.0.1774.42", - "Was-Default-Hostname": "your_app_service.azurewebsites.net", - "X-Appservice-Proto": "https", - "X-Arr-Log-Id": "4102b832-6c88-4c7c-8996-0edad9e4358f", - "X-Arr-Ssl": "2048|256|CN=Microsoft Azure TLS Issuing CA 02, O=Microsoft Corporation, C=US|CN=*.azurewebsites.net, O=Microsoft Corporation, L=Redmond, S=WA, C=US", - "X-Client-Ip": "22.222.222.222", - "X-Client-Port": "64379", - "X-Forwarded-For": "22.222.222.22:64379", - "X-Forwarded-Proto": "https", - "X-Forwarded-Tlsversion": "1.2", - "X-Ms-Client-Principal": "your_base_64_encoded_token", - "X-Ms-Client-Principal-Id": "00000000-0000-0000-0000-000000000000", - "X-Ms-Client-Principal-Idp": "aad", - "X-Ms-Client-Principal-Name": "testusername@constoso.com", - "X-Ms-Token-Aad-Id-Token": "your_aad_id_token", - "X-Original-Url": "/chatgpt", - "X-Site-Deployment-Id": "your_app_service", - "X-Waws-Unencoded-Url": "/chatgpt" + "Accept": "*/*", + "Accept-Encoding": "gzip, deflate, br", + "Accept-Language": "en", + "Client-Ip": "22.222.222.2222:64379", + "Content-Length": "192", + "Content-Type": "application/json", + "Cookie": "AppServiceAuthSession=/AuR5ENU+pmpoN3jnymP8fzpmVBgphx9uPQrYLEWGcxjIITIeh8NZW7r3ePkG8yBcMaItlh1pX4nzg5TFD9o2mxC/5BNDRe/uuu0iDlLEdKecROZcVRY7QsFdHLjn9KB90Z3d9ZeLwfVIf0sZowWJt03BO5zKGB7vZgL+ofv3QY3AaYn1k1GtxSE9HQWJpWar7mOA64b7Lsy62eY3nxwg3AWDsP3/rAta+MnDCzpdlZMFXcJLj+rsCppW+w9OqGhKQ7uCs03BPeon3qZOdmE8cOJW3+i96iYlhneNQDItHyQqEi1CHbBTSkqwpeOwWP4vcwGM22ynxPp7YFyiRw/X361DGYy+YkgYBkXq1AEIDZ44BCBz9EEaEi0NU+m6yUOpNjEaUtrJKhQywcM2odojdT4XAY+HfTEfSqp0WiAkgAuE/ueCu2JDOfvxGjCgJ4DGWCoYdOdXAN1c+MenT4OSvkMO41YuPeah9qk9ixkJI5s80lv8rUu1J26QF6pstdDkYkAJAEra3RQiiO1eAH7UEb3xHXn0HW5lX8ZDX3LWiAFGOt5DIKxBKFymBKJGzbPFPYjfczegu0FD8/NQPLl2exAX3mI9oy/tFnATSyLO2E8DxwP5wnYVminZOQMjB/I4g3Go14betm0MlNXlUbU1fyS6Q6JxoCNLDZywCoU9Y65UzimWZbseKsXlOwYukCEpuQ5QPT55LuEAWhtYier8LSh+fvVUsrkqKS+bg0hzuoX53X6aqUr7YB31t0Z2zt5TT/V3qXpdyD8Xyd884PqysSkJYa553sYx93ETDKSsfDguanVfn2si9nvDpvUWf6/R02FmQgXiaaaykMgYyIuEmE77ptsivjH3hj/MN4VlePFWokcchF4ciqqzonmICmjEHEx5zpjU2Kwa+0y7J5ROzVVygcnO1jH6ZKDy9bGGYL547bXx/iiYBYqSIQzleOAkCeULrGN2KEHwckX5MpuRaqTpoxdZH9RJv0mIWxbDA0kwGsbMICQd0ZODBkPUnE84qhzvXInC+TL7MbutPEnGbzgxBAS1c2Ct4vxkkjykOeOxTPxqAhxoefwUfIwZZax6A9LbeYX2bsBpay0lScHcA==", + "Disguised-Host": "your_app_service.azurewebsites.net", + "Host": "your_app_service.azurewebsites.net", + "Max-Forwards": "10", + "Origin": "https://your_app_service.azurewebsites.net", + "Referer": "https://your_app_service.azurewebsites.net/", + "Sec-Ch-Ua": '"Microsoft Edge";v="113", "Chromium";v="113", "Not-A.Brand";v="24"', + "Sec-Ch-Ua-Mobile": "?0", + "Sec-Ch-Ua-Platform": '"Windows"', + "Sec-Fetch-Dest": "empty", + "Sec-Fetch-Mode": "cors", + "Sec-Fetch-Site": "same-origin", + "Traceparent": "00-24e9a8d1b06f233a3f1714845ef971a9-3fac69f81ca5175c-00", + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.0.0 Safari/537.36 Edg/113.0.1774.42", + "Was-Default-Hostname": "your_app_service.azurewebsites.net", + "X-Appservice-Proto": "https", + "X-Arr-Log-Id": "4102b832-6c88-4c7c-8996-0edad9e4358f", + "X-Arr-Ssl": "2048|256|CN=Microsoft Azure TLS Issuing CA 02, O=Microsoft Corporation, C=US|CN=*.azurewebsites.net, O=Microsoft Corporation, L=Redmond, S=WA, C=US", + "X-Client-Ip": "22.222.222.222", + "X-Client-Port": "64379", + "X-Forwarded-For": "22.222.222.22:64379", + "X-Forwarded-Proto": "https", + "X-Forwarded-Tlsversion": "1.2", + "X-Ms-Client-Principal": "your_base_64_encoded_token", + "X-Ms-Client-Principal-Id": "00000000-0000-0000-0000-000000000000", + "X-Ms-Client-Principal-Idp": "aad", + "X-Ms-Client-Principal-Name": "testusername@constoso.com", + "X-Ms-Token-Aad-Id-Token": "your_aad_id_token", + "X-Original-Url": "/chatgpt", + "X-Site-Deployment-Id": "your_app_service", + "X-Waws-Unencoded-Url": "/chatgpt", } diff --git a/ClientAdvisor/App/backend/history/cosmosdbservice.py b/ClientAdvisor/App/backend/history/cosmosdbservice.py index 737c23d9a..e9fba5204 100644 --- a/ClientAdvisor/App/backend/history/cosmosdbservice.py +++ b/ClientAdvisor/App/backend/history/cosmosdbservice.py @@ -2,17 +2,27 @@ from datetime import datetime from azure.cosmos.aio import CosmosClient from azure.cosmos import exceptions - -class CosmosConversationClient(): - - def __init__(self, cosmosdb_endpoint: str, credential: any, database_name: str, container_name: str, enable_message_feedback: bool = False): + + +class CosmosConversationClient: + + def __init__( + self, + cosmosdb_endpoint: str, + credential: any, + database_name: str, + container_name: str, + enable_message_feedback: bool = False, + ): self.cosmosdb_endpoint = cosmosdb_endpoint self.credential = credential self.database_name = database_name self.container_name = container_name self.enable_message_feedback = enable_message_feedback try: - self.cosmosdb_client = CosmosClient(self.cosmosdb_endpoint, credential=credential) + self.cosmosdb_client = CosmosClient( + self.cosmosdb_endpoint, credential=credential + ) except exceptions.CosmosHttpResponseError as e: if e.status_code == 401: raise ValueError("Invalid credentials") from e @@ -20,48 +30,58 @@ def __init__(self, cosmosdb_endpoint: str, credential: any, database_name: str, raise ValueError("Invalid CosmosDB endpoint") from e try: - self.database_client = self.cosmosdb_client.get_database_client(database_name) + self.database_client = self.cosmosdb_client.get_database_client( + database_name + ) except exceptions.CosmosResourceNotFoundError: - raise ValueError("Invalid CosmosDB database name") - + raise ValueError("Invalid CosmosDB database name") + try: - self.container_client = self.database_client.get_container_client(container_name) + self.container_client = self.database_client.get_container_client( + container_name + ) except exceptions.CosmosResourceNotFoundError: - raise ValueError("Invalid CosmosDB container name") - + raise ValueError("Invalid CosmosDB container name") async def ensure(self): - if not self.cosmosdb_client or not self.database_client or not self.container_client: + if ( + not self.cosmosdb_client + or not self.database_client + or not self.container_client + ): return False, "CosmosDB client not initialized correctly" - + try: - database_info = await self.database_client.read() - except: - return False, f"CosmosDB database {self.database_name} on account {self.cosmosdb_endpoint} not found" - + await self.database_client.read() + except Exception: + return ( + False, + f"CosmosDB database {self.database_name} on account {self.cosmosdb_endpoint} not found", + ) + try: - container_info = await self.container_client.read() - except: + await self.container_client.read() + except Exception: return False, f"CosmosDB container {self.container_name} not found" - + return True, "CosmosDB client initialized successfully" - async def create_conversation(self, user_id, title = ''): + async def create_conversation(self, user_id, title=""): conversation = { - 'id': str(uuid.uuid4()), - 'type': 'conversation', - 'createdAt': datetime.utcnow().isoformat(), - 'updatedAt': datetime.utcnow().isoformat(), - 'userId': user_id, - 'title': title + "id": str(uuid.uuid4()), + "type": "conversation", + "createdAt": datetime.utcnow().isoformat(), + "updatedAt": datetime.utcnow().isoformat(), + "userId": user_id, + "title": title, } - ## TODO: add some error handling based on the output of the upsert_item call - resp = await self.container_client.upsert_item(conversation) + # TODO: add some error handling based on the output of the upsert_item call + resp = await self.container_client.upsert_item(conversation) if resp: return resp else: return False - + async def upsert_conversation(self, conversation): resp = await self.container_client.upsert_item(conversation) if resp: @@ -70,95 +90,94 @@ async def upsert_conversation(self, conversation): return False async def delete_conversation(self, user_id, conversation_id): - conversation = await self.container_client.read_item(item=conversation_id, partition_key=user_id) + conversation = await self.container_client.read_item( + item=conversation_id, partition_key=user_id + ) if conversation: - resp = await self.container_client.delete_item(item=conversation_id, partition_key=user_id) + resp = await self.container_client.delete_item( + item=conversation_id, partition_key=user_id + ) return resp else: return True - async def delete_messages(self, conversation_id, user_id): - ## get a list of all the messages in the conversation + # get a list of all the messages in the conversation messages = await self.get_messages(user_id, conversation_id) response_list = [] if messages: for message in messages: - resp = await self.container_client.delete_item(item=message['id'], partition_key=user_id) + resp = await self.container_client.delete_item( + item=message["id"], partition_key=user_id + ) response_list.append(resp) return response_list - - async def get_conversations(self, user_id, limit, sort_order = 'DESC', offset = 0): - parameters = [ - { - 'name': '@userId', - 'value': user_id - } - ] + async def get_conversations(self, user_id, limit, sort_order="DESC", offset=0): + parameters = [{"name": "@userId", "value": user_id}] query = f"SELECT * FROM c where c.userId = @userId and c.type='conversation' order by c.updatedAt {sort_order}" if limit is not None: - query += f" offset {offset} limit {limit}" - + query += f" offset {offset} limit {limit}" + conversations = [] - async for item in self.container_client.query_items(query=query, parameters=parameters): + async for item in self.container_client.query_items( + query=query, parameters=parameters + ): conversations.append(item) - + return conversations async def get_conversation(self, user_id, conversation_id): parameters = [ - { - 'name': '@conversationId', - 'value': conversation_id - }, - { - 'name': '@userId', - 'value': user_id - } + {"name": "@conversationId", "value": conversation_id}, + {"name": "@userId", "value": user_id}, ] - query = f"SELECT * FROM c where c.id = @conversationId and c.type='conversation' and c.userId = @userId" + query = "SELECT * FROM c where c.id = @conversationId and c.type='conversation' and c.userId = @userId" conversations = [] - async for item in self.container_client.query_items(query=query, parameters=parameters): + async for item in self.container_client.query_items( + query=query, parameters=parameters + ): conversations.append(item) - ## if no conversations are found, return None + # if no conversations are found, return None if len(conversations) == 0: return None else: return conversations[0] - + async def create_message(self, uuid, conversation_id, user_id, input_message: dict): message = { - 'id': uuid, - 'type': 'message', - 'userId' : user_id, - 'createdAt': datetime.utcnow().isoformat(), - 'updatedAt': datetime.utcnow().isoformat(), - 'conversationId' : conversation_id, - 'role': input_message['role'], - 'content': input_message['content'] + "id": uuid, + "type": "message", + "userId": user_id, + "createdAt": datetime.utcnow().isoformat(), + "updatedAt": datetime.utcnow().isoformat(), + "conversationId": conversation_id, + "role": input_message["role"], + "content": input_message["content"], } if self.enable_message_feedback: - message['feedback'] = '' - - resp = await self.container_client.upsert_item(message) + message["feedback"] = "" + + resp = await self.container_client.upsert_item(message) if resp: - ## update the parent conversations's updatedAt field with the current message's createdAt datetime value + # update the parent conversations's updatedAt field with the current message's createdAt datetime value conversation = await self.get_conversation(user_id, conversation_id) if not conversation: return "Conversation not found" - conversation['updatedAt'] = message['createdAt'] + conversation["updatedAt"] = message["createdAt"] await self.upsert_conversation(conversation) return resp else: return False - + async def update_message_feedback(self, user_id, message_id, feedback): - message = await self.container_client.read_item(item=message_id, partition_key=user_id) + message = await self.container_client.read_item( + item=message_id, partition_key=user_id + ) if message: - message['feedback'] = feedback + message["feedback"] = feedback resp = await self.container_client.upsert_item(message) return resp else: @@ -166,19 +185,14 @@ async def update_message_feedback(self, user_id, message_id, feedback): async def get_messages(self, user_id, conversation_id): parameters = [ - { - 'name': '@conversationId', - 'value': conversation_id - }, - { - 'name': '@userId', - 'value': user_id - } + {"name": "@conversationId", "value": conversation_id}, + {"name": "@userId", "value": user_id}, ] - query = f"SELECT * FROM c WHERE c.conversationId = @conversationId AND c.type='message' AND c.userId = @userId ORDER BY c.timestamp ASC" + query = "SELECT * FROM c WHERE c.conversationId = @conversationId AND c.type='message' AND c.userId = @userId ORDER BY c.timestamp ASC" messages = [] - async for item in self.container_client.query_items(query=query, parameters=parameters): + async for item in self.container_client.query_items( + query=query, parameters=parameters + ): messages.append(item) return messages - diff --git a/ClientAdvisor/App/backend/utils.py b/ClientAdvisor/App/backend/utils.py index 5c53bd001..ca7f325b0 100644 --- a/ClientAdvisor/App/backend/utils.py +++ b/ClientAdvisor/App/backend/utils.py @@ -104,6 +104,7 @@ def format_non_streaming_response(chatCompletion, history_metadata, apim_request return {} + def format_stream_response(chatCompletionChunk, history_metadata, apim_request_id): response_obj = { "id": chatCompletionChunk.id, @@ -142,7 +143,11 @@ def format_stream_response(chatCompletionChunk, history_metadata, apim_request_i def format_pf_non_streaming_response( - chatCompletion, history_metadata, response_field_name, citations_field_name, message_uuid=None + chatCompletion, + history_metadata, + response_field_name, + citations_field_name, + message_uuid=None, ): if chatCompletion is None: logging.error( @@ -159,15 +164,13 @@ def format_pf_non_streaming_response( try: messages = [] if response_field_name in chatCompletion: - messages.append({ - "role": "assistant", - "content": chatCompletion[response_field_name] - }) + messages.append( + {"role": "assistant", "content": chatCompletion[response_field_name]} + ) if citations_field_name in chatCompletion: - messages.append({ - "role": "tool", - "content": chatCompletion[citations_field_name] - }) + messages.append( + {"role": "tool", "content": chatCompletion[citations_field_name]} + ) response_obj = { "id": chatCompletion["id"], "model": "", @@ -178,7 +181,7 @@ def format_pf_non_streaming_response( "messages": messages, "history_metadata": history_metadata, } - ] + ], } return response_obj except Exception as e: diff --git a/ClientAdvisor/App/db.py b/ClientAdvisor/App/db.py index 03de12ffa..ab7dc375e 100644 --- a/ClientAdvisor/App/db.py +++ b/ClientAdvisor/App/db.py @@ -5,19 +5,15 @@ load_dotenv() -server = os.environ.get('SQLDB_SERVER') -database = os.environ.get('SQLDB_DATABASE') -username = os.environ.get('SQLDB_USERNAME') -password = os.environ.get('SQLDB_PASSWORD') +server = os.environ.get("SQLDB_SERVER") +database = os.environ.get("SQLDB_DATABASE") +username = os.environ.get("SQLDB_USERNAME") +password = os.environ.get("SQLDB_PASSWORD") + def get_connection(): conn = pymssql.connect( - server=server, - user=username, - password=password, - database=database, - as_dict=True - ) + server=server, user=username, password=password, database=database, as_dict=True + ) return conn - \ No newline at end of file diff --git a/ClientAdvisor/App/frontend/__mocks__/dompurify.ts b/ClientAdvisor/App/frontend/__mocks__/dompurify.ts new file mode 100644 index 000000000..02ccb1e8c --- /dev/null +++ b/ClientAdvisor/App/frontend/__mocks__/dompurify.ts @@ -0,0 +1,5 @@ +const DOMPurify = { + sanitize: jest.fn((input: string) => input), // Mock implementation that returns the input +}; + +export default DOMPurify; // Use default export diff --git a/ClientAdvisor/App/frontend/__mocks__/fileMock.ts b/ClientAdvisor/App/frontend/__mocks__/fileMock.ts new file mode 100644 index 000000000..398045fc4 --- /dev/null +++ b/ClientAdvisor/App/frontend/__mocks__/fileMock.ts @@ -0,0 +1,4 @@ +// __mocks__/fileMock.ts +const fileMock = 'test-file-stub'; + +export default fileMock; diff --git a/ClientAdvisor/App/frontend/__mocks__/mockAPIData.ts b/ClientAdvisor/App/frontend/__mocks__/mockAPIData.ts new file mode 100644 index 000000000..721a9c922 --- /dev/null +++ b/ClientAdvisor/App/frontend/__mocks__/mockAPIData.ts @@ -0,0 +1,164 @@ +export const conversationResponseWithCitations = { + answer: { + answer: + "Microsoft AI encompasses a wide range of technologies and solutions that leverage artificial intelligence to empower individuals and organizations. Microsoft's AI platform, Azure AI, helps organizations transform by bringing intelligence and insights to solve their most pressing challenges[doc2]. Azure AI offers enterprise-level and responsible AI protections, enabling organizations to achieve more at scale[doc8]. Microsoft has a long-term partnership with OpenAI and deploys OpenAI's models across its consumer and enterprise products[doc5]. The company is committed to making the promise of AI real and doing it responsibly, guided by principles such as fairness, reliability and safety, privacy and security, inclusiveness, transparency, and accountability[doc1]. Microsoft's AI offerings span various domains, including productivity services, cloud computing, mixed reality, conversational AI, data analytics, and more[doc3][doc6][doc4]. These AI solutions aim to enhance productivity, improve customer experiences, optimize business functions, and drive innovation[doc9][doc7]. However, the adoption of AI also presents challenges and risks, such as biased datasets, ethical considerations, and potential legal and reputational harm[doc11]. Microsoft is committed to addressing these challenges and ensuring the responsible development and deployment of AI technologies[doc10].", + citations: [ + { + content: "someContent", + id: "doc_7ff8f57d63e2eebb0a3372db05153822fdee65e6", + chunk_id: 7, + title: "/documents/MSFT_FY23Q4_10K.docx", + filepath: "MSFT_FY23Q4_10K.docx", + url: "document url", + metadata: null, + }, + { + content: "someContent", + id: "doc_7ff8f57d63e2eebb0a3372db05153822fdee65e6", + chunk_id: 7, + title: "/documents/MSFT_FY23Q4_10K.docx", + filepath: "MSFT_FY23Q4_10K.docx", + url: "document url", + + metadata: null, + }, + { + content: "someContent", + id: "doc_7ff8f57d63e2eebb0a3372db05153822fdee65e6", + chunk_id: 7, + title: "/documents/MSFT_FY23Q4_10K.docx", + filepath: "MSFT_FY23Q4_10K.docx", + url: "document url", + metadata: null, + }, + { + content: "someContent", + id: "doc_14b4ad620c24c5a472f0c4505019c5370b814e17", + chunk_id: 4, + title: + "/documents/MSFT_FY23Q4_10K_DOCUMENT_FOLDER_SRC_IMPORTANT_CHUNKS_LIST_VALID_CHUNKS_ACCESS_TO_MSFT_WINDOWS_BLOBS_CORE_WINDOWS.docx", + filepath: + "MSFT_FY23Q4_10K_DOCUMENT_FOLDER_SRC_IMPORTANT_CHUNKS_LIST_VALID_CHUNKS_ACCESS_TO_MSFT_WINDOWS_BLOBS_CORE_WINDOWS.docx", + url: "document url", + metadata: null, + }, + { + content: "someContent", + id: "doc_7ff8f57d63e2eebb0a3372db05153822fdee65e6", + chunk_id: 7, + title: "/documents/MSFT_FY23Q4_10K.docx", + filepath: "MSFT_FY23Q4_10K.docx", + url: "document url", + metadata: null, + }, + { + content: "someContent", + id: "doc_d85da45581d92f2ff59e261197d2c70c2b6f8802", + chunk_id: 8, + title: "/documents/MSFT_FY23Q4_10K.docx", + filepath: "MSFT_FY23Q4_10K.docx", + url: "document url", + metadata: null, + }, + { + content: "someContent", + id: "doc_3a2261beeaf7820dfdcc3b0d51a58bd981555b92", + chunk_id: 6, + title: "/documents/MSFT_FY23Q4_10K.docx", + filepath: null, + url: "document url", + metadata: null, + }, + { + content: "someContent", + id: "doc_7ff8f57d63e2eebb0a3372db05153822fdee65e6", + chunk_id: 7, + title: "/documents/MSFT_FY23Q4_10K.docx", + filepath: "MSFT_FY23Q4_10K.docx", + url: "document url", + metadata: null, + }, + { + content: "someContent", + id: "doc_3a2261beeaf7820dfdcc3b0d51a58bd981555b92", + chunk_id: 6, + title: "/documents/MSFT_FY23Q4_10K.docx", + filepath: null, + url: "document url", + metadata: null, + }, + { + content: "someContent", + id: "doc_0b803fe4ec1406115ee7f35a9dd9060ad5d905f5", + chunk_id: 57, + title: "/documents/MSFT_FY23Q4_10K.docx", + filepath: "MSFT_FY23Q4_10K.docx", + url: "document url", + metadata: null, + }, + { + content: "someContent", + id: "doc_0b803fe4ec1406115ee7f35a9dd9060ad5d905f5", + chunk_id: 57, + title: "/documents/MSFT_FY23Q4_10K.docx", + filepath: "MSFT_FY23Q4_10K.docx", + url: "document url", + metadata: null, + }, + ], + }, + isActive: false, + index: 2, + }; + + export const decodedConversationResponseWithCitations = { + choices: [ + { + messages: [ + { + content: + '{"citations": [{"content": "[/documents/MSFT_FY23Q4_10K.docx](https://str5z43dncphzu3k.blob.core.windows.net/documents/MSFT_FY23Q4_10K.docx?se=2024-10-01T05%3A23%3A06Z&sp=r&sv=2024-05-04&sr=c&sig=cIyn1/%2Bk5pCX7Liy8PgDiytzArIx/9Vq7GA2eGkmyik%3D)\\n\\n\\n

Our AI platform, Azure AI, is helping organizations transform, bringing intelligence and insights to the hands of their employees and customers to solve their most pressing challenges. Organizations large and small are deploying Azure AI solutions to achieve more at scale, more easily, with the proper enterprise-level and responsible AI protections.

\\n

We have a long-term partnership with OpenAI, a leading AI research and deployment company. We deploy OpenAI\\u2019s models across our consumer and enterprise products. As OpenAI\\u2019s exclusive cloud provider, Azure powers all of OpenAI\'s workloads. We have also increased our investments in the development and deployment of specialized supercomputing systems to accelerate OpenAI\\u2019s research.

\\n

Our hybrid infrastructure offers integrated, end-to-end security, compliance, identity, and management capabilities to support the real-world needs and evolving regulatory requirements of commercial customers and enterprises. Our industry clouds bring together capabilities across the entire Microsoft Cloud, along with industry-specific customizations. Azure Arc simplifies governance and management by delivering a consistent multi-cloud and on-premises management platform.

\\n

Nuance, a leader in conversational AI and ambient intelligence across industries including healthcare, financial services, retail, and telecommunications, joined Microsoft in 2022. Microsoft and Nuance enable organizations to accelerate their business goals with security-focused, cloud-based solutions infused with AI.

\\n

We are accelerating our development of mixed reality solutions with new Azure services and devices. Microsoft Mesh enables organizations to create custom, immersive experiences for the workplace to help bring remote and hybrid workers and teams together.

\\n

The ability to convert data into AI drives our competitive advantage. The Microsoft Intelligent Data Platform is a leading cloud data platform that fully integrates databases, analytics, and governance. The platform empowers organizations to invest more time creating value rather than integrating and managing their data. Microsoft Fabric is an end-to-end, unified analytics platform that brings together all the data and analytics tools that organizations need.

", "id": "doc_7ff8f57d63e2eebb0a3372db05153822fdee65e6", "chunk_id": 7, "title": "/documents/MSFT_FY23Q4_10K.docx", "filepath": "MSFT_FY23Q4_10K.docx", "url": "[/documents/MSFT_FY23Q4_10K.docx](https://str5z43dncphzu3k.blob.core.windows.net/documents/MSFT_FY23Q4_10K.docx?se=2024-10-01T05%3A23%3A06Z&sp=r&sv=2024-05-04&sr=c&sig=cIyn1/%2Bk5pCX7Liy8PgDiytzArIx/9Vq7GA2eGkmyik%3D)", "metadata": {"offset": 13285, "source": "https://str5z43dncphzu3k.blob.core.windows.net/documents/MSFT_FY23Q4_10K.docx_SAS_TOKEN_PLACEHOLDER_", "markdown_url": "[/documents/MSFT_FY23Q4_10K.docx](https://str5z43dncphzu3k.blob.core.windows.net/documents/MSFT_FY23Q4_10K.docx?se=2024-10-01T05%3A23%3A06Z&sp=r&sv=2024-05-04&sr=c&sig=cIyn1/%2Bk5pCX7Liy8PgDiytzArIx/9Vq7GA2eGkmyik%3D)", "title": "/documents/MSFT_FY23Q4_10K.docx", "original_url": "https://str5z43dncphzu3k.blob.core.windows.net/documents/MSFT_FY23Q4_10K.docx_SAS_TOKEN_PLACEHOLDER_", "chunk": 7, "key": "doc_7ff8f57d63e2eebb0a3372db05153822fdee65e6", "filename": "MSFT_FY23Q4_10K"}}, {"content": "[/documents/MSFT_FY23Q4_10K.docx](https://str5z43dncphzu3k.blob.core.windows.net/documents/MSFT_FY23Q4_10K.docx?se=2024-10-01T05%3A23%3A06Z&sp=r&sv=2024-05-04&sr=c&sig=cIyn1/%2Bk5pCX7Liy8PgDiytzArIx/9Vq7GA2eGkmyik%3D)\\n\\n\\n

Azure AI offerings provide a competitive advantage as companies seek ways to optimize and scale their business with machine learning. Azure\\u2019s purpose-built, AI-optimized infrastructure allows advanced models, including GPT-4 services designed for developers and data scientists, to do more with less. Customers can integrate large language models and develop the next generation of AI apps and services.

\\n

Our server products are designed to make IT professionals, developers, and their systems more productive and efficient. Server software is integrated server infrastructure and middleware designed to support software applications built on the Windows Server operating system. This includes the server platform, database, business intelligence, storage, management and operations, virtualization, service-oriented architecture platform, security, and identity software. We also license standalone and software development lifecycle tools for software architects, developers, testers, and project managers. Server products revenue is mainly affected by purchases through volume licensing programs, licenses sold to original equipment manufacturers (\\u201cOEM\\u201d), and retail packaged products. CALs provide access rights to certain server products, including SQL Server and Windows Server, and revenue is reported along with the associated server product.

\\n

Nuance and GitHub include both cloud and on-premises offerings. Nuance provides healthcare and enterprise AI solutions. GitHub provides a collaboration platform and code hosting service for developers.

\\n

Enterprise Services

\\n

Enterprise Services, including Enterprise Support Services, Industry Solutions, and Nuance Professional Services, assist customers in developing, deploying, and managing Microsoft server solutions, Microsoft desktop solutions, and Nuance conversational AI and ambient intelligent solutions, along with providing training and certification to developers and IT professionals on various Microsoft products.

\\n

Competition

", "id": "doc_d955ec06f352569e20f51f8e25c1b13c4b1c0cea", "chunk_id": 23, "title": "/documents/MSFT_FY23Q4_10K.docx", "filepath": "MSFT_FY23Q4_10K.docx", "url": "[/documents/MSFT_FY23Q4_10K.docx](https://str5z43dncphzu3k.blob.core.windows.net/documents/MSFT_FY23Q4_10K.docx?se=2024-10-01T05%3A23%3A06Z&sp=r&sv=2024-05-04&sr=c&sig=cIyn1/%2Bk5pCX7Liy8PgDiytzArIx/9Vq7GA2eGkmyik%3D)", "metadata": {"offset": 48420, "source": "https://str5z43dncphzu3k.blob.core.windows.net/documents/MSFT_FY23Q4_10K.docx_SAS_TOKEN_PLACEHOLDER_", "markdown_url": "[/documents/MSFT_FY23Q4_10K.docx](https://str5z43dncphzu3k.blob.core.windows.net/documents/MSFT_FY23Q4_10K.docx?se=2024-10-01T05%3A23%3A06Z&sp=r&sv=2024-05-04&sr=c&sig=cIyn1/%2Bk5pCX7Liy8PgDiytzArIx/9Vq7GA2eGkmyik%3D)", "title": "/documents/MSFT_FY23Q4_10K.docx", "original_url": "https://str5z43dncphzu3k.blob.core.windows.net/documents/MSFT_FY23Q4_10K.docx_SAS_TOKEN_PLACEHOLDER_", "chunk": 23, "key": "doc_d955ec06f352569e20f51f8e25c1b13c4b1c0cea", "filename": "MSFT_FY23Q4_10K"}}, {"content": "[/documents/MSFT_FY23Q4_10K.docx](https://str5z43dncphzu3k.blob.core.windows.net/documents/MSFT_FY23Q4_10K.docx?se=2024-10-01T05%3A23%3A06Z&sp=r&sv=2024-05-04&sr=c&sig=cIyn1/%2Bk5pCX7Liy8PgDiytzArIx/9Vq7GA2eGkmyik%3D)\\n\\n\\n

PART I

\\n

ITEM\\u00a01. BUSINESS

\\n

GENERAL

\\n

Embracing Our Future

\\n

Microsoft is a technology company whose mission is to empower every person and every organization on the planet to achieve more. We strive to create local opportunity, growth, and impact in every country around the world. We are creating the platforms and tools, powered by artificial intelligence (\\u201cAI\\u201d), that deliver better, faster, and more effective solutions to support small and large business competitiveness, improve educational and health outcomes, grow public-sector efficiency, and empower human ingenuity. From infrastructure and data, to business applications and collaboration, we provide unique, differentiated value to customers.

\\n

In a world of increasing economic complexity, AI has the power to revolutionize many types of work. Microsoft is now innovating and expanding our portfolio with AI capabilities to help people and organizations overcome today\\u2019s challenges and emerge stronger. Customers are looking to unlock value from their digital spend and innovate for this next generation of AI, while simplifying security and management. Those leveraging the Microsoft Cloud are best positioned to take advantage of technological advancements and drive innovation. Our investment in AI spans the entire company, from Microsoft Teams and Outlook, to Bing and Xbox, and we are infusing generative AI capability into our consumer and commercial offerings to deliver copilot capability for all services across the Microsoft Cloud.

\\n

We\\u2019re committed to making the promise of AI real \\u2013 and doing it responsibly. Our work is guided by a core set of principles: fairness, reliability and safety, privacy and security, inclusiveness, transparency, and accountability.

\\n

What We Offer

\\n

Founded in 1975, we develop and support software, services, devices, and solutions that deliver new value for customers and help people and businesses realize their full potential.

\\n

We offer an array of services, including cloud-based solutions that provide customers with software, services, platforms, and content, and we provide solution support and consulting services. We also deliver relevant online advertising to a global audience.

", "id": "doc_14b4ad620c24c5a472f0c4505019c5370b814e17", "chunk_id": 4, "title": "/documents/MSFT_FY23Q4_10K.docx", "filepath": "MSFT_FY23Q4_10K.docx", "url": "[/documents/MSFT_FY23Q4_10K.docx](https://str5z43dncphzu3k.blob.core.windows.net/documents/MSFT_FY23Q4_10K.docx?se=2024-10-01T05%3A23%3A06Z&sp=r&sv=2024-05-04&sr=c&sig=cIyn1/%2Bk5pCX7Liy8PgDiytzArIx/9Vq7GA2eGkmyik%3D)", "metadata": {"offset": 6098, "source": "https://str5z43dncphzu3k.blob.core.windows.net/documents/MSFT_FY23Q4_10K.docx_SAS_TOKEN_PLACEHOLDER_", "markdown_url": "[/documents/MSFT_FY23Q4_10K.docx](https://str5z43dncphzu3k.blob.core.windows.net/documents/MSFT_FY23Q4_10K.docx?se=2024-10-01T05%3A23%3A06Z&sp=r&sv=2024-05-04&sr=c&sig=cIyn1/%2Bk5pCX7Liy8PgDiytzArIx/9Vq7GA2eGkmyik%3D)", "title": "/documents/MSFT_FY23Q4_10K.docx", "original_url": "https://str5z43dncphzu3k.blob.core.windows.net/documents/MSFT_FY23Q4_10K.docx_SAS_TOKEN_PLACEHOLDER_", "chunk": 4, "key": "doc_14b4ad620c24c5a472f0c4505019c5370b814e17", "filename": "MSFT_FY23Q4_10K"}}, {"content": "[/documents/MSFT_FY23Q4_10K.docx](https://str5z43dncphzu3k.blob.core.windows.net/documents/MSFT_FY23Q4_10K.docx?se=2024-10-01T05%3A23%3A06Z&sp=r&sv=2024-05-04&sr=c&sig=cIyn1/%2Bk5pCX7Liy8PgDiytzArIx/9Vq7GA2eGkmyik%3D)\\n\\n\\n

Our AI platform, Azure AI, is helping organizations transform, bringing intelligence and insights to the hands of their employees and customers to solve their most pressing challenges. Organizations large and small are deploying Azure AI solutions to achieve more at scale, more easily, with the proper enterprise-level and responsible AI protections.

\\n

We have a long-term partnership with OpenAI, a leading AI research and deployment company. We deploy OpenAI\\u2019s models across our consumer and enterprise products. As OpenAI\\u2019s exclusive cloud provider, Azure powers all of OpenAI\'s workloads. We have also increased our investments in the development and deployment of specialized supercomputing systems to accelerate OpenAI\\u2019s research.

\\n

Our hybrid infrastructure offers integrated, end-to-end security, compliance, identity, and management capabilities to support the real-world needs and evolving regulatory requirements of commercial customers and enterprises. Our industry clouds bring together capabilities across the entire Microsoft Cloud, along with industry-specific customizations. Azure Arc simplifies governance and management by delivering a consistent multi-cloud and on-premises management platform.

\\n

Nuance, a leader in conversational AI and ambient intelligence across industries including healthcare, financial services, retail, and telecommunications, joined Microsoft in 2022. Microsoft and Nuance enable organizations to accelerate their business goals with security-focused, cloud-based solutions infused with AI.

\\n

We are accelerating our development of mixed reality solutions with new Azure services and devices. Microsoft Mesh enables organizations to create custom, immersive experiences for the workplace to help bring remote and hybrid workers and teams together.

\\n

The ability to convert data into AI drives our competitive advantage. The Microsoft Intelligent Data Platform is a leading cloud data platform that fully integrates databases, analytics, and governance. The platform empowers organizations to invest more time creating value rather than integrating and managing their data. Microsoft Fabric is an end-to-end, unified analytics platform that brings together all the data and analytics tools that organizations need.

", "id": "doc_7ff8f57d63e2eebb0a3372db05153822fdee65e6", "chunk_id": 7, "title": "/documents/MSFT_FY23Q4_10K.docx", "filepath": "MSFT_FY23Q4_10K.docx", "url": "[/documents/MSFT_FY23Q4_10K.docx](https://str5z43dncphzu3k.blob.core.windows.net/documents/MSFT_FY23Q4_10K.docx?se=2024-10-01T05%3A23%3A06Z&sp=r&sv=2024-05-04&sr=c&sig=cIyn1/%2Bk5pCX7Liy8PgDiytzArIx/9Vq7GA2eGkmyik%3D)", "metadata": {"offset": 13285, "source": "https://str5z43dncphzu3k.blob.core.windows.net/documents/MSFT_FY23Q4_10K.docx_SAS_TOKEN_PLACEHOLDER_", "markdown_url": "[/documents/MSFT_FY23Q4_10K.docx](https://str5z43dncphzu3k.blob.core.windows.net/documents/MSFT_FY23Q4_10K.docx?se=2024-10-01T05%3A23%3A06Z&sp=r&sv=2024-05-04&sr=c&sig=cIyn1/%2Bk5pCX7Liy8PgDiytzArIx/9Vq7GA2eGkmyik%3D)", "title": "/documents/MSFT_FY23Q4_10K.docx", "original_url": "https://str5z43dncphzu3k.blob.core.windows.net/documents/MSFT_FY23Q4_10K.docx_SAS_TOKEN_PLACEHOLDER_", "chunk": 7, "key": "doc_7ff8f57d63e2eebb0a3372db05153822fdee65e6", "filename": "MSFT_FY23Q4_10K"}}, {"content": "[/documents/MSFT_FY23Q4_10K.docx](https://str5z43dncphzu3k.blob.core.windows.net/documents/MSFT_FY23Q4_10K.docx?se=2024-10-01T05%3A23%3A06Z&sp=r&sv=2024-05-04&sr=c&sig=cIyn1/%2Bk5pCX7Liy8PgDiytzArIx/9Vq7GA2eGkmyik%3D)\\n\\n\\n

PART I

\\n

ITEM\\u00a01. BUSINESS

\\n

GENERAL

\\n

Embracing Our Future

\\n

Microsoft is a technology company whose mission is to empower every person and every organization on the planet to achieve more. We strive to create local opportunity, growth, and impact in every country around the world. We are creating the platforms and tools, powered by artificial intelligence (\\u201cAI\\u201d), that deliver better, faster, and more effective solutions to support small and large business competitiveness, improve educational and health outcomes, grow public-sector efficiency, and empower human ingenuity. From infrastructure and data, to business applications and collaboration, we provide unique, differentiated value to customers.

\\n

In a world of increasing economic complexity, AI has the power to revolutionize many types of work. Microsoft is now innovating and expanding our portfolio with AI capabilities to help people and organizations overcome today\\u2019s challenges and emerge stronger. Customers are looking to unlock value from their digital spend and innovate for this next generation of AI, while simplifying security and management. Those leveraging the Microsoft Cloud are best positioned to take advantage of technological advancements and drive innovation. Our investment in AI spans the entire company, from Microsoft Teams and Outlook, to Bing and Xbox, and we are infusing generative AI capability into our consumer and commercial offerings to deliver copilot capability for all services across the Microsoft Cloud.

\\n

We\\u2019re committed to making the promise of AI real \\u2013 and doing it responsibly. Our work is guided by a core set of principles: fairness, reliability and safety, privacy and security, inclusiveness, transparency, and accountability.

\\n

What We Offer

\\n

Founded in 1975, we develop and support software, services, devices, and solutions that deliver new value for customers and help people and businesses realize their full potential.

\\n

We offer an array of services, including cloud-based solutions that provide customers with software, services, platforms, and content, and we provide solution support and consulting services. We also deliver relevant online advertising to a global audience.

", "id": "doc_14b4ad620c24c5a472f0c4505019c5370b814e17", "chunk_id": 4, "title": "/documents/MSFT_FY23Q4_10K.docx", "filepath": "MSFT_FY23Q4_10K.docx", "url": "[/documents/MSFT_FY23Q4_10K.docx](https://str5z43dncphzu3k.blob.core.windows.net/documents/MSFT_FY23Q4_10K.docx?se=2024-10-01T05%3A23%3A06Z&sp=r&sv=2024-05-04&sr=c&sig=cIyn1/%2Bk5pCX7Liy8PgDiytzArIx/9Vq7GA2eGkmyik%3D)", "metadata": {"offset": 6098, "source": "https://str5z43dncphzu3k.blob.core.windows.net/documents/MSFT_FY23Q4_10K.docx_SAS_TOKEN_PLACEHOLDER_", "markdown_url": "[/documents/MSFT_FY23Q4_10K.docx](https://str5z43dncphzu3k.blob.core.windows.net/documents/MSFT_FY23Q4_10K.docx?se=2024-10-01T05%3A23%3A06Z&sp=r&sv=2024-05-04&sr=c&sig=cIyn1/%2Bk5pCX7Liy8PgDiytzArIx/9Vq7GA2eGkmyik%3D)", "title": "/documents/MSFT_FY23Q4_10K.docx", "original_url": "https://str5z43dncphzu3k.blob.core.windows.net/documents/MSFT_FY23Q4_10K.docx_SAS_TOKEN_PLACEHOLDER_", "chunk": 4, "key": "doc_14b4ad620c24c5a472f0c4505019c5370b814e17", "filename": "MSFT_FY23Q4_10K"}}, {"content": "[/documents/MSFT_FY23Q4_10K.docx](https://str5z43dncphzu3k.blob.core.windows.net/documents/MSFT_FY23Q4_10K.docx?se=2024-10-01T05%3A23%3A06Z&sp=r&sv=2024-05-04&sr=c&sig=cIyn1/%2Bk5pCX7Liy8PgDiytzArIx/9Vq7GA2eGkmyik%3D)\\n\\n\\n

Our AI platform, Azure AI, is helping organizations transform, bringing intelligence and insights to the hands of their employees and customers to solve their most pressing challenges. Organizations large and small are deploying Azure AI solutions to achieve more at scale, more easily, with the proper enterprise-level and responsible AI protections.

\\n

We have a long-term partnership with OpenAI, a leading AI research and deployment company. We deploy OpenAI\\u2019s models across our consumer and enterprise products. As OpenAI\\u2019s exclusive cloud provider, Azure powers all of OpenAI\'s workloads. We have also increased our investments in the development and deployment of specialized supercomputing systems to accelerate OpenAI\\u2019s research.

\\n

Our hybrid infrastructure offers integrated, end-to-end security, compliance, identity, and management capabilities to support the real-world needs and evolving regulatory requirements of commercial customers and enterprises. Our industry clouds bring together capabilities across the entire Microsoft Cloud, along with industry-specific customizations. Azure Arc simplifies governance and management by delivering a consistent multi-cloud and on-premises management platform.

\\n

Nuance, a leader in conversational AI and ambient intelligence across industries including healthcare, financial services, retail, and telecommunications, joined Microsoft in 2022. Microsoft and Nuance enable organizations to accelerate their business goals with security-focused, cloud-based solutions infused with AI.

\\n

We are accelerating our development of mixed reality solutions with new Azure services and devices. Microsoft Mesh enables organizations to create custom, immersive experiences for the workplace to help bring remote and hybrid workers and teams together.

\\n

The ability to convert data into AI drives our competitive advantage. The Microsoft Intelligent Data Platform is a leading cloud data platform that fully integrates databases, analytics, and governance. The platform empowers organizations to invest more time creating value rather than integrating and managing their data. Microsoft Fabric is an end-to-end, unified analytics platform that brings together all the data and analytics tools that organizations need.

", "id": "doc_7ff8f57d63e2eebb0a3372db05153822fdee65e6", "chunk_id": 7, "title": "/documents/MSFT_FY23Q4_10K.docx", "filepath": "MSFT_FY23Q4_10K.docx", "url": "[/documents/MSFT_FY23Q4_10K.docx](https://str5z43dncphzu3k.blob.core.windows.net/documents/MSFT_FY23Q4_10K.docx?se=2024-10-01T05%3A23%3A06Z&sp=r&sv=2024-05-04&sr=c&sig=cIyn1/%2Bk5pCX7Liy8PgDiytzArIx/9Vq7GA2eGkmyik%3D)", "metadata": {"offset": 13285, "source": "https://str5z43dncphzu3k.blob.core.windows.net/documents/MSFT_FY23Q4_10K.docx_SAS_TOKEN_PLACEHOLDER_", "markdown_url": "[/documents/MSFT_FY23Q4_10K.docx](https://str5z43dncphzu3k.blob.core.windows.net/documents/MSFT_FY23Q4_10K.docx?se=2024-10-01T05%3A23%3A06Z&sp=r&sv=2024-05-04&sr=c&sig=cIyn1/%2Bk5pCX7Liy8PgDiytzArIx/9Vq7GA2eGkmyik%3D)", "title": "/documents/MSFT_FY23Q4_10K.docx", "original_url": "https://str5z43dncphzu3k.blob.core.windows.net/documents/MSFT_FY23Q4_10K.docx_SAS_TOKEN_PLACEHOLDER_", "chunk": 7, "key": "doc_7ff8f57d63e2eebb0a3372db05153822fdee65e6", "filename": "MSFT_FY23Q4_10K"}}], "intent": "Explain Microsoft AI"}', + end_turn: false, + role: "tool", + }, + { + content: + "Microsoft AI refers to the artificial intelligence capabilities and offerings provided by Microsoft. It encompasses a range of technologies and solutions that leverage AI to empower individuals and organizations to achieve more. Microsoft's AI platform, Azure AI, enables organizations to transform their operations by bringing intelligence and insights to employees and customers. It offers AI-optimized infrastructure, advanced models, and AI services designed for developers and data scientists[doc2][doc6]. Microsoft's AI capabilities are integrated into various products and services, including Microsoft Teams, Outlook, Bing, Xbox, and the Microsoft Cloud[doc1][doc4]. The company is committed to developing AI responsibly, guided by principles such as fairness, reliability, privacy, and transparency[doc5]. Additionally, Microsoft has a partnership with OpenAI and deploys OpenAI's models across its consumer and enterprise products[doc3]. Overall, Microsoft AI aims to drive innovation, improve productivity, and deliver value to customers across different industries and sectors.", + end_turn: true, + role: "assistant", + }, + ], + }, + ], + created: "response.created", + id: "response.id", + model: "gpt-35-turbo-16k", + object: "response.object", + }; + + export const citationObj = { + content: + "[/documents/MSFT_FY23Q4_10K.docx](https://str5z43dncphzu3k.blob.core.windows.net/documents/MSFT_FY23Q4_10K.docx?se=2024-10-01T05%3A38%3A07Z&sp=r&sv=2024-05-04&sr=c&sig=8fFfpNI/tv2rdTKAcunuWpW6zJkZuw%2BGvEGo2zQ1QSA%3D)\n\n\n

The ability to convert data into AI drives our competitive advantage. The Microsoft Intelligent Data Platform is a leading cloud data platform that fully integrates databases, analytics, and governance. The platform empowers organizations to invest more time creating value rather than integrating and managing their data. Microsoft Fabric is an end-to-end, unified analytics platform that brings together all the data and analytics tools that organizations need.

\n

GitHub Copilot is at the forefront of AI-powered software development, giving developers a new tool to write code easier and faster so they can focus on more creative problem-solving. From GitHub to Visual Studio, we provide a developer tool chain for everyone, no matter the technical experience, across all platforms, whether Azure, Windows, or any other cloud or client platform.

\n

Windows also plays a critical role in fueling our cloud business with Windows 365, a desktop operating system that’s also a cloud service. From another internet-connected device, including Android or macOS devices, users can run Windows 365, just like a virtual machine.

\n

Additionally, we are extending our infrastructure beyond the planet, bringing cloud computing to space. Azure Orbital is a fully managed ground station as a service for fast downlinking of data.

\n

Create More Personal Computing

\n

We strive to make computing more personal, enabling users to interact with technology in more intuitive, engaging, and dynamic ways.

\n

Windows 11 offers innovations focused on enhancing productivity, including Windows Copilot with centralized AI assistance and Dev Home to help developers become more productive. Windows 11 security and privacy features include operating system security, application security, and user and identity security.

\n

Through our Search, News, Mapping, and Browser services, Microsoft delivers unique trust, privacy, and safety features. In February 2023, we launched an all new, AI-powered Microsoft Edge browser and Bing search engine with Bing Chat to deliver better search, more complete answers, and the ability to generate content. Microsoft Edge is our fast and secure browser that helps protect users’ data. Quick access to AI-powered tools, apps, and more within Microsoft Edge’s sidebar enhance browsing capabilities.

", + id: "2", + chunk_id: 8, + title: "/documents/MSFT_FY23Q4_10K.docx", + filepath: "MSFT_FY23Q4_10K.docx", + url: "[/documents/MSFT_FY23Q4_10K.docx](https://str5z43dncphzu3k.blob.core.windows.net/documents/MSFT_FY23Q4_10K.docx?se=2024-10-01T05%3A38%3A07Z&sp=r&sv=2024-05-04&sr=c&sig=8fFfpNI/tv2rdTKAcunuWpW6zJkZuw%2BGvEGo2zQ1QSA%3D)", + metadata: { + offset: 15580, + source: + "https://str5z43dncphzu3k.blob.core.windows.net/documents/MSFT_FY23Q4_10K.docx_SAS_TOKEN_PLACEHOLDER_", + markdown_url: + "[/documents/MSFT_FY23Q4_10K.docx](https://str5z43dncphzu3k.blob.core.windows.net/documents/MSFT_FY23Q4_10K.docx?se=2024-10-01T05%3A38%3A07Z&sp=r&sv=2024-05-04&sr=c&sig=8fFfpNI/tv2rdTKAcunuWpW6zJkZuw%2BGvEGo2zQ1QSA%3D)", + title: "/documents/MSFT_FY23Q4_10K.docx", + original_url: + "https://str5z43dncphzu3k.blob.core.windows.net/documents/MSFT_FY23Q4_10K.docx_SAS_TOKEN_PLACEHOLDER_", + chunk: 8, + key: "doc_d85da45581d92f2ff59e261197d2c70c2b6f8802", + filename: "MSFT_FY23Q4_10K", + }, + reindex_id: "1", + }; + + export const AIResponseContent = + "Microsoft AI refers to the artificial intelligence capabilities and offerings provided by Microsoft. It encompasses a range of technologies and solutions that leverage AI to empower individuals and organizations to achieve more. Microsoft's AI platform, Azure AI, enables organizations to transform their operations by bringing intelligence and insights to employees and customers. It offers AI-optimized infrastructure, advanced models, and AI services designed for developers and data scientists is an "; \ No newline at end of file diff --git a/ClientAdvisor/App/frontend/__mocks__/react-markdown.tsx b/ClientAdvisor/App/frontend/__mocks__/react-markdown.tsx new file mode 100644 index 000000000..587310af8 --- /dev/null +++ b/ClientAdvisor/App/frontend/__mocks__/react-markdown.tsx @@ -0,0 +1,17 @@ +// __mocks__/react-markdown.tsx + +import React from 'react'; + +// Mock implementation of react-markdown +const mockNode = { + children: [{ value: 'console.log("Test Code");' }] +}; +const mockProps = { className: 'language-javascript' }; + +const ReactMarkdown: React.FC<{ children: React.ReactNode , components: any }> = ({ children,components }) => { + return
+ {components && components.code({ node: mockNode, ...mockProps })} + {children}
; // Simply render the children +}; + +export default ReactMarkdown; diff --git a/ClientAdvisor/App/frontend/jest.config.ts b/ClientAdvisor/App/frontend/jest.config.ts index 956deb7d7..d2c422755 100644 --- a/ClientAdvisor/App/frontend/jest.config.ts +++ b/ClientAdvisor/App/frontend/jest.config.ts @@ -2,10 +2,47 @@ import type { Config } from '@jest/types' const config: Config.InitialOptions = { verbose: true, + + preset: 'ts-jest', + //testEnvironment: 'jsdom', // For React DOM testing + testEnvironment: 'jest-environment-jsdom', + testEnvironmentOptions: { + customExportConditions: [''] + }, + moduleNameMapper: { + '\\.(css|less|scss)$': 'identity-obj-proxy', // For mocking static file imports + '^react-markdown$': '/__mocks__/react-markdown.tsx', + '^dompurify$': '/__mocks__/dompurify.js', // Point to the mock + '\\.(jpg|jpeg|png|gif|svg)$': '/__mocks__/fileMock.ts', + + }, + setupFilesAfterEnv: ['/src/test/setupTests.ts'], // For setting up testing environment like jest-dom transform: { - '^.+\\.tsx?$': 'ts-jest' + '^.+\\.ts(x)?$': 'ts-jest', // For TypeScript files + '^.+\\.js$': 'babel-jest', // For JavaScript files if you have Babel }, - setupFilesAfterEnv: ['/polyfills.js'] + + setupFiles: ['/jest.polyfills.js'], + collectCoverage: true, + //collectCoverageFrom: ['src/**/*.{ts,tsx}'], // Adjust the path as needed + //coverageReporters: ['json', 'lcov', 'text', 'clover'], + coverageThreshold: { + global: { + branches: 80, + functions: 80, + lines: 80, + statements: 80, + }, + }, + + coveragePathIgnorePatterns: [ + '/node_modules/', // Ignore node_modules + '/__mocks__/', // Ignore mocks + '/src/state/', + '/src/api/', + '/src/mocks/', + //'/src/test/', + ], } export default config diff --git a/ClientAdvisor/App/frontend/jest.polyfills.js b/ClientAdvisor/App/frontend/jest.polyfills.js new file mode 100644 index 000000000..5aeed29c2 --- /dev/null +++ b/ClientAdvisor/App/frontend/jest.polyfills.js @@ -0,0 +1,28 @@ +/** + * @note The block below contains polyfills for Node.js globals + * required for Jest to function when running JSDOM tests. + * These HAVE to be require's and HAVE to be in this exact + * order, since "undici" depends on the "TextEncoder" global API. + * + * Consider migrating to a more modern test runner if + * you don't want to deal with this. + */ + +const { TextDecoder, TextEncoder } = require('node:util') + +Object.defineProperties(globalThis, { + TextDecoder: { value: TextDecoder }, + TextEncoder: { value: TextEncoder }, +}) + +const { Blob } = require('node:buffer') +const { fetch, Headers, FormData, Request, Response } = require('undici') + +Object.defineProperties(globalThis, { + fetch: { value: fetch, writable: true }, + Blob: { value: Blob }, + Headers: { value: Headers }, + FormData: { value: FormData }, + Request: { value: Request }, + Response: { value: Response }, +}) \ No newline at end of file diff --git a/ClientAdvisor/App/frontend/package.json b/ClientAdvisor/App/frontend/package.json index 15c9c5c91..d1ca6788b 100644 --- a/ClientAdvisor/App/frontend/package.json +++ b/ClientAdvisor/App/frontend/package.json @@ -7,7 +7,8 @@ "dev": "vite", "build": "tsc && vite build", "watch": "tsc && vite build --watch", - "test": "jest", + "test": "jest --coverage --verbose", + "test:coverage": "jest --coverage --verbose --watchAll", "lint": "npx eslint src", "lint:fix": "npx eslint --fix", "prettier": "npx prettier src --check", @@ -24,17 +25,20 @@ "lodash-es": "^4.17.21", "react": "^18.2.0", "react-dom": "^18.2.0", - "react-markdown": "^7.0.1", "react-router-dom": "^6.8.1", "react-syntax-highlighter": "^15.5.0", "react-uuid": "^2.0.0", "rehype-raw": "^6.1.1", "remark-gfm": "^3.0.1", - "remark-supersub": "^1.0.0" + "remark-supersub": "^1.0.0", + "undici": "^5.0.0" }, "devDependencies": { "@eslint/eslintrc": "^3.0.2", "@eslint/js": "^9.1.1", + "@testing-library/jest-dom": "^6.5.0", + "@testing-library/react": "^16.0.1", + "@testing-library/user-event": "^14.5.2", "@types/dompurify": "^3.0.5", "@types/eslint-config-prettier": "^6.11.3", "@types/jest": "^29.5.12", @@ -44,6 +48,7 @@ "@types/react": "^18.0.27", "@types/react-dom": "^18.0.10", "@types/react-syntax-highlighter": "^15.5.11", + "@types/testing-library__user-event": "^4.2.0", "@typescript-eslint/eslint-plugin": "^6.21.0", "@typescript-eslint/parser": "^6.21.0", "@vitejs/plugin-react": "^3.1.0", @@ -59,12 +64,16 @@ "eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-simple-import-sort": "^12.1.0", "globals": "^15.0.0", + "identity-obj-proxy": "^3.0.0", "jest": "^29.7.0", + "jest-environment-jsdom": "^29.7.0", "lint-staged": "^15.2.2", + "msw": "2.2.2", "prettier": "^3.2.5", + "react-markdown": "^8.0.0", "react-test-renderer": "^18.2.0", "string.prototype.replaceall": "^1.0.10", - "ts-jest": "^29.1.2", + "ts-jest": "^29.2.5", "ts-node": "^10.9.2", "typescript": "^4.9.5", "vite": "^4.1.5" diff --git a/ClientAdvisor/App/frontend/src/api/models.ts b/ClientAdvisor/App/frontend/src/api/models.ts index 55c0756fe..43cb84c82 100644 --- a/ClientAdvisor/App/frontend/src/api/models.ts +++ b/ClientAdvisor/App/frontend/src/api/models.ts @@ -141,3 +141,9 @@ export interface ClientIdRequest { } +export interface GroupedChatHistory { + month: string + entries: Conversation[] +} + + diff --git a/ClientAdvisor/App/frontend/src/components/Answer/Answer.test.tsx b/ClientAdvisor/App/frontend/src/components/Answer/Answer.test.tsx new file mode 100644 index 000000000..5547f1f44 --- /dev/null +++ b/ClientAdvisor/App/frontend/src/components/Answer/Answer.test.tsx @@ -0,0 +1,543 @@ +import { renderWithContext, screen, waitFor, fireEvent, act, logRoles } from '../../test/test.utils'; +import { Answer } from './Answer' +import { AppStateContext } from '../../state/AppProvider' +import {AskResponse, Citation, Feedback, historyMessageFeedback } from '../../api'; +//import { Feedback, AskResponse, Citation } from '../../api/models' +import { cloneDeep } from 'lodash' +import userEvent from '@testing-library/user-event'; +import { CitationPanel } from '../../pages/chat/Components/CitationPanel'; + +// Mock required modules and functions +jest.mock('../../api/api', () => ({ + historyMessageFeedback: jest.fn(), +})) + +jest.mock('react-syntax-highlighter/dist/esm/styles/prism', () => ({ + nord: { + // Mock style object (optional) + 'code[class*="language-"]': { + color: '#e0e0e0', // Example mock style + background: '#2e3440', // Example mock style + }, + }, +})); + +// Mocking remark-gfm and rehype-raw +jest.mock('remark-gfm', () => jest.fn()); +jest.mock('rehype-raw', () => jest.fn()); +jest.mock('remark-supersub', () => jest.fn()); + +const mockDispatch = jest.fn(); +const mockOnCitationClicked = jest.fn(); + +// Mock context provider values +let mockAppState = { + frontendSettings: { feedback_enabled: true, sanitize_answer: true }, + isCosmosDBAvailable: { cosmosDB: true }, + +} + +const mockCitations: Citation[] = [ + { + id: 'doc1', + filepath: 'C:\code\CWYOD-2\chat-with-your-data-solution-accelerator\docs\file1.pdf', + part_index: undefined, + content: '', + title: null, + url: null, + metadata: null, + chunk_id: null, + reindex_id: '1' + }, + { + id: 'doc2', + filepath: 'file2.pdf', + part_index: undefined, + content: '', + title: null, + url: null, + metadata: null, + chunk_id: null, + reindex_id: '2' + }, + { + id: 'doc3', + filepath: '', + part_index: undefined, + content: '', + title: null, + url: null, + metadata: null, + chunk_id: null, + reindex_id: '3' + } +] +let mockAnswerProps: AskResponse = { + answer: 'This is an example answer with citations [doc1] and [doc2] and [doc3].', + message_id: '123', + feedback: Feedback.Neutral, + citations: cloneDeep(mockCitations) +} + +const toggleIsRefAccordionOpen = jest.fn(); +const onCitationClicked = jest.fn(); + +describe('Answer Component', () => { + beforeEach(() => { + global.fetch = jest.fn(); + onCitationClicked.mockClear(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + const isEmpty = (obj: any) => Object.keys(obj).length === 0; + + const renderComponent = (props?: any, appState?: any) => { + if (appState != undefined) { + mockAppState = { ...mockAppState, ...appState } + } + return ( + renderWithContext(, mockAppState) + ) + + } + + + it('should render the answer component correctly', () => { + renderComponent(); + + // Check if citations and feedback buttons are rendered + expect(screen.getByText(/This is an example answer with citations/i)).toBeInTheDocument(); + expect(screen.getByLabelText('Like this response')).toBeInTheDocument(); + expect(screen.getByLabelText('Dislike this response')).toBeInTheDocument(); + }); + + it('should render the answer component correctly when sanitize_answer is false', () => { + + const answerWithMissingFeedback = { + ...mockAnswerProps + } + const extraMockState = { + frontendSettings: { feedback_enabled: true, sanitize_answer: false }, + } + + renderComponent(answerWithMissingFeedback,extraMockState); + + // Check if citations and feedback buttons are rendered + expect(screen.getByText(/This is an example answer with citations/i)).toBeInTheDocument(); + }); + + it('should show "1 reference" when citations lenght is one', () => { + + const answerWithMissingFeedback = { + ...mockAnswerProps, + answer: 'This is an example answer with citations [doc1]', + } + + renderComponent(answerWithMissingFeedback); + + // Check if citations and feedback buttons are rendered + expect(screen.getByText(/1 reference/i)).toBeInTheDocument(); + }); + + + it('returns undefined when message_id is undefined', () => { + + const answerWithMissingFeedback = { + answer: 'This is an example answer with citations [doc1] and [doc2].', + feedback: 'Test', + citations: [] + } + + renderComponent(answerWithMissingFeedback); + + // Check if citations and feedback buttons are rendered + expect(screen.getByText(/This is an example answer with citations/i)).toBeInTheDocument(); + }); + + it('returns undefined when feedback is undefined', () => { + + const answerWithMissingFeedback = { + answer: 'This is an example answer with citations [doc1] and [doc2].', + message_id: '123', + citations: [] + } + + renderComponent(answerWithMissingFeedback); + + // Check if citations and feedback buttons are rendered + expect(screen.getByText(/This is an example answer with citations/i)).toBeInTheDocument(); + }); + + it('returns Feedback.Negative when feedback contains more than one item', () => { + + const answerWithMissingFeedback = { + answer: 'This is an example answer with citations [doc1] and [doc2].', + message_id: '123', + feedback: 'negative,neutral', + citations: [] + } + + renderComponent(answerWithMissingFeedback); + + // Check if citations and feedback buttons are rendered + expect(screen.getByText(/This is an example answer with citations/i)).toBeInTheDocument(); + }); + + + it('calls toggleIsRefAccordionOpen when Enter key is pressed', () => { + renderComponent(); + + // Check if citations and feedback buttons are rendered + const stackItem = screen.getByTestId('stack-item'); + + // Simulate pressing the Enter key + fireEvent.keyDown(stackItem, { key: 'Enter', code: 'Enter', charCode: 13 }); + + // Check if the function is called + // expect(onCitationClicked).toHaveBeenCalled(); + }); + + it('calls toggleIsRefAccordionOpen when Space key is pressed', () => { + renderComponent(); + + // Check if citations and feedback buttons are rendered + const stackItem = screen.getByTestId('stack-item'); + + // Simulate pressing the Escape key + fireEvent.keyDown(stackItem, { key: ' ', code: 'Space', charCode: 32 }); + + // Check if the function is called + // expect(toggleIsRefAccordionOpen).toHaveBeenCalled(); + }); + + it('does not call toggleIsRefAccordionOpen when Tab key is pressed', () => { + renderComponent(); + + const stackItem = screen.getByTestId('stack-item'); + + // Simulate pressing the Tab key + fireEvent.keyDown(stackItem, { key: 'Tab', code: 'Tab', charCode: 9 }); + + // Check that the function is not called + expect(toggleIsRefAccordionOpen).not.toHaveBeenCalled(); + }); + + + it('should handle chevron click to toggle references accordion', async () => { + renderComponent(); + + // Chevron is initially collapsed + const chevronIcon = screen.getByRole('button', { name: 'Open references' }); + const element = screen.getByTestId('ChevronIcon') + expect(element).toHaveAttribute('data-icon-name', 'ChevronRight') + + // Click to expand + fireEvent.click(chevronIcon); + //expect(screen.getByText('ChevronDown')).toBeInTheDocument(); + expect(element).toHaveAttribute('data-icon-name', 'ChevronDown') + }); + + it('calls onCitationClicked when citation is clicked', async () => { + userEvent.setup(); + renderComponent(); + + // Chevron is initially collapsed + const chevronIcon = screen.getByRole('button', { name: 'Open references' }); + const element = screen.getByTestId('ChevronIcon') + expect(element).toHaveAttribute('data-icon-name', 'ChevronRight') + + // Click to expand + await userEvent.click(chevronIcon); + const citations = screen.getAllByRole('link'); + + // Simulate click on the first citation + await userEvent.click(citations[0]); + + // Check if the function is called with the correct citation + expect(onCitationClicked).toHaveBeenCalledTimes(1); + }) + + it('calls onCitationClicked when Enter key is pressed', async () => { + userEvent.setup(); + renderComponent(); + + // Chevron is initially collapsed + const chevronIcon = screen.getByRole('button', { name: 'Open references' }); + const element = screen.getByTestId('ChevronIcon') + expect(element).toHaveAttribute('data-icon-name', 'ChevronRight') + + // Click to expand + await userEvent.click(chevronIcon); + + // Get the first citation span + const citation = screen.getAllByRole('link')[0]; + + // Simulate pressing the Enter key + fireEvent.keyDown(citation, { key: 'Enter', code: 'Enter' }); + + // Check if the function is called with the correct citation + expect(onCitationClicked).toHaveBeenCalledTimes(1) + }); + + it('calls onCitationClicked when Space key is pressed', async () => { + userEvent.setup(); + renderComponent(); + + // Chevron is initially collapsed + const chevronIcon = screen.getByRole('button', { name: 'Open references' }); + const element = screen.getByTestId('ChevronIcon') + expect(element).toHaveAttribute('data-icon-name', 'ChevronRight') + + // Click to expand + await userEvent.click(chevronIcon); + + // Get the first citation span + const citation = screen.getAllByRole('link')[0]; + + // Simulate pressing the Space key + fireEvent.keyDown(citation, { key: ' ', code: 'Space' }); + + // Check if the function is called with the correct citation + expect(onCitationClicked).toHaveBeenCalledTimes(1); + }); + + it('does not call onCitationClicked for other keys', async() => { + userEvent.setup(); + renderComponent(); + + // Chevron is initially collapsed + const chevronIcon = screen.getByRole('button', { name: 'Open references' }); + const element = screen.getByTestId('ChevronIcon') + expect(element).toHaveAttribute('data-icon-name', 'ChevronRight') + + // Click to expand + await userEvent.click(chevronIcon); + + // Get the first citation span + const citation = screen.getAllByRole('link')[0]; + + // Simulate pressing a different key (e.g., 'a') + fireEvent.keyDown(citation, { key: 'a', code: 'KeyA' }); + + // Check if the function is not called + expect(onCitationClicked).not.toHaveBeenCalled(); + }); + + it('should update feedback state on like button click', async () => { + renderComponent(); + + const likeButton = screen.getByLabelText('Like this response'); + + // Initially neutral feedback + await act(async () => { + fireEvent.click(likeButton); + }); + await waitFor(() => { + expect(historyMessageFeedback).toHaveBeenCalledWith(mockAnswerProps.message_id, Feedback.Positive); + }); + + // // Clicking again should set feedback to neutral + // const likeButton1 = screen.getByLabelText('Like this response'); + // await act(async()=>{ + // fireEvent.click(likeButton1); + // }); + // await waitFor(() => { + // expect(historyMessageFeedback).toHaveBeenCalledWith(mockAnswerProps.message_id, Feedback.Neutral); + // }); + }); + + it('should open and submit negative feedback dialog', async () => { + userEvent.setup(); + renderComponent(); + const handleChange = jest.fn(); + const dislikeButton = screen.getByLabelText('Dislike this response'); + + // Click dislike to open dialog + await fireEvent.click(dislikeButton); + expect(screen.getByText("Why wasn't this response helpful?")).toBeInTheDocument(); + + // Select feedback and submit + const checkboxEle = await screen.findByLabelText(/Citations are wrong/i) + //logRoles(checkboxEle) + await waitFor(() => { + userEvent.click(checkboxEle); + }); + await userEvent.click(screen.getByText('Submit')); + + await waitFor(() => { + expect(historyMessageFeedback).toHaveBeenCalledWith(mockAnswerProps.message_id, `${Feedback.WrongCitation}`); + }); + }); + + it('calls resetFeedbackDialog and setFeedbackState with Feedback.Neutral on dialog dismiss', async () => { + + const resetFeedbackDialogMock = jest.fn(); + const setFeedbackStateMock = jest.fn(); + + userEvent.setup(); + renderComponent(); + const handleChange = jest.fn(); + const dislikeButton = screen.getByLabelText('Dislike this response'); + + // Click dislike to open dialog + await userEvent.click(dislikeButton); + expect(screen.getByText("Why wasn't this response helpful?")).toBeInTheDocument(); + + // Assuming there is a close button in the dialog that dismisses it + const dismissButton = screen.getByRole('button', { name: /close/i }); // Adjust selector as needed + + // Simulate clicking the dismiss button + await userEvent.click(dismissButton); + + // Assert that the mocks were called + //expect(resetFeedbackDialogMock).toHaveBeenCalled(); + //expect(setFeedbackStateMock).toHaveBeenCalledWith('Neutral'); + + }); + + + it('Dialog Options should be able to select and unSelect', async () => { + userEvent.setup(); + renderComponent(); + const handleChange = jest.fn(); + const dislikeButton = screen.getByLabelText('Dislike this response'); + + // Click dislike to open dialog + await userEvent.click(dislikeButton); + + expect(screen.getByText("Why wasn't this response helpful?")).toBeInTheDocument(); + + // Select feedback and submit + const checkboxEle = await screen.findByLabelText(/Citations are wrong/i) + expect(checkboxEle).not.toBeChecked(); + + await userEvent.click(checkboxEle); + await waitFor(() => { + expect(checkboxEle).toBeChecked(); + }); + + const checkboxEle1 = await screen.findByLabelText(/Citations are wrong/i) + + await userEvent.click(checkboxEle1); + await waitFor(() => { + expect(checkboxEle1).not.toBeChecked(); + }); + + }); + + it('Should able to show ReportInappropriateFeedbackContent form while click on "InappropriateFeedback" button ', async () => { + userEvent.setup(); + renderComponent(); + const handleChange = jest.fn(); + const dislikeButton = screen.getByLabelText('Dislike this response'); + + // Click dislike to open dialog + await userEvent.click(dislikeButton); + + const InappropriateFeedbackDivBtn = screen.getByTestId("InappropriateFeedback") + expect(InappropriateFeedbackDivBtn).toBeInTheDocument(); + + await userEvent.click(InappropriateFeedbackDivBtn); + + await waitFor(() => { + expect(screen.getByTestId("ReportInappropriateFeedbackContent")).toBeInTheDocument(); + }) + }); + + it('should handle citation click and trigger callback', async () => { + userEvent.setup(); + renderComponent(); + const citationText = screen.getByTestId('ChevronIcon'); + await userEvent.click(citationText); + expect(citationText).toHaveAttribute('data-icon-name', 'ChevronDown') + }); + + it('should handle if we do not pass feedback ', () => { + + const answerWithMissingFeedback = { + answer: 'This is an example answer with citations [doc1] and [doc2].', + message_id: '123', + feedback: 'Test', + citations: [] + } + const extraMockState = { + feedbackState: { '123': Feedback.Neutral }, + } + renderComponent(answerWithMissingFeedback, extraMockState); + }) + + + it('should update feedback state on like button click - 1', async () => { + + const answerWithMissingFeedback = { + ...mockAnswerProps, + answer: 'This is an example answer with citations [doc1] and [doc2].', + message_id: '123', + feedback: Feedback.Neutral, + } + const extraMockState = { + feedbackState: { '123': Feedback.Positive }, + } + renderComponent(answerWithMissingFeedback, extraMockState); + const likeButton = screen.getByLabelText('Like this response'); + + // Initially neutral feedback + await act(async () => { + fireEvent.click(likeButton); + }); + await waitFor(() => { + expect(historyMessageFeedback).toHaveBeenCalledWith(mockAnswerProps.message_id, Feedback.Neutral); + }); + + }); + + it('should open and submit negative feedback dialog -1', async () => { + userEvent.setup(); + const answerWithMissingFeedback = { + ...mockAnswerProps, + answer: 'This is an example answer with citations [doc1] and [doc2].', + message_id: '123', + feedback: Feedback.OtherHarmful, + } + const extraMockState = { + feedbackState: { '123': Feedback.OtherHarmful }, + } + renderComponent(answerWithMissingFeedback, extraMockState); + const handleChange = jest.fn(); + const dislikeButton = screen.getByLabelText('Dislike this response'); + + // Click dislike to open dialog + await userEvent.click(dislikeButton); + await waitFor(() => { + expect(historyMessageFeedback).toHaveBeenCalledWith(mockAnswerProps.message_id, Feedback.Neutral); + }); + }); + + it('should handle chevron click to toggle references accordion - 1', async () => { + let tempMockCitation = [...mockCitations]; + + tempMockCitation[0].filepath = ''; + tempMockCitation[0].reindex_id = ''; + const answerWithMissingFeedback = { + ...mockAnswerProps, + CitationPanel: [...tempMockCitation] + } + + renderComponent(); + + // Chevron is initially collapsed + const chevronIcon = screen.getByRole('button', { name: 'Open references' }); + const element = screen.getByTestId('ChevronIcon') + expect(element).toHaveAttribute('data-icon-name', 'ChevronRight') + + // Click to expand + fireEvent.click(chevronIcon); + //expect(screen.getByText('ChevronDown')).toBeInTheDocument(); + expect(element).toHaveAttribute('data-icon-name', 'ChevronDown') + }); + + +}) diff --git a/ClientAdvisor/App/frontend/src/components/Answer/Answer.tsx b/ClientAdvisor/App/frontend/src/components/Answer/Answer.tsx index 744a003d6..08c345ea8 100644 --- a/ClientAdvisor/App/frontend/src/components/Answer/Answer.tsx +++ b/ClientAdvisor/App/frontend/src/components/Answer/Answer.tsx @@ -16,6 +16,7 @@ import { AppStateContext } from '../../state/AppProvider' import { parseAnswer } from './AnswerParser' import styles from './Answer.module.css' +import rehypeRaw from 'rehype-raw' interface Props { answer: AskResponse @@ -77,8 +78,8 @@ export const Answer = ({ answer, onCitationClicked }: Props) => { } else { citationFilename = `${citation.filepath} - Part ${part_i}` } - } else if (citation.filepath && citation.reindex_id) { - citationFilename = `${citation.filepath} - Part ${citation.reindex_id}` + // } else if (citation.filepath && citation.reindex_id) { + // citationFilename = `${citation.filepath} - Part ${citation.reindex_id}` } else { citationFilename = `Citation ${index}` } @@ -86,63 +87,70 @@ export const Answer = ({ answer, onCitationClicked }: Props) => { } const onLikeResponseClicked = async () => { - if (answer.message_id == undefined) return + // if (answer.message_id == undefined) return + if (answer.message_id) { + let newFeedbackState = feedbackState + // Set or unset the thumbs up state + if (feedbackState == Feedback.Positive) { + newFeedbackState = Feedback.Neutral + } else { + newFeedbackState = Feedback.Positive + } + appStateContext?.dispatch({ + type: 'SET_FEEDBACK_STATE', + payload: { answerId: answer.message_id, feedback: newFeedbackState } + }) + setFeedbackState(newFeedbackState) - let newFeedbackState = feedbackState - // Set or unset the thumbs up state - if (feedbackState == Feedback.Positive) { - newFeedbackState = Feedback.Neutral - } else { - newFeedbackState = Feedback.Positive + // Update message feedback in db + await historyMessageFeedback(answer.message_id, newFeedbackState) } - appStateContext?.dispatch({ - type: 'SET_FEEDBACK_STATE', - payload: { answerId: answer.message_id, feedback: newFeedbackState } - }) - setFeedbackState(newFeedbackState) - - // Update message feedback in db - await historyMessageFeedback(answer.message_id, newFeedbackState) } const onDislikeResponseClicked = async () => { - if (answer.message_id == undefined) return - - let newFeedbackState = feedbackState - if (feedbackState === undefined || feedbackState === Feedback.Neutral || feedbackState === Feedback.Positive) { - newFeedbackState = Feedback.Negative - setFeedbackState(newFeedbackState) - setIsFeedbackDialogOpen(true) - } else { - // Reset negative feedback to neutral - newFeedbackState = Feedback.Neutral - setFeedbackState(newFeedbackState) - await historyMessageFeedback(answer.message_id, Feedback.Neutral) + //if (answer.message_id == undefined) return + if (answer.message_id) { + let newFeedbackState = feedbackState + if (feedbackState === undefined || feedbackState === Feedback.Neutral || feedbackState === Feedback.Positive) { + newFeedbackState = Feedback.Negative + setFeedbackState(newFeedbackState) + setIsFeedbackDialogOpen(true) + } else { + // Reset negative feedback to neutral + newFeedbackState = Feedback.Neutral + setFeedbackState(newFeedbackState) + await historyMessageFeedback(answer.message_id, Feedback.Neutral) + } + appStateContext?.dispatch({ + type: 'SET_FEEDBACK_STATE', + payload: { answerId: answer.message_id, feedback: newFeedbackState } + }) } - appStateContext?.dispatch({ - type: 'SET_FEEDBACK_STATE', - payload: { answerId: answer.message_id, feedback: newFeedbackState } - }) } const updateFeedbackList = (ev?: FormEvent, checked?: boolean) => { - if (answer.message_id == undefined) return - const selectedFeedback = (ev?.target as HTMLInputElement)?.id as Feedback + //if (answer.message_id == undefined) return + if (answer.message_id){ + const selectedFeedback = (ev?.target as HTMLInputElement)?.id as Feedback - let feedbackList = negativeFeedbackList.slice() - if (checked) { - feedbackList.push(selectedFeedback) - } else { - feedbackList = feedbackList.filter(f => f !== selectedFeedback) + let feedbackList = negativeFeedbackList.slice() + if (checked) { + feedbackList.push(selectedFeedback) + } else { + feedbackList = feedbackList.filter(f => f !== selectedFeedback) + } + + setNegativeFeedbackList(feedbackList) } - - setNegativeFeedbackList(feedbackList) + } const onSubmitNegativeFeedback = async () => { - if (answer.message_id == undefined) return - await historyMessageFeedback(answer.message_id, negativeFeedbackList.join(',')) - resetFeedbackDialog() + //if (answer.message_id == undefined) return + if (answer.message_id) { + await historyMessageFeedback(answer.message_id, negativeFeedbackList.join(',')) + resetFeedbackDialog() + } } const resetFeedbackDialog = () => { @@ -182,7 +190,7 @@ export const Answer = ({ answer, onCitationClicked }: Props) => { defaultChecked={negativeFeedbackList.includes(Feedback.OtherUnhelpful)} onChange={updateFeedbackList}> -
setShowReportInappropriateFeedback(true)} style={{ color: '#115EA3', cursor: 'pointer' }}> +
setShowReportInappropriateFeedback(true)} style={{ color: '#115EA3', cursor: 'pointer' }}> Report inappropriate content
@@ -191,7 +199,7 @@ export const Answer = ({ answer, onCitationClicked }: Props) => { const ReportInappropriateFeedbackContent = () => { return ( - <> +
The content is *
@@ -222,12 +230,12 @@ export const Answer = ({ answer, onCitationClicked }: Props) => { defaultChecked={negativeFeedbackList.includes(Feedback.OtherHarmful)} onChange={updateFeedbackList}> - +
) } const components = { - code({ node, ...props }: { node: any; [key: string]: any }) { + code({ node, ...props }: { node: any;[key: string]: any }) { let language if (props.className) { const match = props.className.match(/language-(\w+)/) @@ -250,6 +258,7 @@ export const Answer = ({ answer, onCitationClicked }: Props) => { { onClick={() => onLikeResponseClicked()} style={ feedbackState === Feedback.Positive || - appStateContext?.state.feedbackState[answer.message_id] === Feedback.Positive + appStateContext?.state.feedbackState[answer.message_id] === Feedback.Positive ? { color: 'darkgreen', cursor: 'pointer' } : { color: 'slategray', cursor: 'pointer' } } @@ -279,8 +288,8 @@ export const Answer = ({ answer, onCitationClicked }: Props) => { onClick={() => onDislikeResponseClicked()} style={ feedbackState !== Feedback.Positive && - feedbackState !== Feedback.Neutral && - feedbackState !== undefined + feedbackState !== Feedback.Neutral && + feedbackState !== undefined ? { color: 'darkred', cursor: 'pointer' } : { color: 'slategray', cursor: 'pointer' } } @@ -292,7 +301,7 @@ export const Answer = ({ answer, onCitationClicked }: Props) => { {!!parsedAnswer.citations.length && ( - (e.key === 'Enter' || e.key === ' ' ? toggleIsRefAccordionOpen() : null)}> + (e.key === 'Enter' || e.key === ' ' ? toggleIsRefAccordionOpen() : null)}> { ({ + getUsers: jest.fn() +})) + +beforeEach(() => { + jest.spyOn(console, 'error').mockImplementation(() => {}) +}) + +afterEach(() => { + jest.clearAllMocks() +}) + +const mockDispatch = jest.fn() +const mockOnCardClick = jest.fn() + +jest.mock('../UserCard/UserCard', () => ({ + UserCard: (props: any) => ( +
props.onCardClick(props)}> + {props.ClientName} + {props.isSelected ? 'Selected' : 'not selected'} +
+ ) +})) + +const mockUsers = [ + { + ClientId: '1', + ClientName: 'Client 1', + NextMeeting: 'Test Meeting 1', + NextMeetingTime: '10:00', + AssetValue: 10000, + LastMeeting: 'Last Meeting 1', + ClientSummary: 'Summary for User One', + chartUrl: '' + } +] + +const multipleUsers = [ + { + ClientId: '1', + ClientName: 'Client 1', + NextMeeting: 'Test Meeting 1', + NextMeetingTime: '10:00 AM', + AssetValue: 10000, + LastMeeting: 'Last Meeting 1', + ClientSummary: 'Summary for User One', + chartUrl: '' + }, + { + ClientId: '2', + ClientName: 'Client 2', + NextMeeting: 'Test Meeting 2', + NextMeetingTime: '2:00 PM', + AssetValue: 20000, + LastMeeting: 'Last Meeting 2', + ClientSummary: 'Summary for User Two', + chartUrl: '' + } +] + +describe('Card Component', () => { + beforeEach(() => { + global.fetch = mockDispatch + jest.spyOn(console, 'error').mockImplementation(() => {}) + }) + + afterEach(() => { + jest.clearAllMocks() + //(console.error as jest.Mock).mockRestore(); + }) + + test('displays loading message while fetching users', async () => { + ;(getUsers as jest.Mock).mockResolvedValueOnce([]) + + renderWithContext() + + expect(screen.queryByText('Loading...')).toBeInTheDocument() + + await waitFor(() => expect(getUsers).toHaveBeenCalled()) + }) + + test('displays no meetings message when there are no users', async () => { + ;(getUsers as jest.Mock).mockResolvedValueOnce([]) + + renderWithContext() + + await waitFor(() => expect(getUsers).toHaveBeenCalled()) + + expect(screen.getByText('No meetings have been arranged')).toBeInTheDocument() + }) + + test('displays user cards when users are fetched', async () => { + ;(getUsers as jest.Mock).mockResolvedValueOnce(mockUsers) + + renderWithContext() + + await waitFor(() => expect(getUsers).toHaveBeenCalled()) + + expect(screen.getByText('Client 1')).toBeInTheDocument() + }) + + test('handles API failure and stops loading', async () => { + const consoleErrorMock = jest.spyOn(console, 'error').mockImplementation(() => {}) + + ;(getUsers as jest.Mock).mockRejectedValueOnce(new Error('API Error')) + + renderWithContext() + + expect(screen.getByText('Loading...')).toBeInTheDocument() + + await waitFor(() => { + expect(getUsers).toHaveBeenCalled() + expect(screen.queryByText('Loading...')).not.toBeInTheDocument() + }) + + const mockError = new Error('API Error') + + expect(console.error).toHaveBeenCalledWith('Error fetching users:', mockError) + + consoleErrorMock.mockRestore() + }) + + test('handles card click and updates context with selected user', async () => { + ;(getUsers as jest.Mock).mockResolvedValueOnce(mockUsers) + + const mockOnCardClick = mockDispatch + + renderWithContext() + + await waitFor(() => expect(getUsers).toHaveBeenCalled()) + + const userCard = screen.getByTestId('user-card-mock') + + await act(() => { + fireEvent.click(userCard) + }) + }) + + test('display "No future meetings have been arranged" when there is only one user', async () => { + ;(getUsers as jest.Mock).mockResolvedValueOnce(mockUsers) + + renderWithContext() + + await waitFor(() => expect(getUsers).toHaveBeenCalled()) + + expect(screen.getByText('No future meetings have been arranged')).toBeInTheDocument() + }) + + test('renders future meetings when there are multiple users', async () => { + ;(getUsers as jest.Mock).mockResolvedValueOnce(multipleUsers) + + renderWithContext() + + await waitFor(() => expect(getUsers).toHaveBeenCalled()) + + expect(screen.getByText('Client 2')).toBeInTheDocument() + expect(screen.queryByText('No future meetings have been arranged')).not.toBeInTheDocument() + }) + + test('logs error when user does not have a ClientId and ClientName', async () => { + ;(getUsers as jest.Mock).mockResolvedValueOnce([ + { + ClientId: null, + ClientName: '', + NextMeeting: 'Test Meeting 1', + NextMeetingTime: '10:00 AM', + AssetValue: 10000, + LastMeeting: 'Last Meeting 1', + ClientSummary: 'Summary for User One', + chartUrl: '' + } + ]) + + renderWithContext(, { + context: { + AppStateContext: { dispatch: mockDispatch } + } + }) + + await waitFor(() => { + expect(screen.getByTestId('user-card-mock')).toBeInTheDocument() + }) + + const userCard = screen.getByTestId('user-card-mock') + fireEvent.click(userCard) + + expect(console.error).toHaveBeenCalledWith( + 'User does not have a ClientId and clientName:', + expect.objectContaining({ + ClientId: null, + ClientName: '' + }) + ) + }) + +}) diff --git a/ClientAdvisor/App/frontend/src/components/Cards/Cards.tsx b/ClientAdvisor/App/frontend/src/components/Cards/Cards.tsx index 99a95abd8..e481013e3 100644 --- a/ClientAdvisor/App/frontend/src/components/Cards/Cards.tsx +++ b/ClientAdvisor/App/frontend/src/components/Cards/Cards.tsx @@ -1,7 +1,7 @@ import React, { useState, useEffect, useContext } from 'react'; -import UserCard from '../UserCard/UserCard'; +import {UserCard} from '../UserCard/UserCard'; import styles from './Cards.module.css'; -import { getUsers, selectUser } from '../../api/api'; +import { getUsers, selectUser } from '../../api'; import { AppStateContext } from '../../state/AppProvider'; import { User } from '../../types/User'; import BellToggle from '../../assets/BellToggle.svg' @@ -17,6 +17,13 @@ const Cards: React.FC = ({ onCardClick }) => { const [selectedClientId, setSelectedClientId] = useState(null); const [loadingUsers, setLoadingUsers] = useState(true); + + useEffect(() => { + if(selectedClientId != null && appStateContext?.state.clientId == ''){ + setSelectedClientId('') + } + },[appStateContext?.state.clientId]); + useEffect(() => { const fetchUsers = async () => { try { @@ -51,8 +58,6 @@ const Cards: React.FC = ({ onCardClick }) => { if (user.ClientId) { appStateContext.dispatch({ type: 'UPDATE_CLIENT_ID', payload: user.ClientId.toString() }); setSelectedClientId(user.ClientId.toString()); - console.log('User clicked:', user); - console.log('Selected ClientId:', user.ClientId.toString()); onCardClick(user); } else { diff --git a/ClientAdvisor/App/frontend/src/components/ChatHistory/ChatHistoryList.test.tsx b/ClientAdvisor/App/frontend/src/components/ChatHistory/ChatHistoryList.test.tsx new file mode 100644 index 000000000..cebb82be7 --- /dev/null +++ b/ClientAdvisor/App/frontend/src/components/ChatHistory/ChatHistoryList.test.tsx @@ -0,0 +1,63 @@ +import React from 'react' +import { renderWithContext, screen, waitFor, fireEvent, act } from '../../test/test.utils'; +import { ChatHistoryList } from './ChatHistoryList' +import {groupByMonth} from '../../helpers/helpers'; + +// Mock the groupByMonth function +jest.mock('../../helpers/helpers', () => ({ + groupByMonth: jest.fn(), +})); + +// Mock ChatHistoryListItemGroups component +jest.mock('./ChatHistoryListItem', () => ({ + ChatHistoryListItemGroups: jest.fn(() =>
Mocked ChatHistoryListItemGroups
), +})); + +describe('ChatHistoryList', () => { + + beforeEach(() => { + global.fetch = jest.fn(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should display "No chat history." when chatHistory is empty', () => { + renderWithContext(); + + expect(screen.getByText('No chat history.')).toBeInTheDocument(); + }); + + it('should call groupByMonth with chatHistory when chatHistory is present', () => { + const mockstate = { + chatHistory : [{ + id: '1', + title: 'Sample chat message', + messages:[], + date:new Date().toISOString(), + updatedAt: new Date().toISOString(), + }] + }; + (groupByMonth as jest.Mock).mockReturnValue([]); + renderWithContext( , mockstate); + + expect(groupByMonth).toHaveBeenCalledWith(mockstate.chatHistory); + }); + + it('should render ChatHistoryListItemGroups with grouped chat history when chatHistory is present', () => { + const mockstate = { + chatHistory : [{ + id: '1', + title: 'Sample chat message', + messages:[], + date:new Date().toISOString(), + updatedAt: new Date().toISOString(), + }] + }; + (groupByMonth as jest.Mock).mockReturnValue([]); + renderWithContext( , mockstate); + + expect(screen.getByText('Mocked ChatHistoryListItemGroups')).toBeInTheDocument(); + }); +}); diff --git a/ClientAdvisor/App/frontend/src/components/ChatHistory/ChatHistoryList.tsx b/ClientAdvisor/App/frontend/src/components/ChatHistory/ChatHistoryList.tsx index 763c6c644..de01eacd2 100644 --- a/ClientAdvisor/App/frontend/src/components/ChatHistory/ChatHistoryList.tsx +++ b/ClientAdvisor/App/frontend/src/components/ChatHistory/ChatHistoryList.tsx @@ -1,69 +1,21 @@ -import React, { useContext } from 'react' +import React, { useContext,useEffect } from 'react' import { Stack, StackItem, Text } from '@fluentui/react' -import { Conversation } from '../../api/models' +import { Conversation , GroupedChatHistory } from '../../api/models' +import {groupByMonth} from '../../helpers/helpers'; import { AppStateContext } from '../../state/AppProvider' import { ChatHistoryListItemGroups } from './ChatHistoryListItem' interface ChatHistoryListProps {} -export interface GroupedChatHistory { - month: string - entries: Conversation[] -} - -const groupByMonth = (entries: Conversation[]) => { - const groups: GroupedChatHistory[] = [{ month: 'Recent', entries: [] }] - const currentDate = new Date() - - entries.forEach(entry => { - const date = new Date(entry.date) - const daysDifference = (currentDate.getTime() - date.getTime()) / (1000 * 60 * 60 * 24) - const monthYear = date.toLocaleString('default', { month: 'long', year: 'numeric' }) - const existingGroup = groups.find(group => group.month === monthYear) - - if (daysDifference <= 7) { - groups[0].entries.push(entry) - } else { - if (existingGroup) { - existingGroup.entries.push(entry) - } else { - groups.push({ month: monthYear, entries: [entry] }) - } - } - }) - groups.sort((a, b) => { - // Check if either group has no entries and handle it - if (a.entries.length === 0 && b.entries.length === 0) { - return 0 // No change in order - } else if (a.entries.length === 0) { - return 1 // Move 'a' to a higher index (bottom) - } else if (b.entries.length === 0) { - return -1 // Move 'b' to a higher index (bottom) - } - const dateA = new Date(a.entries[0].date) - const dateB = new Date(b.entries[0].date) - return dateB.getTime() - dateA.getTime() - }) - groups.forEach(group => { - group.entries.sort((a, b) => { - const dateA = new Date(a.date) - const dateB = new Date(b.date) - return dateB.getTime() - dateA.getTime() - }) - }) - - return groups -} - -const ChatHistoryList: React.FC = () => { +export const ChatHistoryList: React.FC = () => { const appStateContext = useContext(AppStateContext) const chatHistory = appStateContext?.state.chatHistory - React.useEffect(() => {}, [appStateContext?.state.chatHistory]) + useEffect(() => {}, [appStateContext?.state.chatHistory]) let groupedChatHistory if (chatHistory && chatHistory.length > 0) { @@ -83,4 +35,4 @@ const ChatHistoryList: React.FC = () => { return } -export default ChatHistoryList + diff --git a/ClientAdvisor/App/frontend/src/components/ChatHistory/ChatHistoryListItem.test.tsx b/ClientAdvisor/App/frontend/src/components/ChatHistory/ChatHistoryListItem.test.tsx new file mode 100644 index 000000000..62715d93b --- /dev/null +++ b/ClientAdvisor/App/frontend/src/components/ChatHistory/ChatHistoryListItem.test.tsx @@ -0,0 +1,143 @@ +import { renderWithContext, screen, waitFor, fireEvent, act } from '../../test/test.utils'; +import { ChatHistoryListItemGroups } from './ChatHistoryListItem'; +import { historyList } from '../../api'; + +jest.mock('../../api', () => ({ + historyList: jest.fn(), +})); + +const mockDispatch = jest.fn(); +const handleFetchHistory = jest.fn(); + +// Mock the ChatHistoryListItemCell component +jest.mock('./ChatHistoryListItemCell', () => ({ + ChatHistoryListItemCell: jest.fn(({ item, onSelect }) => ( +
onSelect(item)}> + {item?.title} +
+ )), +})); + +const mockGroupedChatHistory = [ + { + month: '2023-09', + entries: [ + { id: '1', title: 'Chat 1', messages: [], date: new Date().toISOString(), updatedAt: new Date().toISOString() }, + { id: '2', title: 'Chat 2', messages: [], date: new Date().toISOString(), updatedAt: new Date().toISOString() }, + ], + }, + { + month: '2023-08', + entries: [ + { id: '3', title: 'Chat 3', messages: [], date: new Date().toISOString(), updatedAt: new Date().toISOString() }, + ], + }, +]; + +describe('ChatHistoryListItemGroups Component', () => { + beforeEach(() => { + global.fetch = jest.fn(); + + jest.spyOn(console, 'error').mockImplementation(() => { }); + }); + + afterEach(() => { + jest.clearAllMocks(); + //(console.error as jest.Mock).mockRestore(); + }); + + it('should call handleFetchHistory with the correct offset when the observer is triggered', async () => { + const responseMock = [{ id: '4', title: 'Chat 4', messages: [], date: new Date().toISOString(), updatedAt: new Date().toISOString() }]; + (historyList as jest.Mock).mockResolvedValue([...responseMock]); + await act(async () => { + renderWithContext(); + }); + + const scrollElms = await screen.findAllByRole('scrollDiv'); + const lastElem = scrollElms[scrollElms.length - 1]; + + await act(async () => { + fireEvent.scroll(lastElem, { target: { scrollY: 100 } }); + //await waitFor(() => expect(historyList).toHaveBeenCalled()); + }); + + await act(async () => { + await waitFor(() => { + expect(historyList).toHaveBeenCalled(); + }); + }); + }); + + it('displays spinner while loading more history', async () => { + const responseMock = [{ id: '4', title: 'Chat 4', messages: [], date: new Date().toISOString(), updatedAt: new Date().toISOString() }]; + (historyList as jest.Mock).mockResolvedValue([...responseMock]); + await act(async () => { + renderWithContext(); + }); + + const scrollElms = await screen.findAllByRole('scrollDiv'); + const lastElem = scrollElms[scrollElms.length - 1]; + + await act(async () => { + fireEvent.scroll(lastElem, { target: { scrollY: 100 } }); + }); + + await act(async () => { + await waitFor(() => { + expect(screen.queryByLabelText(/loading/i)).not.toBeInTheDocument(); + }); + }); + }); + + it('should render the grouped chat history', () => { + renderWithContext(); + + // Check if each group is rendered + expect(screen.getByText('2023-09')).toBeInTheDocument(); + expect(screen.getByText('2023-08')).toBeInTheDocument(); + + // Check if entries are rendered + expect(screen.getByText('Chat 1')).toBeInTheDocument(); + expect(screen.getByText('Chat 2')).toBeInTheDocument(); + expect(screen.getByText('Chat 3')).toBeInTheDocument(); + }); + + it('calls onSelect with the correct item when a ChatHistoryListItemCell is clicked', async () => { + const handleSelectMock = jest.fn(); + + // Render the component + renderWithContext(); + + // Simulate clicks on each ChatHistoryListItemCell + const cells = screen.getAllByTestId(/mock-cell-/); + + // Click on the first cell + fireEvent.click(cells[0]); + + // Wait for the mock function to be called with the correct item + // await waitFor(() => { + // expect(handleSelectMock).toHaveBeenCalledWith(mockGroupedChatHistory[0].entries[0]); + // }); + + }); + + it('handles API failure gracefully', async () => { + // Mock the API to reject with an error + (historyList as jest.Mock).mockResolvedValue(undefined); + + renderWithContext(); + + // Simulate triggering the scroll event that loads more history + const scrollElms = await screen.findAllByRole('scrollDiv'); + const lastElem = scrollElms[scrollElms.length - 1]; + + await act(async () => { + fireEvent.scroll(lastElem, { target: { scrollY: 100 } }); + }); + // Check that the spinner is hidden after the API call + await waitFor(() => { + expect(screen.queryByLabelText(/loading/i)).not.toBeInTheDocument(); + }); + }); + +}); diff --git a/ClientAdvisor/App/frontend/src/components/ChatHistory/ChatHistoryListItem.tsx b/ClientAdvisor/App/frontend/src/components/ChatHistory/ChatHistoryListItem.tsx index 6d26baa2e..cf8ceadc8 100644 --- a/ClientAdvisor/App/frontend/src/components/ChatHistory/ChatHistoryListItem.tsx +++ b/ClientAdvisor/App/frontend/src/components/ChatHistory/ChatHistoryListItem.tsx @@ -19,289 +19,17 @@ import { import { useBoolean } from '@fluentui/react-hooks' import { historyDelete, historyList, historyRename } from '../../api' -import { Conversation } from '../../api/models' +import { Conversation,GroupedChatHistory } from '../../api/models' import { AppStateContext } from '../../state/AppProvider' +import {formatMonth} from '../../helpers/helpers'; -import { GroupedChatHistory } from './ChatHistoryList' - -import styles from './ChatHistoryPanel.module.css' - -interface ChatHistoryListItemCellProps { - item?: Conversation - onSelect: (item: Conversation | null) => void -} +import styles from './ChatHistoryPanel.module.css'; +import { ChatHistoryListItemCell } from './ChatHistoryListItemCell' interface ChatHistoryListItemGroupsProps { groupedChatHistory: GroupedChatHistory[] } -const formatMonth = (month: string) => { - const currentDate = new Date() - const currentYear = currentDate.getFullYear() - - const [monthName, yearString] = month.split(' ') - const year = parseInt(yearString) - - if (year === currentYear) { - return monthName - } else { - return month - } -} - -export const ChatHistoryListItemCell: React.FC = ({ item, onSelect }) => { - const [isHovered, setIsHovered] = React.useState(false) - const [edit, setEdit] = useState(false) - const [editTitle, setEditTitle] = useState('') - const [hideDeleteDialog, { toggle: toggleDeleteDialog }] = useBoolean(true) - const [errorDelete, setErrorDelete] = useState(false) - const [renameLoading, setRenameLoading] = useState(false) - const [errorRename, setErrorRename] = useState(undefined) - const [textFieldFocused, setTextFieldFocused] = useState(false) - const textFieldRef = useRef(null) - const [isButtonDisabled, setIsButtonDisabled] = useState(false); - - const appStateContext = React.useContext(AppStateContext) - const isSelected = item?.id === appStateContext?.state.currentChat?.id - const dialogContentProps = { - type: DialogType.close, - title: 'Are you sure you want to delete this item?', - closeButtonAriaLabel: 'Close', - subText: 'The history of this chat session will permanently removed.' - } - - const modalProps = { - titleAriaId: 'labelId', - subtitleAriaId: 'subTextId', - isBlocking: true, - styles: { main: { maxWidth: 450 } } - } - - if (!item) { - return null - } - - useEffect(() => { - if (textFieldFocused && textFieldRef.current) { - textFieldRef.current.focus() - setTextFieldFocused(false) - } - }, [textFieldFocused]) - - useEffect(() => { - if (appStateContext?.state.currentChat?.id !== item?.id) { - setEdit(false) - setEditTitle('') - } - }, [appStateContext?.state.currentChat?.id, item?.id]) - - useEffect(()=>{ - let v = appStateContext?.state.isRequestInitiated; - if(v!=undefined) - setIsButtonDisabled(v && isSelected) - },[appStateContext?.state.isRequestInitiated]) - - const onDelete = async () => { - appStateContext?.dispatch({ type: 'TOGGLE_LOADER' }); - const response = await historyDelete(item.id) - if (!response.ok) { - setErrorDelete(true) - setTimeout(() => { - setErrorDelete(false) - }, 5000) - } else { - appStateContext?.dispatch({ type: 'DELETE_CHAT_ENTRY', payload: item.id }) - } - appStateContext?.dispatch({ type: 'TOGGLE_LOADER' }); - toggleDeleteDialog() - } - - const onEdit = () => { - setEdit(true) - setTextFieldFocused(true) - setEditTitle(item?.title) - } - - const handleSelectItem = () => { - onSelect(item) - appStateContext?.dispatch({ type: 'UPDATE_CURRENT_CHAT', payload: item }) - } - - const truncatedTitle = item?.title?.length > 28 ? `${item.title.substring(0, 28)} ...` : item.title - - const handleSaveEdit = async (e: any) => { - e.preventDefault() - if (errorRename || renameLoading) { - return - } - if (editTitle == item.title) { - setErrorRename('Error: Enter a new title to proceed.') - setTimeout(() => { - setErrorRename(undefined) - setTextFieldFocused(true) - if (textFieldRef.current) { - textFieldRef.current.focus() - } - }, 5000) - return - } - setRenameLoading(true) - const response = await historyRename(item.id, editTitle) - if (!response.ok) { - setErrorRename('Error: could not rename item') - setTimeout(() => { - setTextFieldFocused(true) - setErrorRename(undefined) - if (textFieldRef.current) { - textFieldRef.current.focus() - } - }, 5000) - } else { - setRenameLoading(false) - setEdit(false) - appStateContext?.dispatch({ type: 'UPDATE_CHAT_TITLE', payload: { ...item, title: editTitle } as Conversation }) - setEditTitle('') - } - } - - const chatHistoryTitleOnChange = (e: any) => { - setEditTitle(e.target.value) - } - - const cancelEditTitle = () => { - setEdit(false) - setEditTitle('') - } - - const handleKeyPressEdit = (e: any) => { - if (e.key === 'Enter') { - return handleSaveEdit(e) - } - if (e.key === 'Escape') { - cancelEditTitle() - return - } - } - - return ( - handleSelectItem()} - onKeyDown={e => (e.key === 'Enter' || e.key === ' ' ? handleSelectItem() : null)} - verticalAlign="center" - // horizontal - onMouseEnter={() => setIsHovered(true)} - onMouseLeave={() => setIsHovered(false)} - styles={{ - root: { - backgroundColor: isSelected ? '#e6e6e6' : 'transparent' - } - }}> - {edit ? ( - <> - -
handleSaveEdit(e)} style={{ padding: '5px 0px' }}> - - - - - {editTitle && ( - - - (e.key === ' ' || e.key === 'Enter' ? handleSaveEdit(e) : null)} - onClick={e => handleSaveEdit(e)} - aria-label="confirm new title" - iconProps={{ iconName: 'CheckMark' }} - styles={{ root: { color: 'green', marginLeft: '5px' } }} - /> - (e.key === ' ' || e.key === 'Enter' ? cancelEditTitle() : null)} - onClick={() => cancelEditTitle()} - aria-label="cancel edit title" - iconProps={{ iconName: 'Cancel' }} - styles={{ root: { color: 'red', marginLeft: '5px' } }} - /> - - - )} - - {errorRename && ( - - {errorRename} - - )} -
-
- - ) : ( - <> - -
{truncatedTitle}
- {(isSelected || isHovered) && ( - - (e.key === ' ' ? toggleDeleteDialog() : null)} - /> - (e.key === ' ' ? onEdit() : null)} - /> - - )} -
- - )} - {errorDelete && ( - - Error: could not delete item - - )} - -
- ) -} - export const ChatHistoryListItemGroups: React.FC = ({ groupedChatHistory }) => { const appStateContext = useContext(AppStateContext) const observerTarget = useRef(null) @@ -381,7 +109,7 @@ export const ChatHistoryListItemGroups: React.FC onRenderCell={onRenderCell} className={styles.chatList} /> -
+
({ + historyRename: jest.fn(), + historyDelete: jest.fn() +})) + +const conversation: Conversation = { + id: '1', + title: 'Test Chat', + messages: [], + date: new Date().toISOString() +} + +const mockOnSelect = jest.fn() +// const mockOnEdit = jest.fn() +const mockAppState = { + currentChat: { id: '1' }, + isRequestInitiated: false +} + +describe('ChatHistoryListItemCell', () => { + beforeEach(() => { + mockOnSelect.mockClear() + global.fetch = jest.fn() + }) + + afterEach(() => { + jest.clearAllMocks() + }) + + test('renders the chat history item', () => { + renderWithContext(, mockAppState) + + const titleElement = screen.getByText(/Test Chat/i) + expect(titleElement).toBeInTheDocument() + }) + + test('truncates long title', () => { + const longTitleConversation = { + ...conversation, + title: 'A very long title that should be truncated after 28 characters' + } + + renderWithContext(, mockAppState) + + const truncatedTitle = screen.getByText(/A very long title that shoul .../i) + expect(truncatedTitle).toBeInTheDocument() + }) + + test('calls onSelect when clicked', () => { + renderWithContext(, mockAppState) + + const item = screen.getByLabelText('chat history item') + fireEvent.click(item) + expect(mockOnSelect).toHaveBeenCalledWith(conversation) + }) + + test('when null item is not passed', () => { + renderWithContext(, mockAppState) + expect(screen.queryByText(/Test Chat/i)).not.toBeInTheDocument() + }) + + test('displays delete and edit buttons on hover', async () => { + const mockAppStateUpdated = { + ...mockAppState, + currentChat: { id: '' } + } + renderWithContext(, mockAppStateUpdated) + + const item = screen.getByLabelText('chat history item') + fireEvent.mouseEnter(item) + + await waitFor(() => { + expect(screen.getByTitle(/Delete/i)).toBeInTheDocument() + expect(screen.getByTitle(/Edit/i)).toBeInTheDocument() + }) + }) + + test('hides delete and edit buttons when not hovered', async () => { + const mockAppStateUpdated = { + ...mockAppState, + currentChat: { id: '' } + } + renderWithContext(, mockAppStateUpdated) + + const item = screen.getByLabelText('chat history item') + fireEvent.mouseEnter(item) + + await waitFor(() => { + expect(screen.getByTitle(/Delete/i)).toBeInTheDocument() + expect(screen.getByTitle(/Edit/i)).toBeInTheDocument() + }) + + fireEvent.mouseLeave(item) + await waitFor(() => { + expect(screen.queryByTitle(/Delete/i)).not.toBeInTheDocument() + expect(screen.queryByTitle(/Edit/i)).not.toBeInTheDocument() + }) + }) + + test('shows confirmation dialog and deletes item', async () => { + ;(historyDelete as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: async () => ({}) + }) + + renderWithContext(, mockAppState) + + const deleteButton = screen.getByTitle(/Delete/i) + fireEvent.click(deleteButton) + + await waitFor(() => { + expect(screen.getByText(/Are you sure you want to delete this item?/i)).toBeInTheDocument() + }) + + const confirmDeleteButton = screen.getByRole('button', { name: 'Delete' }) + fireEvent.click(confirmDeleteButton) + + await waitFor(() => { + expect(historyDelete).toHaveBeenCalled() + }) + }) + + test('when delete API fails or return false', async () => { + ;(historyDelete as jest.Mock).mockResolvedValueOnce({ + ok: false, + json: async () => ({}) + }) + + renderWithContext(, mockAppState) + + const deleteButton = screen.getByTitle(/Delete/i) + fireEvent.click(deleteButton) + + await waitFor(() => { + expect(screen.getByText(/Are you sure you want to delete this item?/i)).toBeInTheDocument() + }) + + const confirmDeleteButton = screen.getByRole('button', { name: 'Delete' }) + + await act(() => { + userEvent.click(confirmDeleteButton) + }) + + await waitFor(async () => { + expect(await screen.findByText(/Error: could not delete item/i)).toBeInTheDocument() + }) + }) + + test('cancel delete when confirmation dialog is shown', async () => { + renderWithContext(, mockAppState) + + const deleteButton = screen.getByTitle(/Delete/i) + fireEvent.click(deleteButton) + + await waitFor(() => { + expect(screen.getByText(/Are you sure you want to delete this item?/i)).toBeInTheDocument() + }) + const cancelDeleteButton = screen.getByRole('button', { name: 'Cancel' }) + fireEvent.click(cancelDeleteButton) + + await waitFor(() => { + expect(screen.queryByText(/Are you sure you want to delete this item?/i)).not.toBeInTheDocument() + }) + }) + + test('disables buttons when request is initiated', () => { + const appStateWithRequestInitiated = { + ...mockAppState, + isRequestInitiated: true + } + + renderWithContext( + , + appStateWithRequestInitiated + ) + + const deleteButton = screen.getByTitle(/Delete/i) + const editButton = screen.getByTitle(/Edit/i) + + expect(deleteButton).toBeDisabled() + expect(editButton).toBeDisabled() + }) + + test('does not disable buttons when request is not initiated', () => { + renderWithContext(, mockAppState) + + const deleteButton = screen.getByTitle(/Delete/i) + const editButton = screen.getByTitle(/Edit/i) + + expect(deleteButton).not.toBeDisabled() + expect(editButton).not.toBeDisabled() + }) + + test('calls onEdit when Edit button is clicked', async () => { + renderWithContext( + , // Pass the mockOnEdit + mockAppState + ) + + const item = screen.getByLabelText('chat history item') + fireEvent.mouseEnter(item) // Simulate hover to reveal Edit button + + await waitFor(() => { + const editButton = screen.getByTitle(/Edit/i) + expect(editButton).toBeInTheDocument() + fireEvent.click(editButton) // Simulate Edit button click + }) + const inputItem = screen.getByPlaceholderText('Test Chat') + expect(inputItem).toBeInTheDocument() // Ensure onEdit is called with the conversation item + expect(inputItem).toHaveValue('Test Chat') + }) + + test('handles input onChange and onKeyDown ENTER events correctly', async () => { + ;(historyRename as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: async () => ({}) + }) + + renderWithContext(, mockAppState) + + // Simulate hover to reveal Edit button + const item = screen.getByLabelText('chat history item') + fireEvent.mouseEnter(item) + + // Wait for the Edit button to appear and click it + await waitFor(() => { + const editButton = screen.getByTitle(/Edit/i) + expect(editButton).toBeInTheDocument() + fireEvent.click(editButton) + }) + + // Find the input field + const inputItem = screen.getByPlaceholderText('Test Chat') + expect(inputItem).toBeInTheDocument() // Ensure input is there + + // Simulate the onChange event by typing into the input field + fireEvent.change(inputItem, { target: { value: 'Updated Chat' } }) + expect(inputItem).toHaveValue('Updated Chat') // Ensure value is updated + + // Simulate keydown event for the 'Enter' key + fireEvent.keyDown(inputItem, { key: 'Enter', code: 'Enter', charCode: 13 }) + + await waitFor(() => expect(historyRename).toHaveBeenCalled()) + + // Optionally: Verify that some onSave or equivalent function is called on Enter key + // expect(mockOnSave).toHaveBeenCalledWith('Updated Chat'); (if you have a mock function for the save logic) + + // Simulate keydown event for the 'Escape' key + // fireEvent.keyDown(inputItem, { key: 'Escape', code: 'Escape', charCode: 27 }); + + //await waitFor(() => expect(screen.getByPlaceholderText('Updated Chat')).not.toBeInTheDocument()); + }) + + test('handles input onChange and onKeyDown ESCAPE events correctly', async () => { + renderWithContext(, mockAppState) + + // Simulate hover to reveal Edit button + const item = screen.getByLabelText('chat history item') + fireEvent.mouseEnter(item) + + // Wait for the Edit button to appear and click it + await waitFor(() => { + const editButton = screen.getByTitle(/Edit/i) + expect(editButton).toBeInTheDocument() + fireEvent.click(editButton) + }) + + // Find the input field + const inputItem = screen.getByLabelText('rename-input') + expect(inputItem).toBeInTheDocument() // Ensure input is there + + // Simulate the onChange event by typing into the input field + fireEvent.change(inputItem, { target: { value: 'Updated Chat' } }) + expect(inputItem).toHaveValue('Updated Chat') // Ensure value is updated + + fireEvent.keyDown(inputItem, { key: 'Escape', code: 'Escape', charCode: 27 }) + + await waitFor(() => expect(inputItem).not.toBeInTheDocument()) + }) + + test('handles rename save when the updated text is equal to initial text', async () => { + userEvent.setup() + + renderWithContext(, mockAppState) + + // Simulate hover to reveal Edit button + const item = screen.getByLabelText('chat history item') + fireEvent.mouseEnter(item) + + // Wait for the Edit button to appear and click it + await waitFor(() => { + const editButton = screen.getByTitle(/Edit/i) + expect(editButton).toBeInTheDocument() + fireEvent.click(editButton) + }) + + // Find the input field + const inputItem = screen.getByPlaceholderText('Test Chat') + expect(inputItem).toBeInTheDocument() // Ensure input is there + + await act(() => { + userEvent.type(inputItem, 'Test Chat') + //fireEvent.change(inputItem, { target: { value: 'Test Chat' } }); + }) + + userEvent.click(screen.getByRole('button', { name: 'confirm new title' })) + + await waitFor(() => { + expect(screen.getByText(/Error: Enter a new title to proceed./i)).toBeInTheDocument() + }) + + // Wait for the error to be hidden after 5 seconds + await waitFor(() => expect(screen.queryByText('Error: Enter a new title to proceed.')).not.toBeInTheDocument(), { + timeout: 6000 + }) + const input = screen.getByLabelText('rename-input') + expect(input).toHaveFocus() + }, 10000) + + test('Should hide the rename from when cancel it.', async () => { + userEvent.setup() + + renderWithContext(, mockAppState) + + // Wait for the Edit button to appear and click it + await waitFor(() => { + const editButton = screen.getByTitle(/Edit/i) + fireEvent.click(editButton) + }) + + await userEvent.click(screen.getByRole('button', { name: 'cancel edit title' })) + + // Wait for the error to be hidden after 5 seconds + await waitFor(() => { + const input = screen.queryByLabelText('rename-input') + expect(input).not.toBeInTheDocument() + }) + }) + + test('handles rename save API failed', async () => { + userEvent.setup() + ;(historyRename as jest.Mock).mockResolvedValueOnce({ + ok: false, + json: async () => ({}) + }) + + renderWithContext(, mockAppState) + + // Simulate hover to reveal Edit button + const item = screen.getByLabelText('chat history item') + fireEvent.mouseEnter(item) + + // Wait for the Edit button to appear and click it + await waitFor(() => { + const editButton = screen.getByTitle(/Edit/i) + fireEvent.click(editButton) + }) + + // Find the input field + const inputItem = screen.getByLabelText('rename-input') + expect(inputItem).toBeInTheDocument() // Ensure input is there + + await act(async () => { + await userEvent.type(inputItem, 'update Chat') + }) + + userEvent.click(screen.getByRole('button', { name: 'confirm new title' })) + + await waitFor(() => { + expect(screen.getByText(/Error: could not rename item/i)).toBeInTheDocument() + }) + + // Wait for the error to be hidden after 5 seconds + await waitFor(() => expect(screen.queryByText('Error: could not rename item')).not.toBeInTheDocument(), { + timeout: 6000 + }) + const input = screen.getByLabelText('rename-input') + expect(input).toHaveFocus() + }, 10000) + + test('shows error when trying to rename to an existing title', async () => { + const existingTitle = 'Existing Chat Title' + const conversationWithExistingTitle: Conversation = { + id: '2', + title: existingTitle, + messages: [], + date: new Date().toISOString() + } + + ;(historyRename as jest.Mock).mockResolvedValueOnce({ + ok: false, + json: async () => ({ message: 'Title already exists' }) + }) + + renderWithContext(, mockAppState) + + const item = screen.getByLabelText('chat history item') + fireEvent.mouseEnter(item) + + await waitFor(() => { + const editButton = screen.getByTitle(/Edit/i) + fireEvent.click(editButton) + }) + + const inputItem = screen.getByPlaceholderText(conversation.title) + fireEvent.change(inputItem, { target: { value: existingTitle } }) + + fireEvent.keyDown(inputItem, { key: 'Enter', code: 'Enter', charCode: 13 }) + + await waitFor(() => { + expect(screen.getByText(/Error: could not rename item/i)).toBeInTheDocument() + }) + }) + + test('triggers edit functionality when Enter key is pressed', async () => { + ;(historyRename as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: async () => ({}) + }) + + renderWithContext(, mockAppState) + + const item = screen.getByLabelText('chat history item') + fireEvent.mouseEnter(item) + + const editButton = screen.getByTitle(/Edit/i) + fireEvent.click(editButton) + + const inputItem = screen.getByLabelText('rename-input') + fireEvent.change(inputItem, { target: { value: 'Updated Chat' } }) + + fireEvent.keyDown(inputItem, { key: 'Enter', code: 'Enter', charCode: 13 }) + + await waitFor(() => { + expect(historyRename).toHaveBeenCalledWith(conversation.id, 'Updated Chat') + }) + }) + + test('successfully saves edited title', async () => { + ;(historyRename as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: async () => ({}) + }) + + renderWithContext(, mockAppState) + + const item = screen.getByLabelText('chat history item') + fireEvent.mouseEnter(item) + + const editButton = screen.getByTitle(/Edit/i) + fireEvent.click(editButton) + + const inputItem = screen.getByPlaceholderText('Test Chat') + fireEvent.change(inputItem, { target: { value: 'Updated Chat' } }) + + const form = screen.getByLabelText('edit title form') + fireEvent.submit(form) + + await waitFor(() => { + expect(historyRename).toHaveBeenCalledWith('1', 'Updated Chat') + }) + }) + + test('calls onEdit when space key is pressed on the Edit button', () => { + const mockOnSelect = jest.fn() + + renderWithContext(, { + currentChat: { id: '1' }, + isRequestInitiated: false + }) + + const editButton = screen.getByTitle(/Edit/i) + + fireEvent.keyDown(editButton, { key: ' ', code: 'Space', charCode: 32 }) + + expect(screen.getByLabelText(/rename-input/i)).toBeInTheDocument() + }) + + test('calls toggleDeleteDialog when space key is pressed on the Delete button', () => { + // const toggleDeleteDialogMock = jest.fn() + + renderWithContext(, { + currentChat: { id: '1' }, + isRequestInitiated: false + // toggleDeleteDialog: toggleDeleteDialogMock + }) + + const deleteButton = screen.getByTitle(/Delete/i) + + // fireEvent.focus(deleteButton) + + fireEvent.keyDown(deleteButton, { key: ' ', code: 'Space', charCode: 32 }) + + expect(screen.getByLabelText(/chat history item/i)).toBeInTheDocument() + }) + + /////// + + test('opens delete confirmation dialog when Enter key is pressed on the Delete button', async () => { + renderWithContext(, mockAppState) + + const item = screen.getByLabelText('chat history item') + fireEvent.mouseEnter(item) + + const deleteButton = screen.getByTitle(/Delete/i) + fireEvent.keyDown(deleteButton, { key: 'Enter', code: 'Enter', charCode: 13 }) + + // expect(await screen.findByText(/Are you sure you want to delete this item?/i)).toBeInTheDocument() + }) + + test('opens delete confirmation dialog when Space key is pressed on the Delete button', async () => { + renderWithContext(, mockAppState) + + const item = screen.getByLabelText('chat history item') + fireEvent.mouseEnter(item) + + const deleteButton = screen.getByTitle(/Delete/i) + fireEvent.keyDown(deleteButton, { key: ' ', code: 'Space', charCode: 32 }) + + expect(await screen.findByText(/Are you sure you want to delete this item?/i)).toBeInTheDocument() + }) + + test('opens edit input when Space key is pressed on the Edit button', async () => { + renderWithContext(, mockAppState) + + const item = screen.getByLabelText('chat history item') + fireEvent.mouseEnter(item) + + const editButton = screen.getByTitle(/Edit/i) + fireEvent.keyDown(editButton, { key: ' ', code: 'Space', charCode: 32 }) + + const inputItem = screen.getByLabelText('rename-input') + expect(inputItem).toBeInTheDocument() + }) + + test('opens edit input when Enter key is pressed on the Edit button', async () => { + renderWithContext(, mockAppState) + + const item = screen.getByLabelText('chat history item') + fireEvent.mouseEnter(item) + + const editButton = screen.getByTitle(/Edit/i) + fireEvent.keyDown(editButton, { key: 'Enter', code: 'Enter', charCode: 13 }) + + // const inputItem = await screen.getByLabelText('rename-input') + // expect(inputItem).toBeInTheDocument() + }) +}) diff --git a/ClientAdvisor/App/frontend/src/components/ChatHistory/ChatHistoryListItemCell.tsx b/ClientAdvisor/App/frontend/src/components/ChatHistory/ChatHistoryListItemCell.tsx new file mode 100644 index 000000000..b9b2017de --- /dev/null +++ b/ClientAdvisor/App/frontend/src/components/ChatHistory/ChatHistoryListItemCell.tsx @@ -0,0 +1,286 @@ +import * as React from 'react' +import { useContext, useEffect, useRef, useState } from 'react' +import { + DefaultButton, + Dialog, + DialogFooter, + DialogType, + IconButton, + ITextField, + List, + PrimaryButton, + Separator, + Spinner, + SpinnerSize, + Stack, + Text, + TextField +} from '@fluentui/react' +import { useBoolean } from '@fluentui/react-hooks' + +import { historyDelete, historyList, historyRename } from '../../api' +import { Conversation,GroupedChatHistory } from '../../api/models' +import { AppStateContext } from '../../state/AppProvider' + +import styles from './ChatHistoryPanel.module.css' + +interface ChatHistoryListItemCellProps { + item?: Conversation + onSelect: (item: Conversation | null) => void +} + +export const ChatHistoryListItemCell: React.FC = ({ item, onSelect }) => { + const [isHovered, setIsHovered] = React.useState(false) + const [edit, setEdit] = useState(false) + const [editTitle, setEditTitle] = useState('') + const [hideDeleteDialog, { toggle: toggleDeleteDialog }] = useBoolean(true) + const [errorDelete, setErrorDelete] = useState(false) + const [renameLoading, setRenameLoading] = useState(false) + const [errorRename, setErrorRename] = useState(undefined) + const [textFieldFocused, setTextFieldFocused] = useState(false) + const textFieldRef = useRef(null) + const [isButtonDisabled, setIsButtonDisabled] = useState(false); + + const appStateContext = React.useContext(AppStateContext) + const isSelected = item?.id === appStateContext?.state.currentChat?.id + const dialogContentProps = { + type: DialogType.close, + title: 'Are you sure you want to delete this item?', + closeButtonAriaLabel: 'Close', + subText: 'The history of this chat session will permanently removed.' + } + + const modalProps = { + titleAriaId: 'labelId', + subtitleAriaId: 'subTextId', + isBlocking: true, + styles: { main: { maxWidth: 450 } } + } + + if (!item) { + return null + } + + useEffect(() => { + if (textFieldFocused && textFieldRef.current) { + textFieldRef.current.focus() + setTextFieldFocused(false) + } + }, [textFieldFocused]) + + useEffect(() => { + if (appStateContext?.state.currentChat?.id !== item?.id) { + setEdit(false) + setEditTitle('') + } + }, [appStateContext?.state.currentChat?.id, item?.id]) + + useEffect(()=>{ + let v = appStateContext?.state.isRequestInitiated; + if(v!=undefined) + setIsButtonDisabled(v && isSelected) + },[appStateContext?.state.isRequestInitiated]) + + const onDelete = async () => { + appStateContext?.dispatch({ type: 'TOGGLE_LOADER' }); + const response = await historyDelete(item.id) + if (!response.ok) { + setErrorDelete(true) + setTimeout(() => { + setErrorDelete(false) + }, 5000) + } else { + appStateContext?.dispatch({ type: 'DELETE_CHAT_ENTRY', payload: item.id }) + } + appStateContext?.dispatch({ type: 'TOGGLE_LOADER' }); + toggleDeleteDialog() + } + + const onEdit = () => { + setEdit(true) + setTextFieldFocused(true) + setEditTitle(item?.title) + } + + const handleSelectItem = () => { + onSelect(item) + appStateContext?.dispatch({ type: 'UPDATE_CURRENT_CHAT', payload: item }) + } + + const truncatedTitle = item?.title?.length > 28 ? `${item.title.substring(0, 28)} ...` : item.title + + const handleSaveEdit = async (e: any) => { + e.preventDefault() + if (errorRename || renameLoading) { + return + } + if (editTitle == item.title) { + setErrorRename('Error: Enter a new title to proceed.') + setTimeout(() => { + setErrorRename(undefined) + setTextFieldFocused(true) + if (textFieldRef.current) { + textFieldRef.current.focus() + } + }, 5000) + return + } + setRenameLoading(true) + const response = await historyRename(item.id, editTitle) + if (!response.ok) { + setErrorRename('Error: could not rename item') + setTimeout(() => { + setTextFieldFocused(true) + setErrorRename(undefined) + if (textFieldRef.current) { + textFieldRef.current.focus() + } + }, 5000) + } else { + setRenameLoading(false) + setEdit(false) + appStateContext?.dispatch({ type: 'UPDATE_CHAT_TITLE', payload: { ...item, title: editTitle } as Conversation }) + setEditTitle('') + } + } + + const chatHistoryTitleOnChange = (e: any) => { + setEditTitle(e.target.value) + } + + const cancelEditTitle = () => { + setEdit(false) + setEditTitle('') + } + + const handleKeyPressEdit = (e: any) => { + if (e.key === 'Enter') { + return handleSaveEdit(e) + } + if (e.key === 'Escape') { + cancelEditTitle() + return + } + } + + return ( + handleSelectItem()} + onKeyDown={e => (e.key === 'Enter' || e.key === ' ' ? handleSelectItem() : null)} + verticalAlign="center" + // horizontal + onMouseEnter={() => setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + styles={{ + root: { + backgroundColor: isSelected ? '#e6e6e6' : 'transparent' + } + }}> + {edit ? ( + <> + +
handleSaveEdit(e)} style={{ padding: '5px 0px' }}> + + + + + {editTitle && ( + + + (e.key === ' ' || e.key === 'Enter' ? handleSaveEdit(e) : null)} + onClick={e => handleSaveEdit(e)} + aria-label="confirm new title" + iconProps={{ iconName: 'CheckMark' }} + styles={{ root: { color: 'green', marginLeft: '5px' } }} + /> + (e.key === ' ' || e.key === 'Enter' ? cancelEditTitle() : null)} + onClick={() => cancelEditTitle()} + aria-label="cancel edit title" + iconProps={{ iconName: 'Cancel' }} + styles={{ root: { color: 'red', marginLeft: '5px' } }} + /> + + + )} + + {errorRename && ( + + {errorRename} + + )} +
+
+ + ) : ( + <> + +
{truncatedTitle}
+ {(isSelected || isHovered) && ( + + (e.key === ' ' ? toggleDeleteDialog() : null)} + /> + (e.key === ' ' ? onEdit() : null)} + /> + + )} +
+ + )} + {errorDelete && ( + + Error: could not delete item + + )} + +
+ ) +} + + diff --git a/ClientAdvisor/App/frontend/src/components/ChatHistory/ChatHistoryPanel.module.css b/ClientAdvisor/App/frontend/src/components/ChatHistory/ChatHistoryPanel.module.css index 784838fe7..abb301598 100644 --- a/ClientAdvisor/App/frontend/src/components/ChatHistory/ChatHistoryPanel.module.css +++ b/ClientAdvisor/App/frontend/src/components/ChatHistory/ChatHistoryPanel.module.css @@ -77,3 +77,11 @@ width: 100%; } } + +@media screen and (-ms-high-contrast: active), (forced-colors: active) { + .container{ + border: 2px solid WindowText; + background-color: Window; + color: WindowText; + } +} \ No newline at end of file diff --git a/ClientAdvisor/App/frontend/src/components/ChatHistory/ChatHistoryPanel.test.tsx b/ClientAdvisor/App/frontend/src/components/ChatHistory/ChatHistoryPanel.test.tsx new file mode 100644 index 000000000..8f59d23d7 --- /dev/null +++ b/ClientAdvisor/App/frontend/src/components/ChatHistory/ChatHistoryPanel.test.tsx @@ -0,0 +1,257 @@ +import React from 'react' +import { renderWithContext, screen, waitFor, fireEvent, act } from '../../test/test.utils' +import { ChatHistoryPanel } from './ChatHistoryPanel' +import { AppStateContext } from '../../state/AppProvider' +import { ChatHistoryLoadingState, CosmosDBStatus } from '../../api/models' +import userEvent from '@testing-library/user-event' +import { historyDeleteAll } from '../../api' + +jest.mock('./ChatHistoryList', () => ({ + ChatHistoryList: () =>
Mocked ChatHistoryPanel
+})) + +// Mock Fluent UI components +jest.mock('@fluentui/react', () => ({ + ...jest.requireActual('@fluentui/react'), + Spinner: () =>
Loading...
+})) + +jest.mock('../../api', () => ({ + historyDeleteAll: jest.fn() +})) + +const mockDispatch = jest.fn() + +describe('ChatHistoryPanel Component', () => { + beforeEach(() => { + global.fetch = jest.fn() + }) + + afterEach(() => { + jest.clearAllMocks() + }) + + const mockAppState = { + chatHistory: [{ id: 1, message: 'Test Message' }], + chatHistoryLoadingState: ChatHistoryLoadingState.Success, + isCosmosDBAvailable: { cosmosDB: true, status: CosmosDBStatus.Working } + } + + it('renders the ChatHistoryPanel with chat history loaded', () => { + renderWithContext(, mockAppState) + expect(screen.getByText('Chat history')).toBeInTheDocument() + expect(screen.getByRole('button', { name: /clear all chat history/i })).toBeInTheDocument() + expect(screen.getByRole('button', { name: /hide/i })).toBeInTheDocument() + }) + + it('renders a spinner when chat history is loading', async () => { + const stateVal = { + ...mockAppState, + chatHistoryLoadingState: ChatHistoryLoadingState.Loading + } + renderWithContext(, stateVal) + await waitFor(() => { + expect(screen.getByText('Loading chat history')).toBeInTheDocument() + }) + }) + + it('opens the clear all chat history dialog when the command button is clicked', async () => { + userEvent.setup() + renderWithContext(, mockAppState) + + const moreButton = screen.getByRole('button', { name: /clear all chat history/i }) + fireEvent.click(moreButton) + + expect(screen.queryByText('Clear all chat history')).toBeInTheDocument() + + const clearAllItem = await screen.findByRole('menuitem') + await act(() => { + userEvent.click(clearAllItem) + }) + await waitFor(() => + expect(screen.getByText(/are you sure you want to clear all chat history/i)).toBeInTheDocument() + ) + }) + + it('calls historyDeleteAll when the "Clear All" button is clicked in the dialog', async () => { + userEvent.setup() + + const compState = { + chatHistory: [{ id: 1, message: 'Test Message' }], + chatHistoryLoadingState: ChatHistoryLoadingState.Success, + isCosmosDBAvailable: { cosmosDB: true, status: CosmosDBStatus.Working } + } + + ;(historyDeleteAll as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: async () => ({}) + }) + + renderWithContext(, compState) + + const moreButton = screen.getByRole('button', { name: /clear all chat history/i }) + fireEvent.click(moreButton) + + //const clearAllItem = screen.getByText('Clear all chat history') + const clearAllItem = await screen.findByRole('menuitem') + await act(() => { + userEvent.click(clearAllItem) + }) + + await waitFor(() => + expect(screen.getByText(/are you sure you want to clear all chat history/i)).toBeInTheDocument() + ) + const clearAllButton = screen.getByRole('button', { name: /clear all/i }) + + await act(async () => { + await userEvent.click(clearAllButton) + }) + + await waitFor(() => expect(historyDeleteAll).toHaveBeenCalled()) + //await waitFor(() => expect(historyDeleteAll).toHaveBeenCalledTimes(1)); + + // await act(()=>{ + // expect(jest.fn()).toHaveBeenCalledWith({ type: 'DELETE_CHAT_HISTORY' }); + // }); + + // Verify that the dialog is hidden + await waitFor(() => { + expect(screen.queryByText('Are you sure you want to clear all chat history?')).not.toBeInTheDocument() + }) + }) + + it('hides the dialog when cancel or close is clicked', async () => { + userEvent.setup() + + const compState = { + chatHistory: [{ id: 1, message: 'Test Message' }], + chatHistoryLoadingState: ChatHistoryLoadingState.Success, + isCosmosDBAvailable: { cosmosDB: true, status: CosmosDBStatus.Working } + } + + renderWithContext(, compState) + + const moreButton = screen.getByRole('button', { name: /clear all chat history/i }) + fireEvent.click(moreButton) + + const clearAllItem = await screen.findByRole('menuitem') + await act(() => { + userEvent.click(clearAllItem) + }) + + await waitFor(() => + expect(screen.getByText(/are you sure you want to clear all chat history/i)).toBeInTheDocument() + ) + + const cancelButton = screen.getByRole('button', { name: /cancel/i }) + + await act(() => { + userEvent.click(cancelButton) + }) + + await waitFor(() => + expect(screen.queryByText(/are you sure you want to clear all chat history/i)).not.toBeInTheDocument() + ) + }) + + test('handles API failure correctly', async () => { + // Mock historyDeleteAll to return a failed response + ;(historyDeleteAll as jest.Mock).mockResolvedValueOnce({ ok: false }) + + userEvent.setup() + + const compState = { + chatHistory: [{ id: 1, message: 'Test Message' }], + chatHistoryLoadingState: ChatHistoryLoadingState.Success, + isCosmosDBAvailable: { cosmosDB: true, status: CosmosDBStatus.Working } + } + + renderWithContext(, compState) + const moreButton = screen.getByRole('button', { name: /clear all chat history/i }) + fireEvent.click(moreButton) + + //const clearAllItem = screen.getByText('Clear all chat history') + const clearAllItem = await screen.findByRole('menuitem') + await act(() => { + userEvent.click(clearAllItem) + }) + + await waitFor(() => + expect(screen.getByText(/are you sure you want to clear all chat history/i)).toBeInTheDocument() + ) + const clearAllButton = screen.getByRole('button', { name: /clear all/i }) + + await act(async () => { + await userEvent.click(clearAllButton) + }) + + // Assert that error state is set + await waitFor(async () => { + expect(await screen.findByText('Error deleting all of chat history')).toBeInTheDocument() + //expect(mockDispatch).not.toHaveBeenCalled(); // Ensure dispatch was not called on failure + }) + }) + + it('handleHistoryClick', () => { + const stateVal = { + ...mockAppState, + chatHistoryLoadingState: ChatHistoryLoadingState.Success, + isCosmosDBAvailable: { cosmosDB: false, status: '' } + } + renderWithContext(, stateVal) + + const hideBtn = screen.getByRole('button', { name: /hide button/i }) + fireEvent.click(hideBtn) + + //expect(mockDispatch).toHaveBeenCalledWith({ type: 'TOGGLE_CHAT_HISTORY' }); + }) + + it('displays an error message when chat history fails to load', async () => { + const errorState = { + ...mockAppState, + chatHistoryLoadingState: ChatHistoryLoadingState.Fail, + isCosmosDBAvailable: { cosmosDB: true, status: '' } // Falsy status to trigger the error message + } + + renderWithContext(, errorState) + + await waitFor(() => { + expect(screen.getByText('Error loading chat history')).toBeInTheDocument() + }) + }) + + // it('resets clearingError after timeout', async () => { + // ;(historyDeleteAll as jest.Mock).mockResolvedValueOnce({ ok: false }) + + // userEvent.setup() + + // renderWithContext(, mockAppState) + + // const moreButton = screen.getByRole('button', { name: /clear all chat history/i }) + // fireEvent.click(moreButton) + + // const clearAllItem = await screen.findByRole('menuitem') + // await act(() => { + // userEvent.click(clearAllItem) + // }) + + // await waitFor(() => + // expect(screen.getByText(/are you sure you want to clear all chat history/i)).toBeInTheDocument() + // ) + + // const clearAllButton = screen.getByRole('button', { name: /clear all/i }) + // await act(async () => { + // userEvent.click(clearAllButton) + // }) + + // await waitFor(() => expect(screen.getByText('Error deleting all of chat history')).toBeInTheDocument()) + + // act(() => { + // jest.advanceTimersByTime(2000) + // }) + + // await waitFor(() => { + // expect(screen.queryByText('Error deleting all of chat history')).not.toBeInTheDocument() + // }) + // }) +}) diff --git a/ClientAdvisor/App/frontend/src/components/ChatHistory/ChatHistoryPanel.tsx b/ClientAdvisor/App/frontend/src/components/ChatHistory/ChatHistoryPanel.tsx index 7a23f4d56..1b4be3ebf 100644 --- a/ClientAdvisor/App/frontend/src/components/ChatHistory/ChatHistoryPanel.tsx +++ b/ClientAdvisor/App/frontend/src/components/ChatHistory/ChatHistoryPanel.tsx @@ -1,5 +1,5 @@ import { useContext } from 'react' -import React from 'react' +import React , {useState,useEffect,useCallback, MouseEvent} from 'react' import { CommandBarButton, ContextualMenu, @@ -19,10 +19,11 @@ import { } from '@fluentui/react' import { useBoolean } from '@fluentui/react-hooks' -import { ChatHistoryLoadingState, historyDeleteAll } from '../../api' +import { historyDeleteAll } from '../../api' +import { ChatHistoryLoadingState } from '../../api/models' import { AppStateContext } from '../../state/AppProvider' -import ChatHistoryList from './ChatHistoryList' +import {ChatHistoryList} from './ChatHistoryList' import styles from './ChatHistoryPanel.module.css' @@ -48,10 +49,10 @@ const commandBarButtonStyle: Partial = { root: { height: '50px' } export function ChatHistoryPanel(_props: ChatHistoryPanelProps) { const { isLoading} = _props const appStateContext = useContext(AppStateContext) - const [showContextualMenu, setShowContextualMenu] = React.useState(false) + const [showContextualMenu, setShowContextualMenu] = useState(false) const [hideClearAllDialog, { toggle: toggleClearAllDialog }] = useBoolean(true) - const [clearing, setClearing] = React.useState(false) - const [clearingError, setClearingError] = React.useState(false) + const [clearing, setClearing] = useState(false) + const [clearingError, setClearingError] = useState(false) const hasChatHistory = appStateContext?.state.chatHistory && appStateContext.state.chatHistory.length > 0; const clearAllDialogContentProps = { type: DialogType.close, @@ -77,12 +78,12 @@ export function ChatHistoryPanel(_props: ChatHistoryPanelProps) { appStateContext?.dispatch({ type: 'TOGGLE_CHAT_HISTORY' }) } - const onShowContextualMenu = React.useCallback((ev: React.MouseEvent) => { + const onShowContextualMenu = useCallback((ev: MouseEvent) => { ev.preventDefault() // don't navigate setShowContextualMenu(true) }, []) - const onHideContextualMenu = React.useCallback(() => setShowContextualMenu(false), []) + const onHideContextualMenu = useCallback(() => setShowContextualMenu(false), []) const onClearAllChatHistory = async () => { setClearing(true) @@ -103,7 +104,7 @@ export function ChatHistoryPanel(_props: ChatHistoryPanelProps) { }, 2000) } - React.useEffect(() => {}, [appStateContext?.state.chatHistory, clearingError]) + useEffect(() => {}, [appStateContext?.state.chatHistory, clearingError]) return (
@@ -111,7 +112,7 @@ export function ChatHistoryPanel(_props: ChatHistoryPanelProps) { { + const chartUrl = 'https://example.com/chart'; + + test('renders the PowerBIChart component', () => { + render(); + + // Check if the iframe is present in the document + const iframe = screen.getByTitle('PowerBI Chart'); + expect(iframe).toBeInTheDocument(); + }); + + test('iframe has the correct src attribute', () => { + render(); + + // Check if the iframe has the correct src attribute + const iframe = screen.getByTitle('PowerBI Chart') as HTMLIFrameElement; + expect(iframe).toHaveAttribute('src', chartUrl); + }); + + test('iframe container has the correct styles applied', () => { + render(); + + // Check if the div containing the iframe has the correct styles + const containerDiv = screen.getByTitle('PowerBI Chart').parentElement; + expect(containerDiv).toHaveStyle('height: 100vh'); + expect(containerDiv).toHaveStyle('max-height: calc(100vh - 300px)'); + }); +}); diff --git a/ClientAdvisor/App/frontend/src/components/PromptButton/PromptButton.test.tsx b/ClientAdvisor/App/frontend/src/components/PromptButton/PromptButton.test.tsx new file mode 100644 index 000000000..a3cab511d --- /dev/null +++ b/ClientAdvisor/App/frontend/src/components/PromptButton/PromptButton.test.tsx @@ -0,0 +1,46 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { PromptButton } from './PromptButton'; + +// Mock Fluent UI's DefaultButton +jest.mock('@fluentui/react', () => ({ + DefaultButton: ({ className, disabled, text, onClick }: any) => ( + + ), +})); + +describe('PromptButton component', () => { + const mockOnClick = jest.fn(); + + afterEach(() => { + jest.clearAllMocks(); + }); + + test('renders button with provided name', () => { + render(); + const button = screen.getByRole('button'); + expect(button).toHaveTextContent('Click Me'); + }); + + test('renders button with default name if no name is provided', () => { + render(); + const button = screen.getByRole('button'); + expect(button).toHaveTextContent('Default'); + }); + + test('does not trigger onClick when button is disabled', () => { + render(); + const button = screen.getByRole('button'); + fireEvent.click(button); + expect(mockOnClick).not.toHaveBeenCalled(); + }); + + test('triggers onClick when button is clicked and not disabled', () => { + render(); + const button = screen.getByRole('button'); + fireEvent.click(button); + expect(mockOnClick).toHaveBeenCalledTimes(1); + }); +}); diff --git a/ClientAdvisor/App/frontend/src/components/PromptButton/PromptButton.tsx b/ClientAdvisor/App/frontend/src/components/PromptButton/PromptButton.tsx index 798691ce2..352db7d97 100644 --- a/ClientAdvisor/App/frontend/src/components/PromptButton/PromptButton.tsx +++ b/ClientAdvisor/App/frontend/src/components/PromptButton/PromptButton.tsx @@ -8,5 +8,18 @@ interface PromptButtonProps extends IButtonProps { } export const PromptButton: React.FC = ({ onClick, name = '', disabled }) => { - return -} + const handleClick = () => { + if (!disabled && onClick) { + onClick(); + } + }; + + return ( + + ); +}; diff --git a/ClientAdvisor/App/frontend/src/components/PromptsSection/PromptsSection.test.tsx b/ClientAdvisor/App/frontend/src/components/PromptsSection/PromptsSection.test.tsx new file mode 100644 index 000000000..cb09a9270 --- /dev/null +++ b/ClientAdvisor/App/frontend/src/components/PromptsSection/PromptsSection.test.tsx @@ -0,0 +1,72 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { PromptsSection, PromptType } from './PromptsSection'; +import { PromptButton } from '../PromptButton/PromptButton'; + +jest.mock('../PromptButton/PromptButton', () => ({ + PromptButton: jest.fn(({ name, onClick, disabled }) => ( + + )), +})); + +describe('PromptsSection', () => { + const mockOnClickPrompt = jest.fn(); + + afterEach(() => { + jest.clearAllMocks(); + }); + + test('renders prompts correctly', () => { + render(); + + // Check if the prompt buttons are rendered + expect(screen.getByText('Top discussion trends')).toBeInTheDocument(); + expect(screen.getByText('Investment summary')).toBeInTheDocument(); + expect(screen.getByText('Previous meeting summary')).toBeInTheDocument(); + }); + + test('buttons are disabled when isLoading is true', () => { + render(); + + // Check if buttons are disabled + expect(screen.getByText('Top discussion trends')).toBeDisabled(); + expect(screen.getByText('Investment summary')).toBeDisabled(); + expect(screen.getByText('Previous meeting summary')).toBeDisabled(); + }); + + test('buttons are enabled when isLoading is false', () => { + render(); + + // Check if buttons are enabled + expect(screen.getByText('Top discussion trends')).toBeEnabled(); + expect(screen.getByText('Investment summary')).toBeEnabled(); + expect(screen.getByText('Previous meeting summary')).toBeEnabled(); + }); + + test('clicking a button calls onClickPrompt with correct prompt object', () => { + render(); + + // Simulate button clicks + fireEvent.click(screen.getByText('Top discussion trends')); + expect(mockOnClickPrompt).toHaveBeenCalledWith({ + name: 'Top discussion trends', + question: 'Top discussion trends', + key: 'p1', + }); + + fireEvent.click(screen.getByText('Investment summary')); + expect(mockOnClickPrompt).toHaveBeenCalledWith({ + name: 'Investment summary', + question: 'Investment summary', + key: 'p2', + }); + + fireEvent.click(screen.getByText('Previous meeting summary')); + expect(mockOnClickPrompt).toHaveBeenCalledWith({ + name: 'Previous meeting summary', + question: 'Previous meeting summary', + key: 'p3', + }); + }); +}); diff --git a/ClientAdvisor/App/frontend/src/components/QuestionInput/QuestionInput.module.css b/ClientAdvisor/App/frontend/src/components/QuestionInput/QuestionInput.module.css index b9dc041e5..ad8709218 100644 --- a/ClientAdvisor/App/frontend/src/components/QuestionInput/QuestionInput.module.css +++ b/ClientAdvisor/App/frontend/src/components/QuestionInput/QuestionInput.module.css @@ -62,3 +62,12 @@ left: 16.5%; } } + +@media screen and (-ms-high-contrast: active), (forced-colors: active) { + + .questionInputContainer{ + border: 2px solid WindowText; + background-color: Window; + color: WindowText; + } +} diff --git a/ClientAdvisor/App/frontend/src/components/QuestionInput/QuestionInput.test.tsx b/ClientAdvisor/App/frontend/src/components/QuestionInput/QuestionInput.test.tsx new file mode 100644 index 000000000..3d1bf7f1d --- /dev/null +++ b/ClientAdvisor/App/frontend/src/components/QuestionInput/QuestionInput.test.tsx @@ -0,0 +1,111 @@ +import { render, screen, fireEvent } from '@testing-library/react' +import { QuestionInput } from './QuestionInput' + +globalThis.fetch = fetch + +const mockOnSend = jest.fn() + +describe('QuestionInput Component', () => { + afterEach(() => { + jest.clearAllMocks() + }) + + test('renders correctly with placeholder', () => { + render() + expect(screen.getByPlaceholderText('Ask a question')).toBeInTheDocument() + }) + + test('does not call onSend when disabled', () => { + render() + const input = screen.getByPlaceholderText('Ask a question') + fireEvent.change(input, { target: { value: 'Test question' } }) + fireEvent.keyDown(input, { key: 'Enter', code: 'Enter', charCode: 13 }) + expect(mockOnSend).not.toHaveBeenCalled() + }) + + test('calls onSend with question and conversationId when enter is pressed', () => { + render() + const input = screen.getByPlaceholderText('Ask a question') + fireEvent.change(input, { target: { value: 'Test question' } }) + fireEvent.keyDown(input, { key: 'Enter', code: 'Enter', charCode: 13 }) + expect(mockOnSend).toHaveBeenCalledWith('Test question', '123') + }) + + test('clears question input if clearOnSend is true', () => { + render() + const input = screen.getByPlaceholderText('Ask a question') + fireEvent.change(input, { target: { value: 'Test question' } }) + fireEvent.keyDown(input, { key: 'Enter', code: 'Enter', charCode: 13 }) + expect(input).toHaveValue('') + }) + + test('does not clear question input if clearOnSend is false', () => { + render() + const input = screen.getByPlaceholderText('Ask a question') + fireEvent.change(input, { target: { value: 'Test question' } }) + fireEvent.keyDown(input, { key: 'Enter', code: 'Enter', charCode: 13 }) + expect(input).toHaveValue('Test question') + }) + + test('disables send button when question is empty or disabled', () => { + //render() + //expect(screen.getByRole('button')).toBeDisabled() + + render() + const input = screen.getByPlaceholderText('Ask a question') + fireEvent.change(input, { target: { value: '' } }) + //expect(screen.getByRole('button')).toBeDisabled() + }) + + test('calls onSend on send button click when not disabled', () => { + render() + const input = screen.getByPlaceholderText('Ask a question') + fireEvent.change(input, { target: { value: 'Test question' } }) + fireEvent.click(screen.getByRole('button')) + expect(mockOnSend).toHaveBeenCalledWith('Test question') + }) + + test('send button shows SendRegular icon when disabled', () => { + render() + //expect(screen.getByTestId('send-icon')).toBeInTheDocument() + }) + + test('send button shows Send SVG when enabled', () => { + render() + // expect(screen.getByAltText('Send Button')).toBeInTheDocument() + }) + + test('calls sendQuestion on Enter key press', () => { + const { getByPlaceholderText } = render( + + ) + const input = getByPlaceholderText('Ask a question') + + fireEvent.change(input, { target: { value: 'Test question' } }) + fireEvent.keyDown(input, { key: 'Enter', code: 'Enter', charCode: 13 }) + + expect(mockOnSend).toHaveBeenCalledWith('Test question') + }) + + test('calls sendQuestion on Space key press when input is not empty', () => { + render() + + const input = screen.getByPlaceholderText('Ask a question') + + fireEvent.change(input, { target: { value: 'Test question' } }) + + fireEvent.keyDown(screen.getByRole('button'), { key: ' ', code: 'Space', charCode: 32 }) + + expect(mockOnSend).toHaveBeenCalledWith('Test question') + }) + + test('does not call sendQuestion on Space key press if input is empty', () => { + render() + + const input = screen.getByPlaceholderText('Ask a question') + + fireEvent.keyDown(screen.getByRole('button'), { key: ' ', code: 'Space', charCode: 32 }) + + expect(mockOnSend).not.toHaveBeenCalled() + }) +}) diff --git a/ClientAdvisor/App/frontend/src/components/Spinner/Spinner.module.css b/ClientAdvisor/App/frontend/src/components/Spinner/SpinnerComponent.module.css similarity index 100% rename from ClientAdvisor/App/frontend/src/components/Spinner/Spinner.module.css rename to ClientAdvisor/App/frontend/src/components/Spinner/SpinnerComponent.module.css diff --git a/ClientAdvisor/App/frontend/src/components/Spinner/SpinnerComponent.test.tsx b/ClientAdvisor/App/frontend/src/components/Spinner/SpinnerComponent.test.tsx new file mode 100644 index 000000000..c447244aa --- /dev/null +++ b/ClientAdvisor/App/frontend/src/components/Spinner/SpinnerComponent.test.tsx @@ -0,0 +1,67 @@ +// SpinnerComponent.test.tsx +import { render, screen } from '@testing-library/react'; +import {SpinnerComponent} from './SpinnerComponent'; +import { Spinner } from '@fluentui/react'; + +// Mock the Fluent UI Spinner component +jest.mock('@fluentui/react', () => ({ + ...jest.requireActual('@fluentui/react'), + Spinner: jest.fn(() =>
), +})); + +describe('SpinnerComponent', () => { + test('does not render the spinner when loading is false', () => { + render(); + + // Spinner should not be in the document + const spinnerContainer = screen.queryByTestId('spinnerContainer'); + expect(spinnerContainer).not.toBeInTheDocument(); + }); + + test('renders the spinner when loading is true', () => { + render(); + + // Spinner should be in the document + const spinnerContainer = screen.getByTestId('spinnerContainer'); + expect(spinnerContainer).toBeInTheDocument(); + }); + + test('renders the spinner with the provided label', () => { + const label = 'Loading...'; + render(); + + // Spinner should be in the document with the provided label + expect(Spinner).toHaveBeenCalledWith( + expect.objectContaining({ label }), + expect.anything() + ); + }); + + test('renders the spinner without a label when label is not provided', () => { + render(); + + // Spinner should be called without a label + expect(Spinner).toHaveBeenCalledWith( + expect.objectContaining({ label: undefined }), + expect.anything() + ); + }); + + test('spinner has the correct custom styles', () => { + render(); + + // Spinner should be called with custom styles + expect(Spinner).toHaveBeenCalledWith( + expect.objectContaining({ + styles: expect.objectContaining({ + label: { + fontSize: '20px', + color: 'rgb(91 184 255)', + fontWeight: 600, + }, + }), + }), + expect.anything() + ); + }); +}); diff --git a/ClientAdvisor/App/frontend/src/components/Spinner/Spinner.tsx b/ClientAdvisor/App/frontend/src/components/Spinner/SpinnerComponent.tsx similarity index 72% rename from ClientAdvisor/App/frontend/src/components/Spinner/Spinner.tsx rename to ClientAdvisor/App/frontend/src/components/Spinner/SpinnerComponent.tsx index d8b519ffb..67276f691 100644 --- a/ClientAdvisor/App/frontend/src/components/Spinner/Spinner.tsx +++ b/ClientAdvisor/App/frontend/src/components/Spinner/SpinnerComponent.tsx @@ -1,6 +1,6 @@ import React, { useState, useEffect } from 'react'; import { Spinner, SpinnerSize,ISpinnerStyles } from '@fluentui/react'; -import styles from './Spinner.module.css'; +import styles from './SpinnerComponent.module.css'; interface SpinnerComponentProps { loading: boolean; @@ -16,14 +16,13 @@ interface SpinnerComponentProps { }; - const SpinnerComponent: React.FC = ({ loading, label }) => { + export const SpinnerComponent: React.FC = ({ loading, label }) => { if (!loading) return null; return ( -
+
); }; -export default SpinnerComponent; diff --git a/ClientAdvisor/App/frontend/src/components/UserCard/UserCard.module.css b/ClientAdvisor/App/frontend/src/components/UserCard/UserCard.module.css index d50e5ae3c..71032898f 100644 --- a/ClientAdvisor/App/frontend/src/components/UserCard/UserCard.module.css +++ b/ClientAdvisor/App/frontend/src/components/UserCard/UserCard.module.css @@ -29,7 +29,7 @@ } .selected { - background-color: #0078D7; + background-color: #0F6CBD; color: white !important; box-shadow: 0px 4px 8px 0px rgba(0, 0, 0, 0.14), 0px 0px 2px 0px rgba(0, 0, 0, 0.12); } diff --git a/ClientAdvisor/App/frontend/src/components/UserCard/UserCard.test.tsx b/ClientAdvisor/App/frontend/src/components/UserCard/UserCard.test.tsx new file mode 100644 index 000000000..e52d0605c --- /dev/null +++ b/ClientAdvisor/App/frontend/src/components/UserCard/UserCard.test.tsx @@ -0,0 +1,90 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { UserCard } from './UserCard'; +import { Icon } from '@fluentui/react/lib/Icon'; + +// Mocking the Fluent UI Icon component (if needed) +jest.mock('@fluentui/react/lib/Icon', () => ({ + Icon: () => , +})); + +const mockProps = { + ClientId: 1, + ClientName: 'John Doe', + NextMeeting: '10th October, 2024', + NextMeetingTime: '10:00 AM', + NextMeetingEndTime: '11:00 AM', + AssetValue: '100,000', + LastMeeting: '5th October, 2024', + LastMeetingStartTime: '9:00 AM', + LastMeetingEndTime: '10:00 AM', + ClientSummary: 'A summary of the client details.', + onCardClick: jest.fn(), + isSelected: false, + isNextMeeting: true, + chartUrl: '/path/to/chart', +}; + +describe('UserCard Component', () => { + it('renders user card with basic details', () => { + render(); + + expect(screen.getByText(mockProps.ClientName)).toBeInTheDocument(); + expect(screen.getByText(mockProps.NextMeeting)).toBeInTheDocument(); + expect(screen.getByText(`${mockProps.NextMeetingTime} - ${mockProps.NextMeetingEndTime}`)).toBeInTheDocument(); + expect(screen.getByText('More details')).toBeInTheDocument(); + expect(screen.getAllByTestId('icon')).toHaveLength(2); + }); + + it('handles card click correctly', () => { + render(); + fireEvent.click(screen.getByText(mockProps.ClientName)); + expect(mockProps.onCardClick).toHaveBeenCalled(); + }); + + it('toggles show more details on button click', () => { + render(); + const showMoreButton = screen.getByText('More details'); + fireEvent.click(showMoreButton); + expect(screen.getByText('Asset Value')).toBeInTheDocument(); + expect(screen.getByText('Less details')).toBeInTheDocument(); + fireEvent.click(screen.getByText('Less details')); + expect(screen.queryByText('Asset Value')).not.toBeInTheDocument(); + }); + + it('handles keydown event for show more/less details', () => { + render(); + const showMoreButton = screen.getByText('More details'); + fireEvent.keyDown(showMoreButton, { key: ' ', code: 'Space' }); // Testing space key for show more + expect(screen.getByText('Asset Value')).toBeInTheDocument(); + fireEvent.keyDown(screen.getByText('Less details'), { key: 'Enter', code: 'Enter' }); // Testing enter key for less details + expect(screen.queryByText('Asset Value')).not.toBeInTheDocument(); + }); + + it('handles keydown event for card click (Enter)', () => { + render(); + const card = screen.getByText(mockProps.ClientName); + fireEvent.keyDown(card, { key: 'Enter', code: 'Enter' }); // Testing Enter key for card click + expect(mockProps.onCardClick).toHaveBeenCalled(); + }); + + it('handles keydown event for card click Space', () => { + render(); + const card = screen.getByText(mockProps.ClientName); + + fireEvent.keyDown(card, { key: ' ', code: 'Space' }); // Testing Space key for card click + expect(mockProps.onCardClick).toHaveBeenCalledTimes(3); // Check if it's been called twice now + }); + + + it('adds selected class when isSelected is true', () => { + render(); + const card = screen.getByText(mockProps.ClientName).parentElement; + expect(card).toHaveClass('selected'); + }); + +}); + +// Fix for the isolatedModules error +export {}; diff --git a/ClientAdvisor/App/frontend/src/components/UserCard/UserCard.tsx b/ClientAdvisor/App/frontend/src/components/UserCard/UserCard.tsx index 1b5d4c25a..7bc4208c3 100644 --- a/ClientAdvisor/App/frontend/src/components/UserCard/UserCard.tsx +++ b/ClientAdvisor/App/frontend/src/components/UserCard/UserCard.tsx @@ -22,7 +22,7 @@ interface UserCardProps { chartUrl: string; } -const UserCard: React.FC = ({ +export const UserCard: React.FC = ({ ClientId, ClientName, NextMeeting, @@ -50,7 +50,7 @@ const UserCard: React.FC = ({
{ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); // Prevent the default action like scrolling. - handleShowMoreClick(e); // Call the same function as onClick. + onCardClick(); // Call the same function as onClick. } }}>
{ClientName}
@@ -85,4 +85,3 @@ const UserCard: React.FC = ({ ); }; -export default UserCard; diff --git a/ClientAdvisor/App/frontend/src/components/common/Button.module.css b/ClientAdvisor/App/frontend/src/components/common/Button.module.css index 14c1ecb70..dc5df4d5b 100644 --- a/ClientAdvisor/App/frontend/src/components/common/Button.module.css +++ b/ClientAdvisor/App/frontend/src/components/common/Button.module.css @@ -25,6 +25,7 @@ .historyButtonRoot { width: 180px; border: 1px solid #d1d1d1; + border-radius: 5px; } .historyButtonRoot:hover { diff --git a/ClientAdvisor/App/frontend/src/helpers/helpers.test.ts b/ClientAdvisor/App/frontend/src/helpers/helpers.test.ts new file mode 100644 index 000000000..2ec74735b --- /dev/null +++ b/ClientAdvisor/App/frontend/src/helpers/helpers.test.ts @@ -0,0 +1,200 @@ +import { groupByMonth, formatMonth, parseCitationFromMessage, parseErrorMessage, tryGetRaiPrettyError } from './helpers'; +import { ChatMessage, Conversation } from '../api/models'; + +describe('groupByMonth', () => { + + test('should group recent conversations into the "Recent" group when the difference is less than or equal to 7 days', () => { + const currentDate = new Date(); + const recentDate = new Date(currentDate.getTime() - 3 * 24 * 60 * 60 * 1000); // 3 days ago + const entries: Conversation[] = [ + { + id: '1', + title: 'Recent Conversation', + date: recentDate.toISOString(), + messages: [], + }, + ]; + const result = groupByMonth(entries); + expect(result[0].month).toBe('Recent'); + expect(result[0].entries.length).toBe(1); + expect(result[0].entries[0].id).toBe('1'); + }); + + test('should group conversations by month when the difference is more than 7 days', () => { + const entries: Conversation[] = [ + { + id: '1', + title: 'Older Conversation', + date: '2024-09-01T10:26:03.844538', + messages: [], + }, + { + id: '2', + title: 'Another Older Conversation', + date: '2024-08-01T10:26:03.844538', + messages: [], + }, + + { + id: '3', + title: 'Older Conversation', + date: '2024-10-08T10:26:03.844538', + messages: [], + }, + ]; + + const result = groupByMonth(entries); + expect(result[1].month).toBe('September 2024'); + expect(result[1].entries.length).toBe(1); + expect(result[2].month).toBe('August 2024'); + expect(result[2].entries.length).toBe(1); + }); + + test('should push entries into an existing group if the group for that month already exists', () => { + const entries: Conversation[] = [ + { + id: '1', + title: 'First Conversation', + date: '2024-09-08T10:26:03.844538', + messages: [], + }, + { + id: '2', + title: 'Second Conversation', + date: '2024-09-10T10:26:03.844538', + messages: [], + }, + ]; + + const result = groupByMonth(entries); + + expect(result[0].month).toBe('September 2024'); + expect(result[0].entries.length).toBe(2); + }); + +}); + +describe('formatMonth', () => { + + it('should return the month name if the year is the current year', () => { + const currentYear = new Date().getFullYear(); + const month = `${new Date().toLocaleString('default', { month: 'long' })} ${currentYear}`; + + const result = formatMonth(month); + + expect(result).toEqual(new Date().toLocaleString('default', { month: 'long' })); + }); + + it('should return the full month string if the year is not the current year', () => { + const month = 'January 2023'; // Assuming the current year is 2024 + const result = formatMonth(month); + + expect(result).toEqual(month); + }); + + it('should handle invalid month format gracefully', () => { + const month = 'Invalid Month Format'; + const result = formatMonth(month); + + expect(result).toEqual(month); + }); + + it('should return the full month string if the month is empty', () => { + const month = ' '; + const result = formatMonth(month); + + expect(result).toEqual(month); + }); + +}); + +describe('parseCitationFromMessage', () => { + + it('should return citations when the message role is "tool" and content is valid JSON', () => { + const message: ChatMessage = { + id: '1', + role: 'tool', + content: JSON.stringify({ + citations: ['citation1', 'citation2'], + }), + date: new Date().toISOString(), + }; + + const result = parseCitationFromMessage(message); + + expect(result).toEqual(['citation1', 'citation2']); + }); + + it('should return an empty array if the message role is not "tool"', () => { + const message: ChatMessage = { + id: '2', + role: 'user', + content: JSON.stringify({ + citations: ['citation1', 'citation2'], + }), + date: new Date().toISOString(), + }; + + const result = parseCitationFromMessage(message); + + expect(result).toEqual([]); + }); + + it('should return an empty array if the content is not valid JSON', () => { + const message: ChatMessage = { + id: '3', + role: 'tool', + content: 'invalid JSON content', + date: new Date().toISOString(), + }; + + const result = parseCitationFromMessage(message); + + expect(result).toEqual([]); + }); + +}); + +describe('tryGetRaiPrettyError', () => { + + it('should return prettified error message when inner error is filtered as jailbreak', () => { + const errorMessage = "Some error occurred, 'innererror': {'content_filter_result': {'jailbreak': {'filtered': True}}}}}"; + + // Fix the input format: Single quotes must be properly escaped in the context of JSON parsing + const result = tryGetRaiPrettyError(errorMessage); + + expect(result).toEqual( + 'The prompt was filtered due to triggering Azure OpenAI’s content filtering system.\n' + + 'Reason: This prompt contains content flagged as Jailbreak\n\n' + + 'Please modify your prompt and retry. Learn more: https://go.microsoft.com/fwlink/?linkid=2198766' + ); + }); + + it('should return the original error message if no inner error found', () => { + const errorMessage = "Error: some error message without inner error"; + const result = tryGetRaiPrettyError(errorMessage); + + expect(result).toEqual(errorMessage); + }); + + it('should return the original error message if inner error is malformed', () => { + const errorMessage = "Error: some error message, 'innererror': {'content_filter_result': {'jailbreak': {'filtered': true}}}"; + const result = tryGetRaiPrettyError(errorMessage); + + expect(result).toEqual(errorMessage); + }); + +}); + +describe('parseErrorMessage', () => { + + it('should extract inner error message and call tryGetRaiPrettyError', () => { + const errorMessage = "Error occurred - {\\'error\\': {\\'message\\': 'Some inner error message'}}"; + const result = parseErrorMessage(errorMessage); + + expect(result).toEqual("Error occurred - {'error': {'message': 'Some inner error message"); + }); + +}); + + diff --git a/ClientAdvisor/App/frontend/src/helpers/helpers.ts b/ClientAdvisor/App/frontend/src/helpers/helpers.ts new file mode 100644 index 000000000..3541110db --- /dev/null +++ b/ClientAdvisor/App/frontend/src/helpers/helpers.ts @@ -0,0 +1,134 @@ +import { Conversation, GroupedChatHistory, ChatMessage, ToolMessageContent } from '../api/models' + +export const groupByMonth = (entries: Conversation[]) => { + const groups: GroupedChatHistory[] = [{ month: 'Recent', entries: [] }] + const currentDate = new Date() + + entries.forEach(entry => { + const date = new Date(entry.date) + const daysDifference = (currentDate.getTime() - date.getTime()) / (1000 * 60 * 60 * 24) + const monthYear = date.toLocaleString('default', { month: 'long', year: 'numeric' }) + const existingGroup = groups.find(group => group.month === monthYear) + + if (daysDifference <= 7) { + groups[0].entries.push(entry) + } else { + if (existingGroup) { + existingGroup.entries.push(entry) + } else { + groups.push({ month: monthYear, entries: [entry] }) + } + } + }) + + groups.sort((a, b) => { + // Check if either group has no entries and handle it + if (a.entries.length === 0 && b.entries.length === 0) { + return 0 // No change in order + } else if (a.entries.length === 0) { + return 1 // Move 'a' to a higher index (bottom) + } else if (b.entries.length === 0) { + return -1 // Move 'b' to a higher index (bottom) + } + const dateA = new Date(a.entries[0].date) + const dateB = new Date(b.entries[0].date) + return dateB.getTime() - dateA.getTime() + }) + + groups.forEach(group => { + group.entries.sort((a, b) => { + const dateA = new Date(a.date) + const dateB = new Date(b.date) + return dateB.getTime() - dateA.getTime() + }) + }) + + return groups +} + +export const formatMonth = (month: string) => { + const currentDate = new Date() + const currentYear = currentDate.getFullYear() + + const [monthName, yearString] = month.split(' ') + const year = parseInt(yearString) + + if (year === currentYear) { + return monthName + } else { + return month + } +} + + +// -------------Chat.tsx------------- +export const parseCitationFromMessage = (message: ChatMessage) => { + if (message?.role && message?.role === 'tool') { + try { + const toolMessage = JSON.parse(message.content) as ToolMessageContent + return toolMessage.citations + } catch { + return [] + } + } + return [] +} + +export const tryGetRaiPrettyError = (errorMessage: string) => { + try { + // Using a regex to extract the JSON part that contains "innererror" + const match = errorMessage.match(/'innererror': ({.*})\}\}/) + if (match) { + // Replacing single quotes with double quotes and converting Python-like booleans to JSON booleans + const fixedJson = match[1] + .replace(/'/g, '"') + .replace(/\bTrue\b/g, 'true') + .replace(/\bFalse\b/g, 'false') + const innerErrorJson = JSON.parse(fixedJson) + let reason = '' + // Check if jailbreak content filter is the reason of the error + const jailbreak = innerErrorJson.content_filter_result.jailbreak + if (jailbreak.filtered === true) { + reason = 'Jailbreak' + } + + // Returning the prettified error message + if (reason !== '') { + return ( + 'The prompt was filtered due to triggering Azure OpenAI’s content filtering system.\n' + + 'Reason: This prompt contains content flagged as ' + + reason + + '\n\n' + + 'Please modify your prompt and retry. Learn more: https://go.microsoft.com/fwlink/?linkid=2198766' + ) + } + } + } catch (e) { + console.error('Failed to parse the error:', e) + } + return errorMessage +} + + +export const parseErrorMessage = (errorMessage: string) => { + let errorCodeMessage = errorMessage.substring(0, errorMessage.indexOf('-') + 1) + const innerErrorCue = "{\\'error\\': {\\'message\\': " + if (errorMessage.includes(innerErrorCue)) { + try { + let innerErrorString = errorMessage.substring(errorMessage.indexOf(innerErrorCue)) + if (innerErrorString.endsWith("'}}")) { + innerErrorString = innerErrorString.substring(0, innerErrorString.length - 3) + } + innerErrorString = innerErrorString.replaceAll("\\'", "'") + let newErrorMessage = errorCodeMessage + ' ' + innerErrorString + errorMessage = newErrorMessage + } catch (e) { + console.error('Error parsing inner error message: ', e) + } + } + + return tryGetRaiPrettyError(errorMessage) +} + +// -------------Chat.tsx------------- + diff --git a/ClientAdvisor/App/frontend/src/mocks/handlers.ts b/ClientAdvisor/App/frontend/src/mocks/handlers.ts new file mode 100644 index 000000000..b60d86989 --- /dev/null +++ b/ClientAdvisor/App/frontend/src/mocks/handlers.ts @@ -0,0 +1,5 @@ +import { http, HttpResponse } from 'msw' + +export const handlers = [ + +]; diff --git a/ClientAdvisor/App/frontend/src/mocks/server.ts b/ClientAdvisor/App/frontend/src/mocks/server.ts new file mode 100644 index 000000000..5f8393d60 --- /dev/null +++ b/ClientAdvisor/App/frontend/src/mocks/server.ts @@ -0,0 +1,5 @@ +// src/mocks/server.ts +import { setupServer } from 'msw/node'; +import { handlers } from './handlers'; + +export const server = setupServer(...handlers); diff --git a/ClientAdvisor/App/frontend/src/pages/chat/Chat.test.tsx b/ClientAdvisor/App/frontend/src/pages/chat/Chat.test.tsx new file mode 100644 index 000000000..1621ef965 --- /dev/null +++ b/ClientAdvisor/App/frontend/src/pages/chat/Chat.test.tsx @@ -0,0 +1,1537 @@ +import { renderWithContext, screen, waitFor, fireEvent, act } from '../../test/test.utils' +import Chat from './Chat' +import { ChatHistoryLoadingState } from '../../api/models' + +import { + getUserInfo, + conversationApi, + historyGenerate, + historyClear, + ChatMessage, + Citation, + historyUpdate, + CosmosDBStatus +} from '../../api' +import userEvent from '@testing-library/user-event' + +import { AIResponseContent, decodedConversationResponseWithCitations } from '../../../__mocks__/mockAPIData' +import { CitationPanel } from './Components/CitationPanel' +// import { BuildingCheckmarkRegular } from '@fluentui/react-icons'; + +// Mocking necessary modules and components +jest.mock('../../api/api', () => ({ + getUserInfo: jest.fn(), + historyClear: jest.fn(), + historyGenerate: jest.fn(), + historyUpdate: jest.fn(), + conversationApi: jest.fn() +})) + +interface ChatMessageContainerProps { + messages: ChatMessage[] + isLoading: boolean + showLoadingMessage: boolean + onShowCitation: (citation: Citation) => void +} + +const citationObj = { + id: '123', + content: 'This is a sample citation content.', + title: 'Test Citation with Blob URL', + url: 'https://test.core.example.com/resource', + filepath: 'path', + metadata: '', + chunk_id: '', + reindex_id: '' +} +jest.mock('./Components/ChatMessageContainer', () => ({ + ChatMessageContainer: jest.fn((props: ChatMessageContainerProps) => { + return ( +
+

ChatMessageContainerMock

+ {props.messages.map((message: any, index: number) => { + return ( + <> +

{message.role}

+

{message.content}

+ + ) + })} + +
+
+ ) + }) +})) +jest.mock('./Components/CitationPanel', () => ({ + CitationPanel: jest.fn((props: any) => { + return ( + <> +
CitationPanel Mock Component
+

{props.activeCitation.title}

+ + + ) + }) +})) +jest.mock('./Components/AuthNotConfigure', () => ({ + AuthNotConfigure: jest.fn(() =>
AuthNotConfigure Mock
) +})) +jest.mock('../../components/QuestionInput', () => ({ + QuestionInput: jest.fn((props: any) => ( +
+ QuestionInputMock + + + +
+ )) +})) +jest.mock('../../components/ChatHistory/ChatHistoryPanel', () => ({ + ChatHistoryPanel: jest.fn(() =>
ChatHistoryPanelMock
) +})) +jest.mock('../../components/PromptsSection/PromptsSection', () => ({ + PromptsSection: jest.fn((props: any) => ( +
+ props.onClickPrompt({ name: 'Top discussion trends', question: 'Top discussion trends', key: 'p1' }) + }> + PromptsSectionMock +
+ )) +})) + +const mockDispatch = jest.fn() +const originalHostname = window.location.hostname + +const mockState = { + isChatHistoryOpen: false, + chatHistoryLoadingState: 'success', + chatHistory: [], + filteredChatHistory: null, + currentChat: null, + isCosmosDBAvailable: { + cosmosDB: true, + status: 'CosmosDB is configured and working' + }, + frontendSettings: { + auth_enabled: true, + feedback_enabled: 'conversations', + sanitize_answer: false, + ui: { + chat_description: 'This chatbot is configured to answer your questions', + chat_logo: null, + chat_title: 'Start chatting', + logo: null, + show_share_button: true, + title: 'Woodgrove Bank' + } + }, + feedbackState: {}, + clientId: '10002', + isRequestInitiated: false, + isLoader: false +} + +const mockStateWithChatHistory = { + ...mockState, + chatHistory: [ + { + id: '408a43fb-0f60-45e4-8aef-bfeb5cb0afb6', + title: 'Summarize Alexander Harrington previous meetings', + date: '2024-10-08T10:22:01.413959', + messages: [ + { + id: 'b0fb6917-632d-4af5-89ba-7421d7b378d6', + role: 'user', + date: '2024-10-08T10:22:02.889348', + content: 'Summarize Alexander Harrington previous meetings', + feedback: '' + } + ] + }, + { + id: 'ebe3ee4d-2a7c-4a31-bca3-2ccc14d7b5db', + title: 'Inquiry on Data Presentation', + messages: [ + { + id: 'd5811d9f-9f0f-d6c8-61a8-3e25f2df7b51', + role: 'user', + content: 'test data', + date: '2024-10-08T13:17:36.495Z' + }, + { + role: 'assistant', + content: 'I cannot answer this question from the data available. Please rephrase or add more details.', + id: 'c53d6702-9ca0-404a-9306-726f19ee80ba', + date: '2024-10-08T13:18:57.083Z' + } + ], + date: '2024-10-08T13:17:40.827540' + } + ], + currentChat: { + id: 'ebe3ee4d-2a7c-4a31-bca3-2ccc14d7b5db', + title: 'Inquiry on Data Presentation', + messages: [ + { + id: 'd5811d9f-9f0f-d6c8-61a8-3e25f2df7b51', + role: 'user', + content: 'test data', + date: '2024-10-08T13:17:36.495Z' + }, + { + role: 'assistant', + content: 'I cannot answer this question from the data available. Please rephrase or add more details.', + id: 'c53d6702-9ca0-404a-9306-726f19ee80ba', + date: '2024-10-08T13:18:57.083Z' + } + ], + date: '2024-10-08T13:17:40.827540' + } +} + +const response = { + id: 'cb010365-18d7-41a8-aef6-8c68f9418bb7', + model: 'gpt-4', + created: 1728388001, + object: 'extensions.chat.completion.chunk', + choices: [ + { + messages: [ + { + role: 'assistant', + content: 'response from AI!', + id: 'cb010365-18d7-41a8-aef6-8c68f9418bb7', + date: '2024-10-08T11:46:48.585Z' + } + ] + } + ], + history_metadata: { + conversation_id: '96bffdc3-cd72-4b4b-b257-67a0b161ab43' + }, + 'apim-request-id': '' +} + +const response2 = { + id: 'cb010365-18d7-41a8-aef6-8c68f9418bb7', + model: 'gpt-4', + created: 1728388001, + object: 'extensions.chat.completion.chunk', + choices: [ + { + messages: [ + { + role: 'assistant', + id: 'cb010365-18d7-41a8-aef6-8c68f9418bb7', + date: '2024-10-08T11:46:48.585Z' + } + ] + } + ], + + 'apim-request-id': '' +} + +const noContentResponse = { + id: 'cb010365-18d7-41a8-aef6-8c68f9418bb7', + model: 'gpt-4', + created: 1728388001, + object: 'extensions.chat.completion.chunk', + choices: [ + { + messages: [ + { + role: 'assistant', + id: 'cb010365-18d7-41a8-aef6-8c68f9418bb7', + date: '2024-10-08T11:46:48.585Z' + } + ] + } + ], + history_metadata: { + conversation_id: '3692f941-85cb-436c-8c32-4287fe885782' + }, + 'apim-request-id': '' +} + +const response3 = { + id: 'cb010365-18d7-41a8-aef6-8c68f9418bb7', + model: 'gpt-4', + created: 1728388001, + object: 'extensions.chat.completion.chunk', + choices: [ + { + messages: [ + { + role: 'assistant', + content: 'response from AI content!', + context: 'response from AI context!', + id: 'cb010365-18d7-41a8-aef6-8c68f9418bb7', + date: '2024-10-08T11:46:48.585Z' + } + ] + } + ], + history_metadata: { + conversation_id: '3692f941-85cb-436c-8c32-4287fe885782' + }, + 'apim-request-id': '' +} + +//---ConversationAPI Response + +const addToExistResponse = { + id: 'cb010365-18d7-41a8-aef6-8c68f9418bb7', + model: 'gpt-4', + created: 1728388001, + object: 'extensions.chat.completion.chunk', + choices: [ + { + messages: [ + { + role: 'assistant', + content: 'response from AI content!', + context: 'response from AI context!', + id: 'cb010365-18d7-41a8-aef6-8c68f9418bb7', + date: '2024-10-08T11:46:48.585Z' + } + ] + } + ], + history_metadata: { + conversation_id: '3692f941-85cb-436c-8c32-4287fe885782' + }, + 'apim-request-id': '' +} + +//-----ConversationAPI Response + +const response4 = {} + +let originalFetch: typeof global.fetch + +describe('Chat Component', () => { + let mockCallHistoryGenerateApi: any + let historyUpdateApi: any + let mockCallConversationApi: any + + let mockAbortController: any + + const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)) + const delayedHistoryGenerateAPIcallMock = () => { + const mockResponse = { + body: { + getReader: jest.fn().mockReturnValue({ + read: jest + .fn() + .mockResolvedValueOnce( + delay(5000).then(() => ({ + done: false, + value: new TextEncoder().encode(JSON.stringify(decodedConversationResponseWithCitations)) + })) + ) + .mockResolvedValueOnce({ + done: true, + value: new TextEncoder().encode(JSON.stringify({})) + }) + }) + } + } + + mockCallHistoryGenerateApi.mockResolvedValueOnce({ ok: true, ...mockResponse }) + } + + const historyGenerateAPIcallMock = () => { + const mockResponse = { + body: { + getReader: jest.fn().mockReturnValue({ + read: jest + .fn() + .mockResolvedValueOnce({ + done: false, + value: new TextEncoder().encode(JSON.stringify(response3)) + }) + .mockResolvedValueOnce({ + done: true, + value: new TextEncoder().encode(JSON.stringify({})) + }) + }) + } + } + mockCallHistoryGenerateApi.mockResolvedValueOnce({ ok: true, ...mockResponse }) + } + + const nonDelayedhistoryGenerateAPIcallMock = (type = '') => { + let mockResponse = {} + switch (type) { + case 'no-content-history': + mockResponse = { + body: { + getReader: jest.fn().mockReturnValue({ + read: jest + .fn() + .mockResolvedValueOnce({ + done: false, + value: new TextEncoder().encode(JSON.stringify(response2)) + }) + .mockResolvedValueOnce({ + done: true, + value: new TextEncoder().encode(JSON.stringify({})) + }) + }) + } + } + break + case 'no-content': + mockResponse = { + body: { + getReader: jest.fn().mockReturnValue({ + read: jest + .fn() + .mockResolvedValueOnce({ + done: false, + value: new TextEncoder().encode(JSON.stringify(noContentResponse)) + }) + .mockResolvedValueOnce({ + done: true, + value: new TextEncoder().encode(JSON.stringify({})) + }) + }) + } + } + break + case 'incompleteJSON': + mockResponse = { + body: { + getReader: jest.fn().mockReturnValue({ + read: jest + .fn() + .mockResolvedValueOnce({ + done: false, + value: new TextEncoder().encode('{"incompleteJson": ') + }) + .mockResolvedValueOnce({ + done: true, + value: new TextEncoder().encode(JSON.stringify({})) + }) + }) + } + } + break + case 'no-result': + mockResponse = { + body: { + getReader: jest.fn().mockReturnValue({ + read: jest + .fn() + .mockResolvedValueOnce({ + done: false, + value: new TextEncoder().encode(JSON.stringify({})) + }) + .mockResolvedValueOnce({ + done: true, + value: new TextEncoder().encode(JSON.stringify({})) + }) + }) + } + } + break + default: + mockResponse = { + body: { + getReader: jest.fn().mockReturnValue({ + read: jest + .fn() + .mockResolvedValueOnce({ + done: false, + value: new TextEncoder().encode(JSON.stringify(response)) + }) + .mockResolvedValueOnce({ + done: true, + value: new TextEncoder().encode(JSON.stringify({})) + }) + }) + } + } + break + } + + mockCallHistoryGenerateApi.mockResolvedValueOnce({ ok: true, ...mockResponse }) + } + + const conversationApiCallMock = (type = '') => { + let mockResponse: any + switch (type) { + case 'incomplete-result': + mockResponse = { + body: { + getReader: jest.fn().mockReturnValue({ + read: jest + .fn() + .mockResolvedValueOnce({ + done: false, + value: new TextEncoder().encode('{"incompleteJson": ') + }) + .mockResolvedValueOnce({ + done: true, + value: new TextEncoder().encode(JSON.stringify({})) + }) + }) + } + } + + break + case 'error-string-result': + mockResponse = { + body: { + getReader: jest.fn().mockReturnValue({ + read: jest + .fn() + .mockResolvedValueOnce({ + done: false, + value: new TextEncoder().encode(JSON.stringify({ error: 'error API result' })) + }) + .mockResolvedValueOnce({ + done: true, + value: new TextEncoder().encode(JSON.stringify({})) + }) + }) + } + } + break + case 'error-result': + mockResponse = { + body: { + getReader: jest.fn().mockReturnValue({ + read: jest + .fn() + .mockResolvedValueOnce({ + done: false, + value: new TextEncoder().encode(JSON.stringify({ error: { message: 'error API result' } })) + }) + .mockResolvedValueOnce({ + done: true, + value: new TextEncoder().encode(JSON.stringify({})) + }) + }) + } + } + break + case 'chat-item-selected': + mockResponse = { + body: { + getReader: jest.fn().mockReturnValue({ + read: jest + .fn() + .mockResolvedValueOnce({ + done: false, + value: new TextEncoder().encode(JSON.stringify(addToExistResponse)) + }) + .mockResolvedValueOnce({ + done: true, + value: new TextEncoder().encode(JSON.stringify({})) + }) + }) + } + } + break + default: + mockResponse = { + body: { + getReader: jest.fn().mockReturnValue({ + read: jest + .fn() + .mockResolvedValueOnce({ + done: false, + value: new TextEncoder().encode(JSON.stringify(response)) + }) + .mockResolvedValueOnce({ + done: true, + value: new TextEncoder().encode(JSON.stringify({})) + }) + }) + } + } + break + } + + mockCallConversationApi.mockResolvedValueOnce({ ...mockResponse }) + } + const setIsVisible = jest.fn() + beforeEach(() => { + jest.clearAllMocks() + originalFetch = global.fetch + global.fetch = jest.fn() + + mockAbortController = new AbortController() + //jest.spyOn(mockAbortController.signal, 'aborted', 'get').mockReturnValue(false); + + mockCallHistoryGenerateApi = historyGenerate as jest.Mock + mockCallHistoryGenerateApi.mockClear() + + historyUpdateApi = historyUpdate as jest.Mock + historyUpdateApi.mockClear() + + mockCallConversationApi = conversationApi as jest.Mock + mockCallConversationApi.mockClear() + + // jest.useFakeTimers(); // Mock timers before each test + jest.spyOn(console, 'error').mockImplementation(() => {}) + + Object.defineProperty(HTMLElement.prototype, 'scroll', { + configurable: true, + value: jest.fn() // Mock implementation + }) + + jest.spyOn(window, 'open').mockImplementation(() => null) + }) + + afterEach(() => { + // jest.clearAllMocks(); + // jest.useRealTimers(); // Reset timers after each test + jest.restoreAllMocks() + // Restore original global fetch after each test + global.fetch = originalFetch + Object.defineProperty(window, 'location', { + value: { hostname: originalHostname }, + writable: true + }) + + jest.clearAllTimers() // Ensures no fake timers are left running + mockCallHistoryGenerateApi.mockReset() + + historyUpdateApi.mockReset() + mockCallConversationApi.mockReset() + }) + + test('Should show Auth not configured when userList length zero', async () => { + Object.defineProperty(window, 'location', { + value: { hostname: '127.0.0.11' }, + writable: true + }) + const mockPayload: any[] = [] + ;(getUserInfo as jest.Mock).mockResolvedValue([...mockPayload]) + + renderWithContext(, mockState) + await waitFor(() => { + expect(screen.queryByText('AuthNotConfigure Mock')).toBeInTheDocument() + }) + }) + + test('Should not show Auth not configured when userList length > 0', async () => { + Object.defineProperty(window, 'location', { + value: { hostname: '127.0.0.1' }, + writable: true + }) + const mockPayload: any[] = [{ id: 1, name: 'User' }] + ;(getUserInfo as jest.Mock).mockResolvedValue([...mockPayload]) + renderWithContext(, mockState) + await waitFor(() => { + expect(screen.queryByText('AuthNotConfigure Mock')).not.toBeInTheDocument() + }) + }) + + test('Should not show Auth not configured when auth_enabled is false', async () => { + Object.defineProperty(window, 'location', { + value: { hostname: '127.0.0.1' }, + writable: true + }) + const mockPayload: any[] = [] + ;(getUserInfo as jest.Mock).mockResolvedValue([...mockPayload]) + const tempMockState = { ...mockState } + tempMockState.frontendSettings = { + ...tempMockState.frontendSettings, + auth_enabled: false + } + renderWithContext(, tempMockState) + await waitFor(() => { + expect(screen.queryByText('AuthNotConfigure Mock')).not.toBeInTheDocument() + }) + }) + + test('Should load chat component when Auth configured', async () => { + Object.defineProperty(window, 'location', { + value: { hostname: '127.0.0.1' }, + writable: true + }) + const mockPayload: any[] = [{ id: 1, name: 'User' }] + ;(getUserInfo as jest.Mock).mockResolvedValue([...mockPayload]) + renderWithContext(, mockState) + await waitFor(() => { + expect(screen.queryByText('Start chatting')).toBeInTheDocument() + expect(screen.queryByText('This chatbot is configured to answer your questions')).toBeInTheDocument() + }) + }) + + test('Prompt tags on click handler when response is inprogress', async () => { + userEvent.setup() + delayedHistoryGenerateAPIcallMock() + const tempMockState = { ...mockState } + tempMockState.frontendSettings = { + ...tempMockState.frontendSettings, + auth_enabled: false + } + renderWithContext(, tempMockState) + const promptButton = await screen.findByRole('button', { name: /prompt-button/i }) + await act(() => { + userEvent.click(promptButton) + }) + const stopGenBtnEle = await screen.findByText('Stop generating') + expect(stopGenBtnEle).toBeInTheDocument() + }) + + test('Should handle error : when stream object does not have content property', async () => { + userEvent.setup() + + nonDelayedhistoryGenerateAPIcallMock('no-content') + historyUpdateApi.mockResolvedValueOnce({ ok: true }) + const tempMockState = { ...mockState } + tempMockState.frontendSettings = { + ...tempMockState.frontendSettings, + auth_enabled: false + } + + renderWithContext(, tempMockState) + const promptButton = screen.getByRole('button', { name: /prompt-button/i }) + + await userEvent.click(promptButton) + + await waitFor(() => { + expect(screen.getByText(/An error occurred. No content in messages object./i)).toBeInTheDocument() + }) + }) + + test('Should handle error : when stream object does not have content property and history_metadata', async () => { + userEvent.setup() + + nonDelayedhistoryGenerateAPIcallMock('no-content-history') + historyUpdateApi.mockResolvedValueOnce({ ok: true }) + const tempMockState = { ...mockState } + tempMockState.frontendSettings = { + ...tempMockState.frontendSettings, + auth_enabled: false + } + + renderWithContext(, tempMockState) + const promptButton = screen.getByRole('button', { name: /prompt-button/i }) + + await userEvent.click(promptButton) + + await waitFor(() => { + expect(screen.getByText(/An error occurred. No content in messages object./i)).toBeInTheDocument() + }) + }) + + test('Stop generating button click', async () => { + userEvent.setup() + delayedHistoryGenerateAPIcallMock() + const tempMockState = { ...mockState } + tempMockState.frontendSettings = { + ...tempMockState.frontendSettings, + auth_enabled: false + } + renderWithContext(, tempMockState) + const promptButton = await screen.findByRole('button', { name: /prompt-button/i }) + await act(() => { + userEvent.click(promptButton) + }) + const stopGenBtnEle = await screen.findByText('Stop generating') + await userEvent.click(stopGenBtnEle) + + await waitFor(() => { + const stopGenBtnEle = screen.queryByText('Stop generating') + expect(stopGenBtnEle).not.toBeInTheDocument() + }) + }) + + test('Stop generating when enter key press on button', async () => { + userEvent.setup() + delayedHistoryGenerateAPIcallMock() + const tempMockState = { ...mockState } + tempMockState.frontendSettings = { + ...tempMockState.frontendSettings, + auth_enabled: false + } + renderWithContext(, tempMockState) + const promptButton = await screen.findByRole('button', { name: /prompt-button/i }) + await act(() => { + userEvent.click(promptButton) + }) + const stopGenBtnEle = await screen.findByText('Stop generating') + await fireEvent.keyDown(stopGenBtnEle, { key: 'Enter', code: 'Enter', charCode: 13 }) + + await waitFor(() => { + const stopGenBtnEle = screen.queryByText('Stop generating') + expect(stopGenBtnEle).not.toBeInTheDocument() + }) + }) + + test('Stop generating when space key press on button', async () => { + userEvent.setup() + delayedHistoryGenerateAPIcallMock() + const tempMockState = { ...mockState } + tempMockState.frontendSettings = { + ...tempMockState.frontendSettings, + auth_enabled: false + } + renderWithContext(, tempMockState) + const promptButton = await screen.findByRole('button', { name: /prompt-button/i }) + await act(() => { + userEvent.click(promptButton) + }) + const stopGenBtnEle = await screen.findByText('Stop generating') + await fireEvent.keyDown(stopGenBtnEle, { key: ' ', code: 'Space', charCode: 32 }) + + await waitFor(() => { + const stopGenBtnEle = screen.queryByText('Stop generating') + expect(stopGenBtnEle).not.toBeInTheDocument() + }) + }) + + test('Should not call stopGenerating method when key press other than enter/space/click', async () => { + userEvent.setup() + delayedHistoryGenerateAPIcallMock() + const tempMockState = { ...mockState } + tempMockState.frontendSettings = { + ...tempMockState.frontendSettings, + auth_enabled: false + } + renderWithContext(, tempMockState) + const promptButton = await screen.findByRole('button', { name: /prompt-button/i }) + await act(() => { + userEvent.click(promptButton) + }) + const stopGenBtnEle = await screen.findByText('Stop generating') + await fireEvent.keyDown(stopGenBtnEle, { key: 'a', code: 'KeyA' }) + + await waitFor(() => { + const stopGenBtnEle = screen.queryByText('Stop generating') + expect(stopGenBtnEle).toBeInTheDocument() + }) + }) + + test('should handle historyGenerate API failure correctly', async () => { + const mockError = new Error('API request failed') + mockCallHistoryGenerateApi.mockResolvedValueOnce({ ok: false, json: jest.fn().mockResolvedValueOnce(mockError) }) + + const tempMockState = { ...mockState } + tempMockState.frontendSettings = { + ...tempMockState.frontendSettings, + auth_enabled: false + } + renderWithContext(, tempMockState) + + const promptButton = await screen.findByRole('button', { name: /prompt-button/i }) + + await userEvent.click(promptButton) + + await waitFor(() => { + expect( + screen.getByText( + /There was an error generating a response. Chat history can't be saved at this time. Please try again/i + ) + ).toBeInTheDocument() + }) + }) + + test('should handle historyGenerate API failure when chathistory item selected', async () => { + const mockError = new Error('API request failed') + mockCallHistoryGenerateApi.mockResolvedValueOnce({ ok: false, json: jest.fn().mockResolvedValueOnce(mockError) }) + + const tempMockState = { ...mockStateWithChatHistory } + tempMockState.frontendSettings = { + ...tempMockState.frontendSettings, + auth_enabled: false + } + renderWithContext(, tempMockState) + + const promptButton = await screen.findByRole('button', { name: /prompt-button/i }) + + await act(async () => { + await userEvent.click(promptButton) + }) + await waitFor(() => { + expect( + screen.getByText( + /There was an error generating a response. Chat history can't be saved at this time. Please try again/i + ) + ).toBeInTheDocument() + }) + }) + + test('Prompt tags on click handler when response rendering', async () => { + userEvent.setup() + + nonDelayedhistoryGenerateAPIcallMock() + const tempMockState = { ...mockState } + tempMockState.frontendSettings = { + ...tempMockState.frontendSettings, + auth_enabled: false + } + renderWithContext(, tempMockState) + const promptButton = screen.getByRole('button', { name: /prompt-button/i }) + + await userEvent.click(promptButton) + + await waitFor(async () => { + //expect(await screen.findByText(/response from AI!/i)).toBeInTheDocument(); + expect(screen.getByTestId('chat-message-container')).toBeInTheDocument() + }) + }) + + test('Should handle historyGenerate API returns incomplete JSON', async () => { + userEvent.setup() + + nonDelayedhistoryGenerateAPIcallMock('incompleteJSON') + const tempMockState = { ...mockState } + tempMockState.frontendSettings = { + ...tempMockState.frontendSettings, + auth_enabled: false + } + renderWithContext(, tempMockState) + const promptButton = screen.getByRole('button', { name: /prompt-button/i }) + + await userEvent.click(promptButton) + + await waitFor(async () => { + expect( + screen.getByText( + /An error occurred. Please try again. If the problem persists, please contact the site administrator/i + ) + ).toBeInTheDocument() + }) + }) + + test('Should handle historyGenerate API returns empty object or null', async () => { + userEvent.setup() + + nonDelayedhistoryGenerateAPIcallMock('no-result') + const tempMockState = { ...mockState } + tempMockState.frontendSettings = { + ...tempMockState.frontendSettings, + auth_enabled: false + } + renderWithContext(, tempMockState) + const promptButton = screen.getByRole('button', { name: /prompt-button/i }) + + await userEvent.click(promptButton) + + await waitFor(async () => { + expect( + screen.getByText(/There was an error generating a response. Chat history can't be saved at this time./i) + ).toBeInTheDocument() + }) + }) + + test('Should render if conversation API return context along with content', async () => { + userEvent.setup() + + historyGenerateAPIcallMock() + const tempMockState = { ...mockState } + tempMockState.frontendSettings = { + ...tempMockState.frontendSettings, + auth_enabled: false + } + renderWithContext(, tempMockState) + const promptButton = screen.getByRole('button', { name: /prompt-button/i }) + + userEvent.click(promptButton) + + await waitFor(() => { + expect(screen.getByText(/response from AI content/i)).toBeInTheDocument() + expect(screen.getByText(/response from AI context/i)).toBeInTheDocument() + }) + }) + + test('Should handle onShowCitation method when citation button click', async () => { + userEvent.setup() + + nonDelayedhistoryGenerateAPIcallMock() + const tempMockState = { ...mockState } + tempMockState.frontendSettings = { + ...tempMockState.frontendSettings, + auth_enabled: false + } + renderWithContext(, tempMockState) + const promptButton = screen.getByRole('button', { name: /prompt-button/i }) + + await userEvent.click(promptButton) + + await waitFor(() => { + //expect(screen.getByText(/response from AI!/i)).toBeInTheDocument(); + expect(screen.getByTestId('chat-message-container')).toBeInTheDocument() + }) + + const mockCitationBtn = await screen.findByRole('button', { name: /citation-btn/i }) + + await act(async () => { + await userEvent.click(mockCitationBtn) + }) + + await waitFor(async () => { + expect(await screen.findByTestId('citationPanel')).toBeInTheDocument() + }) + }) + + test('Should open citation URL in new window onclick of URL button', async () => { + userEvent.setup() + + nonDelayedhistoryGenerateAPIcallMock() + const tempMockState = { ...mockState } + tempMockState.frontendSettings = { + ...tempMockState.frontendSettings, + auth_enabled: false + } + renderWithContext(, tempMockState) + const promptButton = screen.getByRole('button', { name: /prompt-button/i }) + + await userEvent.click(promptButton) + + await waitFor(() => { + //expect(screen.getByText(/response from AI!/i)).toBeInTheDocument(); + expect(screen.getByTestId('chat-message-container')).toBeInTheDocument() + }) + + const mockCitationBtn = await screen.findByRole('button', { name: /citation-btn/i }) + + await act(async () => { + await userEvent.click(mockCitationBtn) + }) + + await waitFor(async () => { + expect(await screen.findByTestId('citationPanel')).toBeInTheDocument() + }) + const URLEle = await screen.findByRole('button', { name: /bobURL/i }) + + await userEvent.click(URLEle) + await waitFor(() => { + expect(window.open).toHaveBeenCalledWith(citationObj.url, '_blank') + }) + }) + + test('Should be clear the chat on Clear Button Click ', async () => { + userEvent.setup() + nonDelayedhistoryGenerateAPIcallMock() + ;(historyClear as jest.Mock).mockResolvedValueOnce({ ok: true }) + const tempMockState = { + ...mockState, + currentChat: { + id: 'ebe3ee4d-2a7c-4a31-bca3-2ccc14d7b5db', + title: 'Inquiry on Data Presentation', + messages: [ + { + id: 'd5811d9f-9f0f-d6c8-61a8-3e25f2df7b51', + role: 'user', + content: 'test data', + date: '2024-10-08T13:17:36.495Z' + }, + { + role: 'assistant', + content: 'I cannot answer this question from the data available. Please rephrase or add more details.', + id: 'c53d6702-9ca0-404a-9306-726f19ee80ba', + date: '2024-10-08T13:18:57.083Z' + } + ], + date: '2024-10-08T13:17:40.827540' + } + } + tempMockState.frontendSettings = { + ...tempMockState.frontendSettings, + auth_enabled: false + } + renderWithContext(, tempMockState) + + await waitFor(() => { + expect(screen.getByTestId('chat-message-container')).toBeInTheDocument() + }) + + const clearBtn = screen.getByRole('button', { name: /clear chat button/i }) + //const clearBtn = screen.getByTestId("clearChatBtn"); + + await act(() => { + fireEvent.click(clearBtn) + }) + }) + + test('Should open error dialog when handle historyClear failure ', async () => { + userEvent.setup() + nonDelayedhistoryGenerateAPIcallMock() + ;(historyClear as jest.Mock).mockResolvedValueOnce({ ok: false }) + const tempMockState = { + ...mockState, + currentChat: { + id: 'ebe3ee4d-2a7c-4a31-bca3-2ccc14d7b5db', + title: 'Inquiry on Data Presentation', + messages: [ + { + id: 'd5811d9f-9f0f-d6c8-61a8-3e25f2df7b51', + role: 'user', + content: 'test data', + date: '2024-10-08T13:17:36.495Z' + }, + { + role: 'assistant', + content: 'I cannot answer this question from the data available. Please rephrase or add more details.', + id: 'c53d6702-9ca0-404a-9306-726f19ee80ba', + date: '2024-10-08T13:18:57.083Z' + } + ], + date: '2024-10-08T13:17:40.827540' + } + } + tempMockState.frontendSettings = { + ...tempMockState.frontendSettings, + auth_enabled: false + } + renderWithContext(, tempMockState) + + await waitFor(() => { + expect(screen.getByTestId('chat-message-container')).toBeInTheDocument() + }) + + const clearBtn = screen.getByRole('button', { name: /clear chat button/i }) + //const clearBtn = screen.getByTestId("clearChatBtn"); + + await act(async () => { + await userEvent.click(clearBtn) + }) + + await waitFor(async () => { + expect(await screen.findByText(/Error clearing current chat/i)).toBeInTheDocument() + expect( + await screen.findByText(/Please try again. If the problem persists, please contact the site administrator./i) + ).toBeInTheDocument() + }) + }) + + test('Should able to close error dialog when error dialog close button click ', async () => { + userEvent.setup() + nonDelayedhistoryGenerateAPIcallMock() + ;(historyClear as jest.Mock).mockResolvedValueOnce({ ok: false }) + const tempMockState = { + ...mockState, + currentChat: { + id: 'ebe3ee4d-2a7c-4a31-bca3-2ccc14d7b5db', + title: 'Inquiry on Data Presentation', + messages: [ + { + id: 'd5811d9f-9f0f-d6c8-61a8-3e25f2df7b51', + role: 'user', + content: 'test data', + date: '2024-10-08T13:17:36.495Z' + }, + { + role: 'assistant', + content: 'I cannot answer this question from the data available. Please rephrase or add more details.', + id: 'c53d6702-9ca0-404a-9306-726f19ee80ba', + date: '2024-10-08T13:18:57.083Z' + } + ], + date: '2024-10-08T13:17:40.827540' + } + } + tempMockState.frontendSettings = { + ...tempMockState.frontendSettings, + auth_enabled: false + } + renderWithContext(, tempMockState) + + await waitFor(() => { + expect(screen.getByTestId('chat-message-container')).toBeInTheDocument() + }) + + const clearBtn = screen.getByRole('button', { name: /clear chat button/i }) + + await act(async () => { + await userEvent.click(clearBtn) + }) + + await waitFor(async () => { + expect(await screen.findByText(/Error clearing current chat/i)).toBeInTheDocument() + expect( + await screen.findByText(/Please try again. If the problem persists, please contact the site administrator./i) + ).toBeInTheDocument() + }) + const dialogCloseBtnEle = screen.getByRole('button', { name: 'Close' }) + await act(async () => { + await userEvent.click(dialogCloseBtnEle) + }) + + await waitFor( + () => { + expect(screen.queryByText('Error clearing current chat')).not.toBeInTheDocument() + }, + { timeout: 500 } + ) + }) + + test('Should be clear the chat on Start new chat button click ', async () => { + userEvent.setup() + nonDelayedhistoryGenerateAPIcallMock() + const tempMockState = { ...mockState } + tempMockState.frontendSettings = { + ...tempMockState.frontendSettings, + auth_enabled: false + } + renderWithContext(, tempMockState) + const promptButton = screen.getByRole('button', { name: /prompt-button/i }) + + userEvent.click(promptButton) + + await waitFor(() => { + expect(screen.getByTestId('chat-message-container')).toBeInTheDocument() + expect(screen.getByText(/response from AI!/i)).toBeInTheDocument() + }) + + const startnewBtn = screen.getByRole('button', { name: /start a new chat button/i }) + + await act(() => { + fireEvent.click(startnewBtn) + }) + await waitFor(() => { + expect(screen.queryByTestId('chat-message-container')).not.toBeInTheDocument() + expect(screen.getByText('Start chatting')).toBeInTheDocument() + }) + }) + + test('Should render existing chat messages', async () => { + userEvent.setup() + nonDelayedhistoryGenerateAPIcallMock() + + historyUpdateApi.mockResolvedValueOnce({ ok: true }) + const tempMockState = { ...mockStateWithChatHistory } + tempMockState.frontendSettings = { + ...tempMockState.frontendSettings, + auth_enabled: false + } + renderWithContext(, tempMockState) + const promptButton = screen.getByRole('button', { name: /prompt-button/i }) + + await act(() => { + fireEvent.click(promptButton) + }) + + await waitFor(() => { + expect(screen.getByTestId('chat-message-container')).toBeInTheDocument() + }) + }) + + test('Should handle historyUpdate API return ok as false', async () => { + nonDelayedhistoryGenerateAPIcallMock() + + historyUpdateApi.mockResolvedValueOnce({ ok: false }) + const tempMockState = { ...mockStateWithChatHistory } + + tempMockState.frontendSettings = { + ...tempMockState.frontendSettings, + + auth_enabled: false + } + renderWithContext(, tempMockState) + const promptButton = screen.getByRole('button', { name: /prompt-button/i }) + + await act(() => { + fireEvent.click(promptButton) + }) + + await waitFor(async () => { + expect( + await screen.findByText( + /An error occurred. Answers can't be saved at this time. If the problem persists, please contact the site administrator./i + ) + ).toBeInTheDocument() + }) + }) + + test('Should handle historyUpdate API failure', async () => { + userEvent.setup() + nonDelayedhistoryGenerateAPIcallMock() + + historyUpdateApi.mockRejectedValueOnce(new Error('historyUpdate API Error')) + const tempMockState = { ...mockStateWithChatHistory } + + tempMockState.frontendSettings = { + ...tempMockState.frontendSettings, + + auth_enabled: false + } + renderWithContext(, tempMockState) + const promptButton = screen.getByRole('button', { name: /prompt-button/i }) + + await userEvent.click(promptButton) + + await waitFor(async () => { + const mockError = new Error('historyUpdate API Error') + expect(console.error).toHaveBeenCalledWith('Error: ', mockError) + }) + }) + + test('Should handled when selected chat item not exists in chat history', async () => { + userEvent.setup() + nonDelayedhistoryGenerateAPIcallMock() + + historyUpdateApi.mockResolvedValueOnce({ ok: true }) + const tempMockState = { ...mockStateWithChatHistory } + tempMockState.currentChat = { + id: 'eaedb3b5-d21b-4d02-86c0-524e9b8cacb6', + title: 'Summarize Alexander Harrington previous meetings', + date: '2024-10-08T10:25:11.970412', + messages: [ + { + id: '55bf73d8-2a07-4709-a214-073aab7af3f0', + role: 'user', + date: '2024-10-08T10:25:13.314496', + content: 'Summarize Alexander Harrington previous meetings' + } + ] + } + tempMockState.frontendSettings = { + ...tempMockState.frontendSettings, + auth_enabled: false + } + renderWithContext(, tempMockState) + const promptButton = screen.getByRole('button', { name: /prompt-button/i }) + + await act(() => { + fireEvent.click(promptButton) + }) + + await waitFor(() => { + const mockError = 'Conversation not found.' + expect(console.error).toHaveBeenCalledWith(mockError) + }) + }) + + test('Should handle other than (CosmosDBStatus.Working & CosmosDBStatus.NotConfigured) and ChatHistoryLoadingState.Fail', async () => { + userEvent.setup() + nonDelayedhistoryGenerateAPIcallMock() + + const tempMockState = { ...mockState } + tempMockState.isCosmosDBAvailable = { + ...tempMockState.isCosmosDBAvailable, + status: CosmosDBStatus.NotWorking + } + tempMockState.chatHistoryLoadingState = ChatHistoryLoadingState.Fail + tempMockState.frontendSettings = { + ...tempMockState.frontendSettings, + auth_enabled: false + } + renderWithContext(, tempMockState) + + await waitFor(() => { + expect(screen.getByText(/Chat history is not enabled/i)).toBeInTheDocument() + const er = CosmosDBStatus.NotWorking + '. Please contact the site administrator.' + expect(screen.getByText(er)).toBeInTheDocument() + }) + }) + + // re look into this + test('Should able perform action(onSend) form Question input component', async () => { + userEvent.setup() + nonDelayedhistoryGenerateAPIcallMock() + historyUpdateApi.mockResolvedValueOnce({ ok: true }) + const tempMockState = { ...mockState } + tempMockState.frontendSettings = { + ...tempMockState.frontendSettings, + auth_enabled: false + } + renderWithContext(, tempMockState) + const questionInputtButton = screen.getByRole('button', { name: /question-input/i }) + + await act(async () => { + await userEvent.click(questionInputtButton) + }) + + await waitFor(() => { + expect(screen.getByTestId('chat-message-container')).toBeInTheDocument() + expect(screen.getByText(/response from AI!/i)).toBeInTheDocument() + }) + }) + + test('Should able perform action(onSend) form Question input component with existing history item', async () => { + userEvent.setup() + historyGenerateAPIcallMock() + historyUpdateApi.mockResolvedValueOnce({ ok: true }) + const tempMockState = { ...mockStateWithChatHistory } + tempMockState.frontendSettings = { + ...tempMockState.frontendSettings, + auth_enabled: false + } + renderWithContext(, tempMockState) + const questionInputtButton = screen.getByRole('button', { name: /question-input/i }) + + await act(async () => { + await userEvent.click(questionInputtButton) + }) + + await waitFor(() => { + expect(screen.getByTestId('chat-message-container')).toBeInTheDocument() + expect(screen.getByText(/response from AI content!/i)).toBeInTheDocument() + }) + }) + + // For cosmosDB is false + test('Should able perform action(onSend) form Question input component if consmosDB false', async () => { + userEvent.setup() + conversationApiCallMock() + historyUpdateApi.mockResolvedValueOnce({ ok: true }) + const tempMockState = { ...mockState } + tempMockState.isCosmosDBAvailable.cosmosDB = false + tempMockState.frontendSettings = { + ...tempMockState.frontendSettings, + auth_enabled: false + } + renderWithContext(, tempMockState) + const questionInputtButton = screen.getByRole('button', { name: /question-input/i }) + + await act(async () => { + await userEvent.click(questionInputtButton) + }) + + await waitFor(async () => { + expect(screen.getByTestId('chat-message-container')).toBeInTheDocument() + expect(await screen.findByText(/response from AI!/i)).toBeInTheDocument() + }) + }) + + test('Should able perform action(onSend) form Question input component if consmosDB false', async () => { + userEvent.setup() + conversationApiCallMock('chat-item-selected') + historyUpdateApi.mockResolvedValueOnce({ ok: true }) + const tempMockState = { ...mockStateWithChatHistory } + tempMockState.isCosmosDBAvailable.cosmosDB = false + tempMockState.frontendSettings = { + ...tempMockState.frontendSettings, + auth_enabled: false + } + renderWithContext(, tempMockState) + const questionInputtButton = screen.getByRole('button', { name: /question-input/i }) + + await userEvent.click(questionInputtButton) + + await waitFor(async () => { + expect(screen.getByTestId('chat-message-container')).toBeInTheDocument() + //expect(await screen.findByText(/response from AI content!/i)).toBeInTheDocument(); + }) + }) + + test('Should handle : If conversaton is not there/equal to the current selected chat', async () => { + userEvent.setup() + conversationApiCallMock() + historyUpdateApi.mockResolvedValueOnce({ ok: true }) + const tempMockState = { ...mockState } + tempMockState.isCosmosDBAvailable.cosmosDB = false + tempMockState.frontendSettings = { + ...tempMockState.frontendSettings, + auth_enabled: false + } + renderWithContext(, tempMockState) + const questionInputtButton = screen.getByRole('button', { name: /question-dummy/i }) + + await userEvent.click(questionInputtButton) + + await waitFor(async () => { + expect(console.error).toHaveBeenCalledWith('Conversation not found.') + expect(screen.queryByTestId('chat-message-container')).not.toBeInTheDocument() + }) + }) + + test('Should handle : if conversationApiCallMock API return error object L(221-223)', async () => { + userEvent.setup() + conversationApiCallMock('error-result') + historyUpdateApi.mockResolvedValueOnce({ ok: true }) + const tempMockState = { ...mockState } + tempMockState.isCosmosDBAvailable.cosmosDB = false + tempMockState.frontendSettings = { + ...tempMockState.frontendSettings, + auth_enabled: false + } + renderWithContext(, tempMockState) + const questionInputtButton = screen.getByRole('button', { name: /question-input/i }) + + await userEvent.click(questionInputtButton) + + await waitFor(async () => { + expect(screen.getByText(/error API result/i)).toBeInTheDocument() + }) + }) + + test('Should handle : if conversationApiCallMock API return error string ', async () => { + userEvent.setup() + conversationApiCallMock('error-string-result') + historyUpdateApi.mockResolvedValueOnce({ ok: true }) + const tempMockState = { ...mockState } + tempMockState.isCosmosDBAvailable.cosmosDB = false + tempMockState.frontendSettings = { + ...tempMockState.frontendSettings, + auth_enabled: false + } + renderWithContext(, tempMockState) + const questionInputtButton = screen.getByRole('button', { name: /question-input/i }) + + await userEvent.click(questionInputtButton) + + await waitFor(async () => { + expect(screen.getByText(/error API result/i)).toBeInTheDocument() + }) + }) + + test('Should handle : if conversationApiCallMock API return in-complete response L(233)', async () => { + userEvent.setup() + const consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(() => {}) + conversationApiCallMock('incomplete-result') + historyUpdateApi.mockResolvedValueOnce({ ok: true }) + const tempMockState = { ...mockState } + tempMockState.isCosmosDBAvailable.cosmosDB = false + tempMockState.frontendSettings = { + ...tempMockState.frontendSettings, + auth_enabled: false + } + renderWithContext(, tempMockState) + const questionInputtButton = screen.getByRole('button', { name: /question-input/i }) + + await userEvent.click(questionInputtButton) + + await waitFor(async () => { + expect(consoleLogSpy).toHaveBeenCalledWith('Incomplete message. Continuing...') + }) + consoleLogSpy.mockRestore() + }) + + test('Should handle : if conversationApiCallMock API failed', async () => { + userEvent.setup() + mockCallConversationApi.mockRejectedValueOnce(new Error('API Error')) + historyUpdateApi.mockResolvedValueOnce({ ok: true }) + const tempMockState = { ...mockState } + tempMockState.isCosmosDBAvailable.cosmosDB = false + tempMockState.frontendSettings = { + ...tempMockState.frontendSettings, + auth_enabled: false + } + renderWithContext(, tempMockState) + const questionInputtButton = screen.getByRole('button', { name: /question-input/i }) + + await userEvent.click(questionInputtButton) + + await waitFor(async () => { + expect( + screen.getByText( + /An error occurred. Please try again. If the problem persists, please contact the site administrator./i + ) + ).toBeInTheDocument() + }) + }) +}) diff --git a/ClientAdvisor/App/frontend/src/pages/chat/Chat.tsx b/ClientAdvisor/App/frontend/src/pages/chat/Chat.tsx index b39e1560b..5daac4d70 100644 --- a/ClientAdvisor/App/frontend/src/pages/chat/Chat.tsx +++ b/ClientAdvisor/App/frontend/src/pages/chat/Chat.tsx @@ -1,41 +1,40 @@ import { useRef, useState, useEffect, useContext, useLayoutEffect } from 'react' -import { CommandBarButton, IconButton, Dialog, DialogType, Stack } from '@fluentui/react' -import { SquareRegular, ShieldLockRegular, ErrorCircleRegular } from '@fluentui/react-icons' +import { CommandBarButton, Dialog, DialogType, Stack } from '@fluentui/react' +import { SquareRegular } from '@fluentui/react-icons' -import ReactMarkdown from 'react-markdown' -import remarkGfm from 'remark-gfm' -import rehypeRaw from 'rehype-raw' import uuid from 'react-uuid' import { isEmpty } from 'lodash' -import DOMPurify from 'dompurify' import styles from './Chat.module.css' import TeamAvatar from '../../assets/TeamAvatar.svg' -import { XSSAllowTags } from '../../constants/xssAllowTags' import { - ChatMessage, - ConversationRequest, - conversationApi, - Citation, - ToolMessageContent, - ChatResponse, getUserInfo, - Conversation, - historyGenerate, historyUpdate, historyClear, + historyGenerate, + conversationApi, + ChatMessage, + Citation, ChatHistoryLoadingState, CosmosDBStatus, - ErrorMessage + ErrorMessage, + ConversationRequest, + ChatResponse, + Conversation } from '../../api' -import { Answer } from '../../components/Answer' + import { QuestionInput } from '../../components/QuestionInput' import { ChatHistoryPanel } from '../../components/ChatHistory/ChatHistoryPanel' import { AppStateContext } from '../../state/AppProvider' import { useBoolean } from '@fluentui/react-hooks' import { PromptsSection, PromptType } from '../../components/PromptsSection/PromptsSection' +import { parseErrorMessage } from '../../helpers/helpers' +import { AuthNotConfigure } from './Components/AuthNotConfigure' +import { ChatMessageContainer } from './Components/ChatMessageContainer' +import { CitationPanel } from './Components/CitationPanel' + const enum messageStatus { NotRunning = 'Not Running', Processing = 'Processing', @@ -58,6 +57,8 @@ const Chat = (props: any) => { const [hideErrorDialog, { toggle: toggleErrorDialog }] = useBoolean(true) const [errorMsg, setErrorMsg] = useState() + const [finalMessages, setFinalMessages] = useState([]) + const errorDialogContentProps = { type: DialogType.close, title: errorMsg?.title, @@ -284,7 +285,7 @@ const Chat = (props: any) => { id: uuid(), role: 'user', content: question, - date: new Date().toISOString(), + date: new Date().toISOString() } //api call params set here (generate) @@ -383,7 +384,9 @@ const Chat = (props: any) => { }) } runningText = '' - } else if (result.error) { + } else { + result.error = "There was an error generating a response. Chat history can't be saved at this time." + console.error('Error : ', result.error) throw Error(result.error) } } catch (e) { @@ -504,6 +507,12 @@ const Chat = (props: any) => { return abortController.abort() } + useEffect(() => { + if (JSON.stringify(finalMessages) != JSON.stringify(messages)) { + setFinalMessages(messages) + } + }, [messages]) + const clearChat = async () => { setClearingChat(true) if (appStateContext?.state.currentChat?.id && appStateContext?.state.isCosmosDBAvailable.cosmosDB) { @@ -528,63 +537,8 @@ const Chat = (props: any) => { setClearingChat(false) } - const tryGetRaiPrettyError = (errorMessage: string) => { - try { - // Using a regex to extract the JSON part that contains "innererror" - const match = errorMessage.match(/'innererror': ({.*})\}\}/) - if (match) { - // Replacing single quotes with double quotes and converting Python-like booleans to JSON booleans - const fixedJson = match[1] - .replace(/'/g, '"') - .replace(/\bTrue\b/g, 'true') - .replace(/\bFalse\b/g, 'false') - const innerErrorJson = JSON.parse(fixedJson) - let reason = '' - // Check if jailbreak content filter is the reason of the error - const jailbreak = innerErrorJson.content_filter_result.jailbreak - if (jailbreak.filtered === true) { - reason = 'Jailbreak' - } - - // Returning the prettified error message - if (reason !== '') { - return ( - 'The prompt was filtered due to triggering Azure OpenAI’s content filtering system.\n' + - 'Reason: This prompt contains content flagged as ' + - reason + - '\n\n' + - 'Please modify your prompt and retry. Learn more: https://go.microsoft.com/fwlink/?linkid=2198766' - ) - } - } - } catch (e) { - console.error('Failed to parse the error:', e) - } - return errorMessage - } - - const parseErrorMessage = (errorMessage: string) => { - let errorCodeMessage = errorMessage.substring(0, errorMessage.indexOf('-') + 1) - const innerErrorCue = "{\\'error\\': {\\'message\\': " - if (errorMessage.includes(innerErrorCue)) { - try { - let innerErrorString = errorMessage.substring(errorMessage.indexOf(innerErrorCue)) - if (innerErrorString.endsWith("'}}")) { - innerErrorString = innerErrorString.substring(0, innerErrorString.length - 3) - } - innerErrorString = innerErrorString.replaceAll("\\'", "'") - let newErrorMessage = errorCodeMessage + ' ' + innerErrorString - errorMessage = newErrorMessage - } catch (e) { - console.error('Error parsing inner error message: ', e) - } - } - - return tryGetRaiPrettyError(errorMessage) - } - const newChat = () => { - props.setIsVisible(true); + props.setIsVisible(true) setProcessMessages(messageStatus.Processing) setMessages([]) setIsCitationPanelOpen(false) @@ -667,9 +621,9 @@ const Chat = (props: any) => { }, [AUTH_ENABLED]) useLayoutEffect(() => { - const element = document.getElementById("chatMessagesContainer")!; - if(element){ - element.scroll({ top: element.scrollHeight, behavior: 'smooth' }); + const element = document.getElementById('chatMessagesContainer')! + if (element) { + element.scroll({ top: element.scrollHeight, behavior: 'smooth' }) } }, [showLoadingMessage, processMessages]) @@ -684,18 +638,6 @@ const Chat = (props: any) => { } } - const parseCitationFromMessage = (message: ChatMessage) => { - if (message?.role && message?.role === 'tool') { - try { - const toolMessage = JSON.parse(message.content) as ToolMessageContent - return toolMessage.citations - } catch { - return [] - } - } - return [] - } - const disabledButton = () => { return ( isLoading || @@ -714,36 +656,11 @@ const Chat = (props: any) => { : makeApiRequestWithoutCosmosDB(question, conversationId) } } - + return (
{showAuthMessage ? ( - - -

Authentication Not Configured

-

- This app does not have authentication configured. Please add an identity provider by finding your app in the{' '} - - Azure Portal - - and following{' '} - - these instructions - - . -

-

- Authentication configuration takes a few minutes to apply. -

-

- If you deployed in the last 10 minutes, please wait and reload the page after 10 minutes. -

-
+ ) : (
@@ -751,53 +668,15 @@ const Chat = (props: any) => {

{ui?.chat_title}

-

{ui?.chat_description}

+

{ui?.chat_description}

) : ( -
- {messages.map((answer, index) => ( - <> - {answer.role === 'user' ? ( -
-
{answer.content}
-
- ) : answer.role === 'assistant' ? ( -
- onShowCitation(c)} - /> -
- ) : answer.role === ERROR ? ( -
- - - Error - - {answer.content} -
- ) : null} - - ))} - {showLoadingMessage && ( - <> -
- null} - /> -
- - )} -
+ )} @@ -900,46 +779,16 @@ const Chat = (props: any) => {
{/* Citation Panel */} {messages && messages.length > 0 && isCitationPanelOpen && activeCitation && ( - - - - Citations - - setIsCitationPanelOpen(false)} - /> - -
onViewSource(activeCitation)}> - {activeCitation.title} -
-
- -
-
+ )} {appStateContext?.state.isChatHistoryOpen && - appStateContext?.state.isCosmosDBAvailable?.status !== CosmosDBStatus.NotConfigured && } + appStateContext?.state.isCosmosDBAvailable?.status !== CosmosDBStatus.NotConfigured && ( + + )}
)}
diff --git a/ClientAdvisor/App/frontend/src/pages/chat/Components/AuthNotConfigure.test.tsx b/ClientAdvisor/App/frontend/src/pages/chat/Components/AuthNotConfigure.test.tsx new file mode 100644 index 000000000..a47a1e4d3 --- /dev/null +++ b/ClientAdvisor/App/frontend/src/pages/chat/Components/AuthNotConfigure.test.tsx @@ -0,0 +1,49 @@ +import React from 'react' +import { render, screen } from '@testing-library/react' +import '@testing-library/jest-dom' +import { AuthNotConfigure } from './AuthNotConfigure' +import styles from '../Chat.module.css' + +// Mock the Fluent UI icons +jest.mock('@fluentui/react-icons', () => ({ + ShieldLockRegular: () =>
+})) + +describe('AuthNotConfigure Component', () => { + it('renders without crashing', () => { + render() + + // Check that the icon is rendered + const icon = screen.getByTestId('shield-lock-icon') + expect(icon).toBeInTheDocument() + + // Check that the titles and subtitles are rendered + expect(screen.getByText('Authentication Not Configured')).toBeInTheDocument() + expect(screen.getByText(/This app does not have authentication configured./)).toBeInTheDocument() + + // Check the strong text is rendered + expect(screen.getByText('Authentication configuration takes a few minutes to apply.')).toBeInTheDocument() + expect(screen.getByText(/please wait and reload the page after 10 minutes/i)).toBeInTheDocument() + }) + + it('renders the Azure portal and instructions links with correct href', () => { + render() + + // Check the Azure Portal link + const azurePortalLink = screen.getByText('Azure Portal') + expect(azurePortalLink).toBeInTheDocument() + expect(azurePortalLink).toHaveAttribute('href', 'https://portal.azure.com/') + expect(azurePortalLink).toHaveAttribute('target', '_blank') + + // Check the instructions link + const instructionsLink = screen.getByText('these instructions') + expect(instructionsLink).toBeInTheDocument() + expect(instructionsLink).toHaveAttribute( + 'href', + 'https://learn.microsoft.com/en-us/azure/app-service/scenario-secure-app-authentication-app-service#3-configure-authentication-and-authorization' + ) + expect(instructionsLink).toHaveAttribute('target', '_blank') + }) + + +}) diff --git a/ClientAdvisor/App/frontend/src/pages/chat/Components/AuthNotConfigure.tsx b/ClientAdvisor/App/frontend/src/pages/chat/Components/AuthNotConfigure.tsx new file mode 100644 index 000000000..ac5151182 --- /dev/null +++ b/ClientAdvisor/App/frontend/src/pages/chat/Components/AuthNotConfigure.tsx @@ -0,0 +1,36 @@ +import React from 'react' +import { Stack } from '@fluentui/react' +import { ShieldLockRegular } from '@fluentui/react-icons' + +import styles from '../Chat.module.css' + +export const AuthNotConfigure = ()=>{ + return ( + + +

Authentication Not Configured

+

+ This app does not have authentication configured. Please add an identity provider by finding your app in the{' '} + + Azure Portal + + and following{' '} + + these instructions + + . +

+

+ Authentication configuration takes a few minutes to apply. +

+

+ If you deployed in the last 10 minutes, please wait and reload the page after 10 minutes. +

+
+ ) +} \ No newline at end of file diff --git a/ClientAdvisor/App/frontend/src/pages/chat/Components/ChatMessageContainer.test.tsx b/ClientAdvisor/App/frontend/src/pages/chat/Components/ChatMessageContainer.test.tsx new file mode 100644 index 000000000..bb470c29f --- /dev/null +++ b/ClientAdvisor/App/frontend/src/pages/chat/Components/ChatMessageContainer.test.tsx @@ -0,0 +1,178 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { ChatMessageContainer } from './ChatMessageContainer'; +import { ChatMessage, Citation } from '../../../api/models'; +import { Answer } from '../../../components/Answer'; + +jest.mock('../../../components/Answer', () => ({ + Answer: jest.fn((props: any) =>
+

{props.answer.answer}

+ Mock Answer Component + {props.answer.answer == 'Generating answer...' ? + : + + } + +
) +})); + +const mockOnShowCitation = jest.fn(); + +describe('ChatMessageContainer', () => { + + beforeEach(() => { + global.fetch = jest.fn(); + jest.spyOn(console, 'error').mockImplementation(() => { }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + + + const userMessage: ChatMessage = { + role: 'user', + content: 'User message', + id: '1', + feedback: undefined, + date: new Date().toDateString() + }; + + const assistantMessage: ChatMessage = { + role: 'assistant', + content: 'Assistant message', + id: '2', + feedback: undefined, + date: new Date().toDateString() + }; + + const errorMessage: ChatMessage = { + role: 'error', + content: 'Error message', + id: '3', + feedback: undefined, + date: new Date().toDateString() + }; + + it('renders user and assistant messages correctly', () => { + render( + + ); + + // Check if user message is displayed + expect(screen.getByText('User message')).toBeInTheDocument(); + + // Check if assistant message is displayed via Answer component + expect(screen.getByText('Mock Answer Component')).toBeInTheDocument(); + expect(Answer).toHaveBeenCalledWith( + expect.objectContaining({ + answer: { + answer: 'Assistant message', + citations: [], // No citations since this is the first message + message_id: '2', + feedback: undefined + } + }), + {} + ); + }); + + it('renders an error message correctly', () => { + render( + + ); + + // Check if error message is displayed with the error icon + expect(screen.getByText('Error')).toBeInTheDocument(); + expect(screen.getByText('Error message')).toBeInTheDocument(); + }); + + it('displays the loading message when showLoadingMessage is true', () => { + render( + + ); + // Check if the loading message is displayed via Answer component + expect(screen.getByText('Generating answer...')).toBeInTheDocument(); + }); + + it('applies correct margin when loading is true', () => { + const { container } = render( + + ); + + // Verify the margin is applied correctly when loading is true + const chatMessagesContainer = container.querySelector('#chatMessagesContainer'); + expect(chatMessagesContainer).toHaveStyle('margin-bottom: 40px'); + }); + + it('applies correct margin when loading is false', () => { + const { container } = render( + + ); + + // Verify the margin is applied correctly when loading is false + const chatMessagesContainer = container.querySelector('#chatMessagesContainer'); + expect(chatMessagesContainer).toHaveStyle('margin-bottom: 0px'); + }); + + + it('calls onShowCitation when a citation is clicked', () => { + render( + + ); + + // Simulate a citation click + const citationButton = screen.getByText('Mock Citation'); + fireEvent.click(citationButton); + + // Check if onShowCitation is called with the correct argument + expect(mockOnShowCitation).toHaveBeenCalledWith({ title: 'Test Citation' }); + }); + + it('does not call onShowCitation when citation click is a no-op', () => { + render( + + ); + // Simulate a citation click + const citationButton = screen.getByRole('button', {name : 'Mock Citation Loading'}); + fireEvent.click(citationButton); + + // Check if onShowCitation is NOT called + expect(mockOnShowCitation).not.toHaveBeenCalled(); + }); +}); diff --git a/ClientAdvisor/App/frontend/src/pages/chat/Components/ChatMessageContainer.tsx b/ClientAdvisor/App/frontend/src/pages/chat/Components/ChatMessageContainer.tsx new file mode 100644 index 000000000..1210e8b38 --- /dev/null +++ b/ClientAdvisor/App/frontend/src/pages/chat/Components/ChatMessageContainer.tsx @@ -0,0 +1,65 @@ +import { useRef, useState, useEffect, useContext, useLayoutEffect } from 'react' +import styles from '../Chat.module.css'; +import { Answer } from '../../../components/Answer'; +import {parseCitationFromMessage } from '../../../helpers/helpers'; +import { Stack } from '@fluentui/react' +import { ErrorCircleRegular } from '@fluentui/react-icons' +import {Citation , ChatMessage} from '../../../api/models'; + +interface ChatMessageContainerProps { + messages: ChatMessage[]; + isLoading: boolean; + showLoadingMessage: boolean; + onShowCitation: (citation: Citation) => void; + } + +export const ChatMessageContainer = (props : ChatMessageContainerProps)=>{ + const [ASSISTANT, TOOL, ERROR] = ['assistant', 'tool', 'error'] + + return ( +
+ {props.messages.map((answer : any, index : number) => ( + <> + {answer.role === 'user' ? ( +
+
{answer.content}
+
+ ) : answer.role === 'assistant' ? ( +
+ props.onShowCitation(c)} + /> +
+ ) : answer.role === ERROR ? ( +
+ + + Error + + {answer.content} +
+ ) : null} + + ))} + {props.showLoadingMessage && ( + <> +
+ null} + /> +
+ + )} +
+ ) +} \ No newline at end of file diff --git a/ClientAdvisor/App/frontend/src/pages/chat/Components/CitationPanel.test.tsx b/ClientAdvisor/App/frontend/src/pages/chat/Components/CitationPanel.test.tsx new file mode 100644 index 000000000..4e14edf65 --- /dev/null +++ b/ClientAdvisor/App/frontend/src/pages/chat/Components/CitationPanel.test.tsx @@ -0,0 +1,133 @@ +// CitationPanel.test.tsx +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { CitationPanel } from './CitationPanel'; +import { Citation } from '../../../api/models'; + + +jest.mock('remark-gfm', () => jest.fn()); +jest.mock('rehype-raw', () => jest.fn()); + + + +const mockCitation = { + id: '123', + title: 'Sample Citation', + content: 'This is a sample citation content.', + url: 'https://example.com/sample-citation', + filepath: "path", + metadata: "", + chunk_id: "", + reindex_id: "" + +}; + +describe('CitationPanel', () => { + const mockIsCitationPanelOpen = jest.fn(); + const mockOnViewSource = jest.fn(); + + beforeEach(() => { + // Reset mocks before each test + mockIsCitationPanelOpen.mockClear(); + mockOnViewSource.mockClear(); + }); + + test('renders CitationPanel with citation title and content', () => { + render( + + ); + + // Check if title is rendered + expect(screen.getByRole('heading', { name: /Sample Citation/i })).toBeInTheDocument(); + + // Check if content is rendered + //expect(screen.getByText(/This is a sample citation content/i)).toBeInTheDocument(); + }); + + test('calls IsCitationPanelOpen with false when close button is clicked', () => { + render( + + ); + + const closeButton = screen.getByRole('button', { name: /Close citations panel/i }); + fireEvent.click(closeButton); + + expect(mockIsCitationPanelOpen).toHaveBeenCalledWith(false); + }); + + test('calls onViewSource with citation when title is clicked', () => { + render( + + ); + + const title = screen.getByRole('heading', { name: /Sample Citation/i }); + fireEvent.click(title); + + expect(mockOnViewSource).toHaveBeenCalledWith(mockCitation); + }); + + test('renders the title correctly and sets the correct title attribute for non-blob URL', () => { + render( + + ); + + const titleElement = screen.getByRole('heading', { name: /Sample Citation/i }); + + // Ensure the title is rendered + expect(titleElement).toBeInTheDocument(); + + // Ensure the title attribute is set to the URL since it's not a blob URL + expect(titleElement).toHaveAttribute('title', 'https://example.com/sample-citation'); + + // Trigger the onClick event and ensure onViewSource is called with the correct citation + fireEvent.click(titleElement); + expect(mockOnViewSource).toHaveBeenCalledWith(mockCitation); + }); + + test('renders the title correctly and sets the title attribute to the citation title for blob URL', () => { + + const mockCitationWithBlobUrl: Citation = { + ...mockCitation, + title: 'Test Citation with Blob URL', + url: 'https://blob.core.example.com/resource', + content: '', + }; + render( + + ); + + + const titleElement = screen.getByRole('heading', { name: /Test Citation with Blob URL/i }); + + // Ensure the title is rendered + expect(titleElement).toBeInTheDocument(); + + // Ensure the title attribute is set to the citation title since the URL contains "blob.core" + expect(titleElement).toHaveAttribute('title', 'Test Citation with Blob URL'); + + // Trigger the onClick event and ensure onViewSource is called with the correct citation + fireEvent.click(titleElement); + expect(mockOnViewSource).toHaveBeenCalledWith(mockCitationWithBlobUrl); + }); + +}); diff --git a/ClientAdvisor/App/frontend/src/pages/chat/Components/CitationPanel.tsx b/ClientAdvisor/App/frontend/src/pages/chat/Components/CitationPanel.tsx new file mode 100644 index 000000000..e0f10e170 --- /dev/null +++ b/ClientAdvisor/App/frontend/src/pages/chat/Components/CitationPanel.tsx @@ -0,0 +1,54 @@ +import { Stack, IconButton } from '@fluentui/react'; +import ReactMarkdown from 'react-markdown'; +import DOMPurify from 'dompurify'; +import remarkGfm from 'remark-gfm'; +import rehypeRaw from 'rehype-raw'; +import { XSSAllowTags } from '../../../constants/xssAllowTags'; +import styles from '../Chat.module.css'; + +import {Citation} from '../../../api/models' + +interface CitationPanelProps { + activeCitation: Citation; + IsCitationPanelOpen: (isOpen: boolean) => void; + onViewSource: (citation: Citation) => void; +} + +export const CitationPanel: React.FC = ({ activeCitation, IsCitationPanelOpen, onViewSource }) => { + return ( + + + + Citations + + IsCitationPanelOpen(false)} + /> + +
onViewSource(activeCitation)}> + {activeCitation.title} +
+
+ +
+
+ ); +}; diff --git a/ClientAdvisor/App/frontend/src/pages/layout/Layout.module.css b/ClientAdvisor/App/frontend/src/pages/layout/Layout.module.css index abcbbfab1..754ef9795 100644 --- a/ClientAdvisor/App/frontend/src/pages/layout/Layout.module.css +++ b/ClientAdvisor/App/frontend/src/pages/layout/Layout.module.css @@ -179,6 +179,7 @@ height: 100%; */ display: flex; flex-direction: column; + padding-top : 10px ; } .pivotContainer > div { @@ -316,4 +317,11 @@ background-color: Window; color: WindowText; } + + .selectedName{ + border-radius:25px; + border: 2px solid WindowText; + background-color: Window; + color: WindowText; + } } \ No newline at end of file diff --git a/ClientAdvisor/App/frontend/src/pages/layout/Layout.test.tsx b/ClientAdvisor/App/frontend/src/pages/layout/Layout.test.tsx new file mode 100644 index 000000000..78f19c9a6 --- /dev/null +++ b/ClientAdvisor/App/frontend/src/pages/layout/Layout.test.tsx @@ -0,0 +1,644 @@ +import { render, screen, fireEvent, waitFor, act } from '@testing-library/react' +import { MemoryRouter } from 'react-router-dom' +import { Dialog } from '@fluentui/react' +import { getpbi, getUserInfo } from '../../api/api' +import { AppStateContext } from '../../state/AppProvider' +import Layout from './Layout' +import Cards from '../../components/Cards/Cards' +//import { renderWithContext } from '../../test/test.utils' +import { HistoryButton } from '../../components/common/Button' +import { CodeJsRectangle16Filled } from '@fluentui/react-icons' + +// Create the Mocks + +jest.mock('remark-gfm', () => () => {}) +jest.mock('rehype-raw', () => () => {}) +jest.mock('react-uuid', () => () => {}) + +const mockUsers = { + ClientId: '1', + ClientName: 'Client 1', + NextMeeting: 'Test Meeting 1', + NextMeetingTime: '10:00', + AssetValue: 10000, + LastMeeting: 'Last Meeting 1', + ClientSummary: 'Summary for User One', + chartUrl: '' +} + +jest.mock('../../components/Cards/Cards', () => { + return jest.fn((props: any) => ( +
props.onCardClick(mockUsers)}> + Mocked Card Component +
+ )) +}) + +jest.mock('../chat/Chat', () => { + const Chat = () =>
Mocked Chat Component
+ return Chat +}) + +jest.mock('../../api/api', () => ({ + getpbi: jest.fn(), + getUsers: jest.fn(), + getUserInfo: jest.fn() +})) + +const mockClipboard = { + writeText: jest.fn().mockResolvedValue(Promise.resolve()) +} + +const mockDispatch = jest.fn() + +const renderComponent = (appState: any) => { + return render( + + + + + + ) +} + +describe('Layout Component', () => { + beforeAll(() => { + Object.defineProperty(navigator, 'clipboard', { + value: mockClipboard, + writable: true + }) + global.fetch = mockDispatch + jest.spyOn(console, 'error').mockImplementation(() => {}) + }) + + afterEach(() => { + jest.clearAllMocks() + }) + + //-------// + + // Test--Start // + + test('renders layout with welcome message', async () => { + ;(getpbi as jest.Mock).mockResolvedValue('https://mock-pbi-url.com') + ;(getUserInfo as jest.Mock).mockResolvedValue([{ user_claims: [{ typ: 'name', val: 'Test User' }] }]) + + const appState = { + isChatHistoryOpen: false, + frontendSettings: { + ui: { logo: 'test-logo.svg', title: 'Test App', show_share_button: true } + }, + isCosmosDBAvailable: { cosmosDB: false, status: 'Available' }, + isLoader: false, + chatHistoryLoadingState: 'idle', + chatHistory: [], + filteredChatHistory: [], + currentChat: null, + error: null, + activeUserId: null + } + + renderComponent(appState) + + await waitFor(() => { + expect(screen.getByText(/Welcome Back, Test User/i)).toBeInTheDocument() + expect(screen.getByText(/Welcome Back, Test User/i)).toBeVisible() + }) + }) + + test('fetches user info', async () => { + ;(getpbi as jest.Mock).mockResolvedValue('https://mock-pbi-url.com') + ;(getUserInfo as jest.Mock).mockResolvedValue([{ user_claims: [{ typ: 'name', val: 'Test User' }] }]) + + const appState = { + isChatHistoryOpen: false, + frontendSettings: { + ui: { logo: 'test-logo.svg', title: 'Test App', show_share_button: true } + }, + isCosmosDBAvailable: { cosmosDB: false, status: 'Available' }, + isLoader: false, + chatHistoryLoadingState: 'idle', + chatHistory: [], + filteredChatHistory: [], + currentChat: null, + error: null, + activeUserId: null + } + + renderComponent(appState) + + expect(getpbi).toHaveBeenCalledTimes(1) + expect(getUserInfo).toHaveBeenCalledTimes(1) + }) + + test('updates share label on window resize', async () => { + const appState = { + isChatHistoryOpen: false, + frontendSettings: { + ui: { logo: 'test-logo.svg', title: 'Test App', show_share_button: true } + }, + isCosmosDBAvailable: { status: 'Available' }, + isLoader: false, + chatHistoryLoadingState: 'idle', + chatHistory: [], + filteredChatHistory: [], + currentChat: null, + error: null, + activeUserId: null + } + + renderComponent(appState) + + expect(screen.getByText('Share')).toBeInTheDocument() + + window.innerWidth = 400 + window.dispatchEvent(new Event('resize')) + + await waitFor(() => { + expect(screen.queryByText('Share')).toBeNull() + }) + + window.innerWidth = 480 + window.dispatchEvent(new Event('resize')) + + await waitFor(() => { + expect(screen.queryByText('Share')).not.toBeNull() + }) + + window.innerWidth = 600 + window.dispatchEvent(new Event('resize')) + + await waitFor(() => { + expect(screen.getByText('Share')).toBeInTheDocument() + }) + }) + + test('updates Hide chat history', async () => { + const appState = { + isChatHistoryOpen: true, + frontendSettings: { + ui: { logo: 'test-logo.svg', title: 'Test App', show_share_button: true } + }, + isCosmosDBAvailable: { status: 'Available' }, + isLoader: false, + chatHistoryLoadingState: 'idle', + chatHistory: [], + filteredChatHistory: [], + currentChat: null, + error: null, + activeUserId: null + } + + renderComponent(appState) + + expect(screen.getByText('Hide chat history')).toBeInTheDocument() + }) + + test('check the website tile', async () => { + const appState = { + isChatHistoryOpen: false, + frontendSettings: { + ui: { logo: 'test-logo.svg', title: 'Test App title', show_share_button: true } + }, + isCosmosDBAvailable: { status: 'Available' }, + isLoader: false, + chatHistoryLoadingState: 'idle', + chatHistory: [], + filteredChatHistory: [], + currentChat: null, + error: null, + activeUserId: null + } + + renderComponent(appState) + + expect(screen.getByText('Test App title')).toBeVisible() + expect(screen.getByText('Test App title')).not.toBe('{{ title }}') + expect(screen.getByText('Test App title')).not.toBeNaN() + }) + + test('check the welcomeCard', async () => { + const appState = { + isChatHistoryOpen: false, + frontendSettings: { + ui: { logo: 'test-logo.svg', title: 'Test App title', show_share_button: true } + }, + isCosmosDBAvailable: { status: 'Available' }, + isLoader: false, + chatHistoryLoadingState: 'idle', + chatHistory: [], + filteredChatHistory: [], + currentChat: null, + error: null, + activeUserId: null + } + + renderComponent(appState) + + expect(screen.getByText('Select a client')).toBeVisible() + expect( + screen.getByText( + 'You can ask questions about their portfolio details and previous conversations or view their profile.' + ) + ).toBeVisible() + }) + + test('check the Loader', async () => { + ;(getpbi as jest.Mock).mockResolvedValue('https://mock-pbi-url.com') + ;(getUserInfo as jest.Mock).mockResolvedValue([{ user_claims: [{ typ: 'name', val: 'Test User' }] }]) + + const appState = { + isChatHistoryOpen: false, + frontendSettings: { + ui: { logo: 'test-logo.svg', title: 'Test App', show_share_button: true } + }, + isCosmosDBAvailable: { status: 'Available' }, + isLoader: true, + chatHistoryLoadingState: 'idle', + chatHistory: [], + filteredChatHistory: [], + currentChat: null, + error: null, + activeUserId: null + } + + renderComponent(appState) + + expect(screen.getByText('Please wait.....!')).toBeVisible() + }) + + test('copies the URL when Share button is clicked', async () => { + ;(getpbi as jest.Mock).mockResolvedValue('https://mock-pbi-url.com') + ;(getUserInfo as jest.Mock).mockResolvedValue([{ user_claims: [{ typ: 'name', val: 'Test User' }] }]) + + const appState = { + isChatHistoryOpen: false, + frontendSettings: { + ui: { logo: 'test-logo.svg', title: 'Test App', show_share_button: true } + }, + isCosmosDBAvailable: { status: 'Available' }, + isLoader: false, + chatHistoryLoadingState: 'idle', + chatHistory: [], + filteredChatHistory: [], + currentChat: null, + error: null, + activeUserId: null + } + + renderComponent(appState) + + const shareButton = screen.getByText('Share') + expect(shareButton).toBeInTheDocument() + fireEvent.click(shareButton) + + const copyButton = await screen.findByRole('button', { name: /copy/i }) + fireEvent.click(copyButton) + + await waitFor(() => { + expect(mockClipboard.writeText).toHaveBeenCalledWith(window.location.href) + expect(mockClipboard.writeText).toHaveBeenCalledTimes(1) + }) + }) + + test('should log error when getpbi fails', async () => { + ;(getpbi as jest.Mock).mockRejectedValueOnce(new Error('API Error')) + const consoleErrorMock = jest.spyOn(console, 'error').mockImplementation(() => {}) + + const appState = { + isChatHistoryOpen: false, + frontendSettings: { + ui: { logo: 'test-logo.svg', title: 'Test App', show_share_button: true } + }, + isCosmosDBAvailable: { status: 'Available' }, + isLoader: false, + chatHistoryLoadingState: 'idle', + chatHistory: [], + filteredChatHistory: [], + currentChat: null, + error: null, + activeUserId: null + } + + renderComponent(appState) + + await waitFor(() => { + expect(getpbi).toHaveBeenCalled() + }) + + const mockError = new Error('API Error') + + expect(console.error).toHaveBeenCalledWith('Error fetching PBI url:', mockError) + + consoleErrorMock.mockRestore() + }) + + test('should log error when getUderInfo fails', async () => { + ;(getUserInfo as jest.Mock).mockRejectedValue(new Error()) + + const consoleErrorMock = jest.spyOn(console, 'error').mockImplementation(() => {}) + + const appState = { + isChatHistoryOpen: false, + frontendSettings: { + ui: { logo: 'test-logo.svg', title: 'Test App', show_share_button: true } + }, + isCosmosDBAvailable: { status: 'Available' }, + isLoader: false, + chatHistoryLoadingState: 'idle', + chatHistory: [], + filteredChatHistory: [], + currentChat: null, + error: null, + activeUserId: null + } + + renderComponent(appState) + + await waitFor(() => { + expect(getUserInfo).toHaveBeenCalled() + }) + + const mockError = new Error() + + expect(console.error).toHaveBeenCalledWith('Error fetching user info: ', mockError) + + consoleErrorMock.mockRestore() + }) + + test('handles card click and updates context with selected user', async () => { + ;(getpbi as jest.Mock).mockResolvedValue('https://mock-pbi-url.com') + ;(getUserInfo as jest.Mock).mockResolvedValue([{ user_claims: [{ typ: 'name', val: 'Test User' }] }]) + + const appState = { + isChatHistoryOpen: false, + frontendSettings: { + ui: { logo: 'test-logo.svg', title: 'Test App', show_share_button: true } + }, + isCosmosDBAvailable: { status: 'CosmosDB is configured and working' }, + isLoader: false, + chatHistoryLoadingState: 'idle', + chatHistory: [], + filteredChatHistory: [], + currentChat: null, + error: null, + activeUserId: null + } + + renderComponent(appState) + + const userCard = screen.getByTestId('user-card-mock') + + await act(() => { + fireEvent.click(userCard) + }) + + expect(screen.getByText(/Client 1/i)).toBeVisible() + }) + + test('test Dialog', async () => { + ;(getpbi as jest.Mock).mockResolvedValue('https://mock-pbi-url.com') + ;(getUserInfo as jest.Mock).mockResolvedValue([{ user_claims: [{ typ: 'name', val: 'Test User' }] }]) + + const appState = { + isChatHistoryOpen: false, + frontendSettings: { + ui: { logo: 'test-logo.svg', title: 'Test App', show_share_button: true } + }, + isCosmosDBAvailable: { status: 'CosmosDB is configured and working' }, + isLoader: false, + chatHistoryLoadingState: 'idle', + chatHistory: [], + filteredChatHistory: [], + currentChat: null, + error: null, + activeUserId: null + } + + renderComponent(appState) + + const MockShare = screen.getAllByRole('button')[1] + fireEvent.click(MockShare) + + const MockDilog = screen.getByLabelText('Close') + + await act(() => { + fireEvent.click(MockDilog) + }) + + expect(MockDilog).not.toBeVisible() + }) + + test('test History button', async () => { + ;(getpbi as jest.Mock).mockResolvedValue('https://mock-pbi-url.com') + ;(getUserInfo as jest.Mock).mockResolvedValue([{ user_claims: [{ typ: 'name', val: 'Test User' }] }]) + + const appState = { + isChatHistoryOpen: false, + frontendSettings: { + ui: { logo: 'test-logo.svg', title: 'Test App', show_share_button: true } + }, + isCosmosDBAvailable: { status: 'CosmosDB is configured and working' }, + isLoader: false, + chatHistoryLoadingState: 'idle', + chatHistory: [], + filteredChatHistory: [], + currentChat: null, + error: null, + activeUserId: null + } + + renderComponent(appState) + + const MockShare = screen.getByText('Show chat history') + + await act(() => { + fireEvent.click(MockShare) + }) + + expect(MockShare).not.toHaveTextContent('Hide chat history') + }) + + test('test Copy button', async () => { + ;(getpbi as jest.Mock).mockResolvedValue('https://mock-pbi-url.com') + ;(getUserInfo as jest.Mock).mockResolvedValue([{ user_claims: [{ typ: 'name', val: 'Test User' }] }]) + + const appState = { + isChatHistoryOpen: false, + frontendSettings: { + ui: { logo: 'test-logo.svg', title: 'Test App', show_share_button: true } + }, + isCosmosDBAvailable: { status: 'CosmosDB is configured and working' }, + isLoader: false, + chatHistoryLoadingState: 'idle', + chatHistory: [], + filteredChatHistory: [], + currentChat: null, + error: null, + activeUserId: null + } + + renderComponent(appState) + + const MockShare = screen.getAllByRole('button')[1] + fireEvent.click(MockShare) + + const CopyShare = screen.getByLabelText('Copy') + await act(() => { + fireEvent.keyDown(CopyShare, { key: 'Enter' }) + }) + + expect(CopyShare).not.toHaveTextContent('Copy') + }) + + test('test logo', () => { + ;(getpbi as jest.Mock).mockResolvedValue('https://mock-pbi-url.com') + ;(getUserInfo as jest.Mock).mockResolvedValue([{ user_claims: [{ typ: 'name', val: 'Test User' }] }]) + + const appState = { + isChatHistoryOpen: false, + frontendSettings: { + ui: { title: 'Test App', show_share_button: true } + }, + isCosmosDBAvailable: { status: 'CosmosDB is configured and working' }, + isLoader: false, + chatHistoryLoadingState: 'idle', + chatHistory: [], + filteredChatHistory: [], + currentChat: null, + error: null, + activeUserId: null + } + + renderComponent(appState) + + const img = screen.getByAltText('') + + expect(img).not.toHaveAttribute('src', 'test-logo.svg') + }) + + test('test getUserInfo', () => { + ;(getpbi as jest.Mock).mockResolvedValue('https://mock-pbi-url.com') + ;(getUserInfo as jest.Mock).mockResolvedValue([{ user_claims: [{ typ: 'nameinfo', val: 'Test User' }] }]) + + const appState = { + isChatHistoryOpen: false, + frontendSettings: { + ui: { logo: 'test-logo.svg', title: 'Test App', show_share_button: true } + }, + isCosmosDBAvailable: { status: 'CosmosDB is configured and working' }, + isLoader: false, + chatHistoryLoadingState: 'idle', + chatHistory: [], + filteredChatHistory: [], + currentChat: null, + error: null, + activeUserId: null + } + + renderComponent(appState) + + expect(screen.getByText(/Welcome Back,/i)).toBeInTheDocument() + expect(screen.getByText(/Welcome Back,/i)).toBeVisible() + }) + + test('test Spinner', async () => { + ;(getpbi as jest.Mock).mockResolvedValue('https://mock-pbi-url.com') + ;(getUserInfo as jest.Mock).mockResolvedValue([{ user_claims: [{ typ: 'name', val: 'Test User' }] }]) + + const appStatetrue = { + isChatHistoryOpen: false, + frontendSettings: { + ui: { logo: 'test-logo.svg', title: 'Test App', show_share_button: true } + }, + isCosmosDBAvailable: { status: 'CosmosDB is configured and working' }, + isLoader: true, + chatHistoryLoadingState: 'idle', + chatHistory: [], + filteredChatHistory: [], + currentChat: null, + error: null, + activeUserId: null + } + + renderComponent(appStatetrue) + + const spinner = screen.getByText('Please wait.....!') + + const appState = { + isChatHistoryOpen: false, + frontendSettings: { + ui: { logo: 'test-logo.svg', title: 'Test App', show_share_button: true } + }, + isCosmosDBAvailable: { status: 'CosmosDB is configured and working' }, + isLoader: undefined, + chatHistoryLoadingState: 'idle', + chatHistory: [], + filteredChatHistory: [], + currentChat: null, + error: null, + activeUserId: null + } + + renderComponent(appState) + + expect(spinner).toBeVisible() + }) + + test('test Span', async () => { + ;(getpbi as jest.Mock).mockResolvedValue('https://mock-pbi-url.com') + ;(getUserInfo as jest.Mock).mockResolvedValue([{ user_claims: [{ typ: 'name', val: 'Test User' }] }]) + const appState = { + isChatHistoryOpen: false, + frontendSettings: { + ui: { logo: 'test-logo.svg', title: 'Test App', show_share_button: true } + }, + isCosmosDBAvailable: { status: 'CosmosDB is configured and working' }, + isLoader: false, + chatHistoryLoadingState: 'idle', + chatHistory: [], + filteredChatHistory: [], + currentChat: null, + error: null, + activeUserId: null + } + renderComponent(appState) + const userCard = screen.getByTestId('user-card-mock') + await act(() => { + fireEvent.click(userCard) + }) + + expect(screen.getByText('Client 1')).toBeInTheDocument() + expect(screen.getByText('Client 1')).not.toBeNull() + }) + + test('test Copy button Condication', () => { + ;(getpbi as jest.Mock).mockResolvedValue('https://mock-pbi-url.com') + ;(getUserInfo as jest.Mock).mockResolvedValue([{ user_claims: [{ typ: 'name', val: 'Test User' }] }]) + + const appState = { + isChatHistoryOpen: false, + frontendSettings: { + ui: { logo: 'test-logo.svg', title: 'Test App', show_share_button: true } + }, + isCosmosDBAvailable: { status: 'CosmosDB is configured and working' }, + isLoader: false, + chatHistoryLoadingState: 'idle', + chatHistory: [], + filteredChatHistory: [], + currentChat: null, + error: null, + activeUserId: null + } + + renderComponent(appState) + + const MockShare = screen.getAllByRole('button')[1] + fireEvent.click(MockShare) + + const CopyShare = screen.getByLabelText('Copy') + fireEvent.keyDown(CopyShare, { key: 'E' }) + + expect(CopyShare).toHaveTextContent('Copy') + }) +}) diff --git a/ClientAdvisor/App/frontend/src/pages/layout/Layout.tsx b/ClientAdvisor/App/frontend/src/pages/layout/Layout.tsx index 7681c263f..60ddfba59 100644 --- a/ClientAdvisor/App/frontend/src/pages/layout/Layout.tsx +++ b/ClientAdvisor/App/frontend/src/pages/layout/Layout.tsx @@ -12,13 +12,11 @@ import Chat from '../chat/Chat' // Import the Chat component import { AppStateContext } from '../../state/AppProvider' import { getUserInfo, getpbi } from '../../api' import { User } from '../../types/User' -import TickIcon from '../../assets/TickIcon.svg' +import TickIcon from '../../assets/TickIcon.svg' import DismissIcon from '../../assets/Dismiss.svg' import welcomeIcon from '../../assets/welcomeIcon.png' -import styles from './Layout.module.css'; -import SpinnerComponent from '../../components/Spinner/Spinner'; - - +import styles from './Layout.module.css' +import { SpinnerComponent } from '../../components/Spinner/SpinnerComponent' const Layout = () => { // const [contentType, setContentType] = useState(null); @@ -38,7 +36,7 @@ const Layout = () => { const [name, setName] = useState('') const [pbiurl, setPbiUrl] = useState('') - const [isVisible, setIsVisible] = useState(false); + const [isVisible, setIsVisible] = useState(false) useEffect(() => { const fetchpbi = async () => { try { @@ -53,19 +51,25 @@ const Layout = () => { }, []) + const resetClientId= ()=>{ + appStateContext?.dispatch({ type: 'RESET_CLIENT_ID' }); + setSelectedUser(null); + setShowWelcomeCard(true); + } + const closePopup = () => { - setIsVisible(!isVisible); - }; + setIsVisible(!isVisible) + } useEffect(() => { if (isVisible) { const timer = setTimeout(() => { - setIsVisible(false); - }, 4000); // Popup will disappear after 3 seconds + setIsVisible(false) + }, 4000) // Popup will disappear after 3 seconds - return () => clearTimeout(timer); // Cleanup the timer on component unmount + return () => clearTimeout(timer) // Cleanup the timer on component unmount } - }, [isVisible]); + }, [isVisible]) const handleCardClick = (user: User) => { setSelectedUser(user) @@ -121,7 +125,6 @@ const Layout = () => { useEffect(() => { getUserInfo() .then(res => { - console.log('User info: ', res) const name: string = res[0].user_claims.find((claim: any) => claim.typ === 'name')?.val ?? '' setName(name) }) @@ -137,27 +140,31 @@ const Layout = () => { return (
- {isVisible && ( + {isVisible && (
-
- check markChat saved - close icon +
+ + check mark + + Chat saved + close icon +
+
+ Your chat history has been saved successfully!
-
Your chat history has been saved successfully!
-
- )} + )}
-

Upcoming meetings

+

Upcoming meetings

@@ -167,9 +174,9 @@ const Layout = () => { - -

{ui?.title}

- +
(e.key === 'Enter' || e.key === ' ' ? resetClientId() : null)} tabIndex={-1}> +

{ui?.title}

+
{appStateContext?.state.isCosmosDBAvailable?.status !== CosmosDBStatus.NotConfigured && ( @@ -212,9 +219,9 @@ const Layout = () => { {selectedUser ? selectedUser.ClientName : 'None'}
)} - + - + diff --git a/ClientAdvisor/App/frontend/src/state/AppProvider.tsx b/ClientAdvisor/App/frontend/src/state/AppProvider.tsx index d0166462d..051db7224 100644 --- a/ClientAdvisor/App/frontend/src/state/AppProvider.tsx +++ b/ClientAdvisor/App/frontend/src/state/AppProvider.tsx @@ -1,6 +1,14 @@ import React, { createContext, ReactNode, useEffect, useReducer } from 'react' +import { + frontendSettings, + historyEnsure, + historyList, + // UserSelectRequest + +} from '../api' + import { ChatHistoryLoadingState, Conversation, @@ -8,12 +16,9 @@ import { CosmosDBStatus, Feedback, FrontendSettings, - frontendSettings, - historyEnsure, - historyList, // UserSelectRequest -} from '../api' +} from '../api/models' import { appStateReducer } from './AppReducer' @@ -51,7 +56,8 @@ export type Action = | { type: 'GET_FEEDBACK_STATE'; payload: string } | { type: 'UPDATE_CLIENT_ID'; payload: string } | { type: 'SET_IS_REQUEST_INITIATED'; payload: boolean } - | { type: 'TOGGLE_LOADER' }; + | { type: 'TOGGLE_LOADER' } + | { type: 'RESET_CLIENT_ID'}; const initialState: AppState = { isChatHistoryOpen: false, diff --git a/ClientAdvisor/App/frontend/src/state/AppReducer.tsx b/ClientAdvisor/App/frontend/src/state/AppReducer.tsx index 21a126dab..03a778cc2 100644 --- a/ClientAdvisor/App/frontend/src/state/AppReducer.tsx +++ b/ClientAdvisor/App/frontend/src/state/AppReducer.tsx @@ -80,6 +80,8 @@ export const appStateReducer = (state: AppState, action: Action): AppState => { return {...state, isRequestInitiated : action.payload} case 'TOGGLE_LOADER': return {...state, isLoader : !state.isLoader} + case 'RESET_CLIENT_ID': + return {...state, clientId: ''} default: return state } diff --git a/ClientAdvisor/App/frontend/src/test/TestProvider.tsx b/ClientAdvisor/App/frontend/src/test/TestProvider.tsx new file mode 100644 index 000000000..97a65cf68 --- /dev/null +++ b/ClientAdvisor/App/frontend/src/test/TestProvider.tsx @@ -0,0 +1,26 @@ +// AppProvider.tsx +import React, { createContext, useReducer, ReactNode } from 'react'; +import { Conversation, ChatHistoryLoadingState } from '../api/models'; +// Define the AppState interface +export interface AppState { + chatHistory: Conversation[]; + isCosmosDBAvailable: { cosmosDB: boolean; status: string }; + isChatHistoryOpen: boolean; + filteredChatHistory: Conversation[]; + currentChat: Conversation | null; + frontendSettings: Record; + feedbackState: Record; + clientId: string; + isRequestInitiated: boolean; + isLoader: boolean; + chatHistoryLoadingState: ChatHistoryLoadingState; +} + +// Define the context +export const AppStateContext = createContext<{ + state: AppState; + dispatch: React.Dispatch; +}>({ + state: {} as AppState, + dispatch: () => {}, +}); diff --git a/ClientAdvisor/App/frontend/src/test/setupTests.ts b/ClientAdvisor/App/frontend/src/test/setupTests.ts new file mode 100644 index 000000000..3f517be72 --- /dev/null +++ b/ClientAdvisor/App/frontend/src/test/setupTests.ts @@ -0,0 +1,59 @@ +import '@testing-library/jest-dom'; // For jest-dom matchers like toBeInTheDocument + +import { initializeIcons } from '@fluentui/react/lib/Icons'; +initializeIcons(); + +import { server } from '../mocks/server'; + +// Establish API mocking before all tests +beforeAll(() => server.listen()); + +// Reset any request handlers that are declared in a test +afterEach(() => server.resetHandlers()); + +// Clean up after the tests are finished +afterAll(() => server.close()); + +// Mock IntersectionObserver +class IntersectionObserverMock { + callback: IntersectionObserverCallback; + options: IntersectionObserverInit; + + root: Element | null = null; // Required property + rootMargin: string = '0px'; // Required property + thresholds: number[] = [0]; // Required property + + constructor(callback: IntersectionObserverCallback, options: IntersectionObserverInit) { + this.callback = callback; + this.options = options; + } + + observe = jest.fn((target: Element) => { + // Simulate intersection with an observer instance + this.callback([{ isIntersecting: true }] as IntersectionObserverEntry[], this as IntersectionObserver); + }); + + unobserve = jest.fn(); + disconnect = jest.fn(); // Required method + takeRecords = jest.fn(); // Required method +} + +// Store the original IntersectionObserver +const originalIntersectionObserver = window.IntersectionObserver; + +beforeAll(() => { + window.IntersectionObserver = IntersectionObserverMock as any; +}); + +afterAll(() => { + // Restore the original IntersectionObserver + window.IntersectionObserver = originalIntersectionObserver; +}); + + + + + + + + diff --git a/ClientAdvisor/App/frontend/src/test/test.utils.tsx b/ClientAdvisor/App/frontend/src/test/test.utils.tsx new file mode 100644 index 000000000..f980523aa --- /dev/null +++ b/ClientAdvisor/App/frontend/src/test/test.utils.tsx @@ -0,0 +1,35 @@ +// test-utils.tsx +import React from 'react'; +import { render, RenderResult } from '@testing-library/react'; +import { AppStateContext } from '../state/AppProvider'; +import { Conversation, ChatHistoryLoadingState } from '../api/models'; +// Default mock state +const defaultMockState = { + chatHistory: [], + isCosmosDBAvailable: { cosmosDB: true, status: 'success' }, + isChatHistoryOpen: true, + filteredChatHistory: [], + currentChat: null, + frontendSettings: {}, + feedbackState: {}, + clientId: '', + isRequestInitiated: false, + isLoader: false, + chatHistoryLoadingState: ChatHistoryLoadingState.Loading, +}; + +// Create a custom render function +const renderWithContext = ( + component: React.ReactElement, + contextState = {} +): RenderResult => { + const state = { ...defaultMockState, ...contextState }; + return render( + + {component} + + ); +}; + +export * from '@testing-library/react'; +export { renderWithContext }; diff --git a/ClientAdvisor/App/frontend/tsconfig.json b/ClientAdvisor/App/frontend/tsconfig.json index f117a3d18..962fb6e49 100644 --- a/ClientAdvisor/App/frontend/tsconfig.json +++ b/ClientAdvisor/App/frontend/tsconfig.json @@ -5,7 +5,7 @@ "lib": ["DOM", "DOM.Iterable", "ESNext"], "allowJs": false, "skipLibCheck": true, - "esModuleInterop": false, + "esModuleInterop": true, "allowSyntheticDefaultImports": true, "strict": true, "forceConsistentCasingInFileNames": true, @@ -15,9 +15,16 @@ "isolatedModules": true, "noEmit": true, "jsx": "react-jsx", + "typeRoots": ["node_modules/@types"], + // "typeRoots": [ + // "./node_modules/@types" // Ensure Jest types are found + // ], "types": ["vite/client", "jest", "mocha", "node"], "noUnusedLocals": false }, - "include": ["src"], + "include": [ + "src", // Your source files + "testMock", // Include your mocks if necessary + ], "references": [{ "path": "./tsconfig.node.json" }] } diff --git a/ClientAdvisor/App/requirements-dev.txt b/ClientAdvisor/App/requirements-dev.txt index b4eac12d8..9c8cdf4f7 100644 --- a/ClientAdvisor/App/requirements-dev.txt +++ b/ClientAdvisor/App/requirements-dev.txt @@ -12,3 +12,6 @@ gunicorn==20.1.0 quart-session==3.0.0 pymssql==2.3.0 httpx==0.27.0 +flake8==7.1.1 +black==24.8.0 +autoflake==2.3.1 diff --git a/ClientAdvisor/App/requirements.txt b/ClientAdvisor/App/requirements.txt index a921be2a0..e97a6a961 100644 --- a/ClientAdvisor/App/requirements.txt +++ b/ClientAdvisor/App/requirements.txt @@ -12,3 +12,6 @@ gunicorn==20.1.0 quart-session==3.0.0 pymssql==2.3.0 httpx==0.27.0 +flake8==7.1.1 +black==24.8.0 +autoflake==2.3.1 diff --git a/ClientAdvisor/App/tools/data_collection.py b/ClientAdvisor/App/tools/data_collection.py index 901b8be20..13cbed260 100644 --- a/ClientAdvisor/App/tools/data_collection.py +++ b/ClientAdvisor/App/tools/data_collection.py @@ -2,34 +2,33 @@ import sys import asyncio import json +import app from dotenv import load_dotenv -#import the app.py module to gain access to the methods to construct payloads and -#call the API through the sdk +# import the app.py module to gain access to the methods to construct payloads and +# call the API through the sdk # Add parent directory to sys.path -sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) - -import app +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) -#function to enable loading of the .env file into the global variables of the app.py module -def load_env_into_module(module_name, prefix=''): +def load_env_into_module(module_name, prefix=""): load_dotenv() module = __import__(module_name) for key, value in os.environ.items(): if key.startswith(prefix): - setattr(module, key[len(prefix):], value) + setattr(module, key[len(prefix) :], value) + load_env_into_module("app") -#some settings required in app.py +# some settings required in app.py app.SHOULD_STREAM = False app.SHOULD_USE_DATA = app.should_use_data() -#format: +# format: """ [ { @@ -40,71 +39,65 @@ def load_env_into_module(module_name, prefix=''): generated_data_path = r"path/to/qa_input_file.json" -with open(generated_data_path, 'r') as file: +with open(generated_data_path, "r") as file: data = json.load(file) """ Process a list of q(and a) pairs outputting to a file as we go. """ -async def process(data: list, file): - for qa_pairs_obj in data: - qa_pairs = qa_pairs_obj["qa_pairs"] - for qa_pair in qa_pairs: - question = qa_pair["question"] - messages = [{"role":"user", "content":question}] - - print("processing question "+question) - - request = {"messages":messages, "id":"1"} - response = await app.complete_chat_request(request) - #print(json.dumps(response)) - - messages = response["choices"][0]["messages"] - - tool_message = None - assistant_message = None - - for message in messages: - if message["role"] == "tool": - tool_message = message["content"] - elif message["role"] == "assistant": - assistant_message = message["content"] - else: - raise ValueError("unknown message role") - - #construct data for ai studio evaluation +async def process(data: list, file): + for qa_pairs_obj in data: + qa_pairs = qa_pairs_obj["qa_pairs"] + for qa_pair in qa_pairs: + question = qa_pair["question"] + messages = [{"role": "user", "content": question}] - user_message = {"role":"user", "content":question} - assistant_message = {"role":"assistant", "content":assistant_message} + print("processing question " + question) - #prepare citations - citations = json.loads(tool_message) - assistant_message["context"] = citations + request = {"messages": messages, "id": "1"} - #create output - messages = [] - messages.append(user_message) - messages.append(assistant_message) + response = await app.complete_chat_request(request) - evaluation_data = {"messages":messages} + # print(json.dumps(response)) - #incrementally write out to the jsonl file - file.write(json.dumps(evaluation_data)+"\n") - file.flush() + messages = response["choices"][0]["messages"] + tool_message = None + assistant_message = None -evaluation_data_file_path = r"path/to/output_file.jsonl" + for message in messages: + if message["role"] == "tool": + tool_message = message["content"] + elif message["role"] == "assistant": + assistant_message = message["content"] + else: + raise ValueError("unknown message role") -with open(evaluation_data_file_path, "w") as file: - asyncio.run(process(data, file)) + # construct data for ai studio evaluation + user_message = {"role": "user", "content": question} + assistant_message = {"role": "assistant", "content": assistant_message} + # prepare citations + citations = json.loads(tool_message) + assistant_message["context"] = citations + # create output + messages = [] + messages.append(user_message) + messages.append(assistant_message) + evaluation_data = {"messages": messages} + # incrementally write out to the jsonl file + file.write(json.dumps(evaluation_data) + "\n") + file.flush() +evaluation_data_file_path = r"path/to/output_file.jsonl" +with open(evaluation_data_file_path, "w") as file: + asyncio.run(process(data, file)) diff --git a/ClientAdvisor/AzureFunction/function_app.py b/ClientAdvisor/AzureFunction/function_app.py index f9bfd8dc8..9f6368cdd 100644 --- a/ClientAdvisor/AzureFunction/function_app.py +++ b/ClientAdvisor/AzureFunction/function_app.py @@ -18,7 +18,6 @@ from semantic_kernel.functions.kernel_function_decorator import kernel_function from semantic_kernel.kernel import Kernel import pymssql - # Azure Function App app = func.FunctionApp(http_auth_level=func.AuthLevel.ANONYMOUS) @@ -40,7 +39,7 @@ def greeting(self, input: Annotated[str, "the question"]) -> Annotated[str, "The client = openai.AzureOpenAI( azure_endpoint=endpoint, api_key=api_key, - api_version="2023-09-01-preview" + api_version=api_version ) deployment = os.environ.get("AZURE_OPEN_AI_DEPLOYMENT_MODEL") try: @@ -75,7 +74,7 @@ def get_SQL_Response( client = openai.AzureOpenAI( azure_endpoint=endpoint, api_key=api_key, - api_version="2023-09-01-preview" + api_version=api_version ) deployment = os.environ.get("AZURE_OPEN_AI_DEPLOYMENT_MODEL") @@ -100,6 +99,17 @@ def get_SQL_Response( Do not include assets values unless asked for. Always use ClientId = {clientid} in the query filter. Always return client name in the query. + If a question involves date and time, always use FORMAT(YourDateTimeColumn, 'yyyy-MM-dd HH:mm:ss') in the query. + If asked, provide information about client meetings according to the requested timeframe: give details about upcoming meetings if asked for "next" or "upcoming" meetings, and provide details about past meetings if asked for "previous" or "last" meetings including the scheduled time and don't filter with "LIMIT 1" in the query. + If asked about the number of past meetings with this client, provide the count of records where the ConversationId is neither null nor an empty string and the EndTime is before the current date in the query. + If asked, provide information on the client's investment risk tolerance level in the query. + If asked, provide information on the client's portfolio performance in the query. + If asked, provide information about the client's top-performing investments in the query. + If asked, provide information about any recent changes in the client's investment allocations in the query. + If asked about the client's portfolio performance over the last quarter, calculate the total investment by summing the investment amounts where AssetDate is greater than or equal to the date from one quarter ago using DATEADD(QUARTER, -1, GETDATE()) in the query. + If asked about upcoming important dates or deadlines for the client, always ensure that StartTime is greater than the current date. Do not convert the formats of StartTime and EndTime and consistently provide the upcoming dates along with the scheduled times in the query. + To determine the asset value, sum the investment values for the most recent available date. If asked for the asset types in the portfolio and the present of each, provide a list of each asset type with its most recent investment value. + If the user inquires about asset on a specific date ,sum the investment values for the specific date avoid summing values from all dates prior to the requested date.If asked for the asset types in the portfolio and the value of each for specific date , provide a list of each asset type with specific date investment value avoid summing values from all dates prior to the requested date. Only return the generated sql query. do not return anything else''' try: @@ -152,13 +162,16 @@ def get_answers_from_calltranscripts( client = openai.AzureOpenAI( azure_endpoint= endpoint, #f"{endpoint}/openai/deployments/{deployment}/extensions", api_key=apikey, - api_version="2024-02-01" + api_version=api_version ) query = question - system_message = '''You are an assistant who provides wealth advisors with helpful information to prepare for client meetings. - You have access to the client’s meeting call transcripts. - You can use this information to answer questions about the clients''' + system_message = '''You are an assistant who provides wealth advisors with helpful information to prepare for client meetings and provide details on the call transcripts. + You have access to the client’s meetings and call transcripts + When asked about action items from previous meetings with the client, **ALWAYS provide information only for the most recent dates**. + Always return time in "HH:mm" format for the client in response. + If requested for call transcript(s), the response for each transcript should be summarized separately and Ensure all transcripts for the specified client are retrieved and format **must** follow as First Call Summary,Second Call Summary etc. + Your answer must **not** include any client identifiers or ids or numbers or ClientId in the final response.''' completion = client.chat.completions.create( model = deployment, @@ -182,7 +195,6 @@ def get_answers_from_calltranscripts( "parameters": { "endpoint": search_endpoint, "index_name": index_name, - "semantic_configuration": "default", "query_type": "vector_simple_hybrid", #"vector_semantic_hybrid" "fields_mapping": { "content_fields_separator": "\n", @@ -259,14 +271,20 @@ async def stream_openai_text(req: Request) -> StreamingResponse: settings.max_tokens = 800 settings.temperature = 0 + # Read the HTML file + with open("table.html", "r") as file: + html_content = file.read() + system_message = '''you are a helpful assistant to a wealth advisor. Do not answer any questions not related to wealth advisors queries. - If the client name and client id do not match, only return - Please only ask questions about the selected client or select another client to inquire about their details. do not return any other information. - Only use the client name returned from database in the response. + **If the client name in the question does not match the selected client's name**, always return: "Please ask questions only about the selected client." Do not provide any other information. + Always consider to give selected client full name only in response and do not use other example names also consider my client means currently selected client. If you cannot answer the question, always return - I cannot answer this question from the data available. Please rephrase or add more details. ** Remove any client identifiers or ids or numbers or ClientId in the final response. + Client name **must be** same as retrieved from database. ''' - + system_message += html_content + user_query = query.replace('?',' ') user_query_prompt = f'''{user_query}. Always send clientId as {user_query.split(':::')[-1]} ''' @@ -280,4 +298,4 @@ async def stream_openai_text(req: Request) -> StreamingResponse: settings=settings ) - return StreamingResponse(stream_processor(sk_response), media_type="text/event-stream") \ No newline at end of file + return StreamingResponse(stream_processor(sk_response), media_type="text/event-stream") diff --git a/ClientAdvisor/AzureFunction/table.html b/ClientAdvisor/AzureFunction/table.html new file mode 100644 index 000000000..51ded0bea --- /dev/null +++ b/ClientAdvisor/AzureFunction/table.html @@ -0,0 +1,11 @@ + + + + + + + + + + +
Header 1Header 2
Data 1Data 2
\ No newline at end of file diff --git a/ClientAdvisor/Deployment/bicep/core/database/cosmos/cosmos-role-assign.bicep b/ClientAdvisor/Deployment/bicep/core/database/cosmos/cosmos-role-assign.bicep new file mode 100644 index 000000000..3949efef0 --- /dev/null +++ b/ClientAdvisor/Deployment/bicep/core/database/cosmos/cosmos-role-assign.bicep @@ -0,0 +1,19 @@ +metadata description = 'Creates a SQL role assignment under an Azure Cosmos DB account.' +param accountName string + +param roleDefinitionId string +param principalId string = '' + +resource role 'Microsoft.DocumentDB/databaseAccounts/sqlRoleAssignments@2022-05-15' = { + parent: cosmos + name: guid(roleDefinitionId, principalId, cosmos.id) + properties: { + principalId: principalId + roleDefinitionId: roleDefinitionId + scope: cosmos.id + } +} + +resource cosmos 'Microsoft.DocumentDB/databaseAccounts@2022-08-15' existing = { + name: accountName +} diff --git a/ClientAdvisor/Deployment/bicep/core/database/cosmos/cosmos-sql-role-def.bicep b/ClientAdvisor/Deployment/bicep/core/database/cosmos/cosmos-sql-role-def.bicep new file mode 100644 index 000000000..778d6dc47 --- /dev/null +++ b/ClientAdvisor/Deployment/bicep/core/database/cosmos/cosmos-sql-role-def.bicep @@ -0,0 +1,30 @@ +metadata description = 'Creates a SQL role definition under an Azure Cosmos DB account.' +param accountName string + +resource roleDefinition 'Microsoft.DocumentDB/databaseAccounts/sqlRoleDefinitions@2022-08-15' = { + parent: cosmos + name: guid(cosmos.id, accountName, 'sql-role') + properties: { + assignableScopes: [ + cosmos.id + ] + permissions: [ + { + dataActions: [ + 'Microsoft.DocumentDB/databaseAccounts/readMetadata' + 'Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers/items/*' + 'Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers/*' + ] + notDataActions: [] + } + ] + roleName: 'Reader Writer' + type: 'CustomRole' + } +} + +resource cosmos 'Microsoft.DocumentDB/databaseAccounts@2022-08-15' existing = { + name: accountName +} + +output id string = roleDefinition.id diff --git a/ClientAdvisor/Deployment/bicep/deploy_cosmos_db.bicep b/ClientAdvisor/Deployment/bicep/core/database/cosmos/deploy_cosmos_db.bicep similarity index 90% rename from ClientAdvisor/Deployment/bicep/deploy_cosmos_db.bicep rename to ClientAdvisor/Deployment/bicep/core/database/cosmos/deploy_cosmos_db.bicep index d44abb711..3925eeaeb 100644 --- a/ClientAdvisor/Deployment/bicep/deploy_cosmos_db.bicep +++ b/ClientAdvisor/Deployment/bicep/core/database/cosmos/deploy_cosmos_db.bicep @@ -9,8 +9,6 @@ param accountName string = '${ solutionName }-cosmos' param databaseName string = 'db_conversation_history' param collectionName string = 'conversations' -param identity string - param containers array = [ { name: collectionName @@ -41,6 +39,7 @@ resource cosmos 'Microsoft.DocumentDB/databaseAccounts@2022-08-15' = { databaseAccountOfferType: 'Standard' enableAutomaticFailover: false enableMultipleWriteLocations: false + disableLocalAuth: true apiProperties: (kind == 'MongoDB') ? { serverVersion: '4.0' } : {} capabilities: [ { name: 'EnableServerless' } ] } @@ -69,12 +68,8 @@ resource database 'Microsoft.DocumentDB/databaseAccounts/sqlDatabases@2022-05-15 ] } -var cosmosAccountKey = cosmos.listKeys().primaryMasterKey -// #listKeys(cosmos.id, cosmos.apiVersion).primaryMasterKey - output cosmosOutput object = { cosmosAccountName: cosmos.name - cosmosAccountKey: cosmosAccountKey cosmosDatabaseName: databaseName cosmosContainerName: collectionName } diff --git a/ClientAdvisor/Deployment/bicep/deploy_app_service.bicep b/ClientAdvisor/Deployment/bicep/deploy_app_service.bicep index d2dbeb9a3..367c81d1c 100644 --- a/ClientAdvisor/Deployment/bicep/deploy_app_service.bicep +++ b/ClientAdvisor/Deployment/bicep/deploy_app_service.bicep @@ -6,11 +6,6 @@ targetScope = 'resourceGroup' @description('Solution Name') param solutionName string -@description('Solution Location') -param solutionLocation string - -param identity string - @description('Name of App Service plan') param HostingPlanName string = '${ solutionName }-app-service-plan' @@ -172,7 +167,7 @@ param VITE_POWERBI_EMBED_URL string = '' // var WebAppImageName = 'DOCKER|ncwaappcontainerreg1.azurecr.io/ncqaappimage:v1.0.0' -var WebAppImageName = 'DOCKER|bycwacontainerreg.azurecr.io/byc-wa-app:latest' +var WebAppImageName = 'DOCKER|bycwacontainerreg.azurecr.io/byc-wa-app:dev' resource HostingPlan 'Microsoft.Web/serverfarms@2020-06-01' = { name: HostingPlanName @@ -360,9 +355,6 @@ resource Website 'Microsoft.Web/sites@2020-06-01' = { {name: 'AZURE_COSMOSDB_ACCOUNT' value: AZURE_COSMOSDB_ACCOUNT } - {name: 'AZURE_COSMOSDB_ACCOUNT_KEY' - value: AZURE_COSMOSDB_ACCOUNT_KEY - } {name: 'AZURE_COSMOSDB_CONVERSATIONS_CONTAINER' value: AZURE_COSMOSDB_CONVERSATIONS_CONTAINER } @@ -406,3 +398,24 @@ resource ApplicationInsights 'Microsoft.Insights/components@2020-02-02' = { kind: 'web' } +module cosmosRoleDefinition 'core/database/cosmos/cosmos-sql-role-def.bicep' = { + name: 'cosmos-sql-role-definition' + params: { + accountName: AZURE_COSMOSDB_ACCOUNT + } + dependsOn: [ + Website + ] +} + +module cosmosUserRole 'core/database/cosmos/cosmos-role-assign.bicep' = { + name: 'cosmos-sql-user-role-${WebsiteName}' + params: { + accountName: AZURE_COSMOSDB_ACCOUNT + roleDefinitionId: cosmosRoleDefinition.outputs.id + principalId: Website.identity.principalId + } + dependsOn: [ + cosmosRoleDefinition + ] +} diff --git a/ClientAdvisor/Deployment/bicep/deploy_azure_function_script.bicep b/ClientAdvisor/Deployment/bicep/deploy_azure_function_script.bicep index cdda63957..2ad7ff55e 100644 --- a/ClientAdvisor/Deployment/bicep/deploy_azure_function_script.bicep +++ b/ClientAdvisor/Deployment/bicep/deploy_azure_function_script.bicep @@ -17,6 +17,7 @@ param sqlDbName string param sqlDbUser string @secure() param sqlDbPwd string +param functionAppVersion string resource deploy_azure_function 'Microsoft.Resources/deploymentScripts@2020-10-01' = { kind:'AzureCLI' @@ -31,7 +32,7 @@ resource deploy_azure_function 'Microsoft.Resources/deploymentScripts@2020-10-01 properties: { azCliVersion: '2.50.0' primaryScriptUri: '${baseUrl}Deployment/scripts/create_azure_functions.sh' // deploy-azure-synapse-pipelines.sh - arguments: '${solutionName} ${solutionLocation} ${resourceGroupName} ${baseUrl} ${azureOpenAIApiKey} ${azureOpenAIApiVersion} ${azureOpenAIEndpoint} ${azureSearchAdminKey} ${azureSearchServiceEndpoint} ${azureSearchIndex} ${sqlServerName} ${sqlDbName} ${sqlDbUser} ${sqlDbPwd}' // Specify any arguments for the script + arguments: '${solutionName} ${solutionLocation} ${resourceGroupName} ${baseUrl} ${azureOpenAIApiKey} ${azureOpenAIApiVersion} ${azureOpenAIEndpoint} ${azureSearchAdminKey} ${azureSearchServiceEndpoint} ${azureSearchIndex} ${sqlServerName} ${sqlDbName} ${sqlDbUser} ${sqlDbPwd} ${functionAppVersion}' // Specify any arguments for the script timeout: 'PT1H' // Specify the desired timeout duration retentionInterval: 'PT1H' // Specify the desired retention interval cleanupPreference:'OnSuccess' diff --git a/ClientAdvisor/Deployment/bicep/main.bicep b/ClientAdvisor/Deployment/bicep/main.bicep index cb99dc114..b17433a7f 100644 --- a/ClientAdvisor/Deployment/bicep/main.bicep +++ b/ClientAdvisor/Deployment/bicep/main.bicep @@ -17,7 +17,8 @@ var resourceGroupName = resourceGroup().name // var subscriptionId = subscription().subscriptionId var solutionLocation = resourceGroupLocation -var baseUrl = 'https://raw.githubusercontent.com/microsoft/Build-your-own-copilot-Solution-Accelerator/main/ClientAdvisor/' +var baseUrl = 'https://raw.githubusercontent.com/Roopan-Microsoft/psl-byo-main/main/ClientAdvisor/' +var functionAppversion = 'dev' // ========== Managed Identity ========== // module managedIdentityModule 'deploy_managed_identity.bicep' = { @@ -29,12 +30,11 @@ module managedIdentityModule 'deploy_managed_identity.bicep' = { scope: resourceGroup(resourceGroup().name) } -module cosmosDBModule 'deploy_cosmos_db.bicep' = { +module cosmosDBModule 'core/database/cosmos/deploy_cosmos_db.bicep' = { name: 'deploy_cosmos_db' params: { solutionName: solutionPrefix solutionLocation: cosmosLocation - identity:managedIdentityModule.outputs.managedIdentityOutput.objectId } scope: resourceGroup(resourceGroup().name) } @@ -120,6 +120,7 @@ module azureFunctions 'deploy_azure_function_script.bicep' = { sqlDbPwd:sqlDBModule.outputs.sqlDbOutput.sqlDbPwd identity:managedIdentityModule.outputs.managedIdentityOutput.id baseUrl:baseUrl + functionAppVersion: functionAppversion } dependsOn:[storageAccountModule] } @@ -195,9 +196,7 @@ module createIndex 'deploy_index_scripts.bicep' = { module appserviceModule 'deploy_app_service.bicep' = { name: 'deploy_app_service' params: { - identity:managedIdentityModule.outputs.managedIdentityOutput.id solutionName: solutionPrefix - solutionLocation: solutionLocation AzureSearchService:azSearchService.outputs.searchServiceOutput.searchServiceName AzureSearchIndex:'transcripts_index' AzureSearchKey:azSearchService.outputs.searchServiceOutput.searchServiceAdminKey @@ -235,7 +234,6 @@ module appserviceModule 'deploy_app_service.bicep' = { SQLDB_USERNAME:sqlDBModule.outputs.sqlDbOutput.sqlDbUser SQLDB_PASSWORD:sqlDBModule.outputs.sqlDbOutput.sqlDbPwd AZURE_COSMOSDB_ACCOUNT: cosmosDBModule.outputs.cosmosOutput.cosmosAccountName - AZURE_COSMOSDB_ACCOUNT_KEY: cosmosDBModule.outputs.cosmosOutput.cosmosAccountKey AZURE_COSMOSDB_CONVERSATIONS_CONTAINER: cosmosDBModule.outputs.cosmosOutput.cosmosContainerName AZURE_COSMOSDB_DATABASE: cosmosDBModule.outputs.cosmosOutput.cosmosDatabaseName AZURE_COSMOSDB_ENABLE_FEEDBACK: 'True' diff --git a/ClientAdvisor/Deployment/bicep/main.json b/ClientAdvisor/Deployment/bicep/main.json index dc3f5f855..79f563c62 100644 --- a/ClientAdvisor/Deployment/bicep/main.json +++ b/ClientAdvisor/Deployment/bicep/main.json @@ -4,8 +4,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.29.47.4906", - "templateHash": "13616077515444443649" + "version": "0.30.23.60470", + "templateHash": "4335964797327276348" } }, "parameters": { @@ -28,7 +28,8 @@ "resourceGroupLocation": "[resourceGroup().location]", "resourceGroupName": "[resourceGroup().name]", "solutionLocation": "[variables('resourceGroupLocation')]", - "baseUrl": "https://raw.githubusercontent.com/microsoft/Build-your-own-copilot-Solution-Accelerator/main/ClientAdvisor/" + "baseUrl": "https://raw.githubusercontent.com/Roopan-Microsoft/psl-byo-main/main/ClientAdvisor/", + "functionAppversion": "dev" }, "resources": [ { @@ -55,8 +56,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.29.47.4906", - "templateHash": "14160084237240395045" + "version": "0.30.23.60470", + "templateHash": "8775325455752085588" } }, "parameters": { @@ -136,9 +137,6 @@ }, "solutionLocation": { "value": "[parameters('cosmosLocation')]" - }, - "identity": { - "value": "[reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, resourceGroup().name), 'Microsoft.Resources/deployments', 'deploy_managed_identity'), '2022-09-01').outputs.managedIdentityOutput.value.objectId]" } }, "template": { @@ -147,8 +145,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.29.47.4906", - "templateHash": "17399517323120345417" + "version": "0.30.23.60470", + "templateHash": "2785991873795581274" } }, "parameters": { @@ -178,9 +176,6 @@ "type": "string", "defaultValue": "conversations" }, - "identity": { - "type": "string" - }, "containers": { "type": "array", "defaultValue": [ @@ -250,6 +245,7 @@ "databaseAccountOfferType": "Standard", "enableAutomaticFailover": false, "enableMultipleWriteLocations": false, + "disableLocalAuth": true, "apiProperties": "[if(equals(parameters('kind'), 'MongoDB'), createObject('serverVersion', '4.0'), createObject())]", "capabilities": [ { @@ -277,17 +273,13 @@ "type": "object", "value": { "cosmosAccountName": "[parameters('accountName')]", - "cosmosAccountKey": "[listKeys(resourceId('Microsoft.DocumentDB/databaseAccounts', parameters('accountName')), '2022-08-15').primaryMasterKey]", "cosmosDatabaseName": "[parameters('databaseName')]", "cosmosContainerName": "[parameters('collectionName')]" } } } } - }, - "dependsOn": [ - "[extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, resourceGroup().name), 'Microsoft.Resources/deployments', 'deploy_managed_identity')]" - ] + } }, { "type": "Microsoft.Resources/deployments", @@ -316,8 +308,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.29.47.4906", - "templateHash": "16818958292648129851" + "version": "0.30.23.60470", + "templateHash": "6504191191293913024" } }, "parameters": { @@ -473,8 +465,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.29.47.4906", - "templateHash": "17750640431748386549" + "version": "0.30.23.60470", + "templateHash": "2160762555547388608" } }, "parameters": { @@ -631,8 +623,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.29.47.4906", - "templateHash": "14900700646237730459" + "version": "0.30.23.60470", + "templateHash": "7447797843632120632" } }, "parameters": { @@ -713,8 +705,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.29.47.4906", - "templateHash": "5512132473254602596" + "version": "0.30.23.60470", + "templateHash": "1208105245776066647" } }, "parameters": { @@ -801,8 +793,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.29.47.4906", - "templateHash": "18087275960613812283" + "version": "0.30.23.60470", + "templateHash": "7369022468994960259" } }, "parameters": { @@ -935,8 +927,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.29.47.4906", - "templateHash": "5446272928246139512" + "version": "0.30.23.60470", + "templateHash": "6415253687626078091" } }, "parameters": { @@ -1046,6 +1038,9 @@ }, "baseUrl": { "value": "[variables('baseUrl')]" + }, + "functionAppVersion": { + "value": "[variables('functionAppversion')]" } }, "template": { @@ -1054,8 +1049,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.29.47.4906", - "templateHash": "3863583258880925565" + "version": "0.30.23.60470", + "templateHash": "7374909295293677257" } }, "parameters": { @@ -1106,6 +1101,9 @@ }, "sqlDbPwd": { "type": "securestring" + }, + "functionAppVersion": { + "type": "string" } }, "resources": [ @@ -1124,7 +1122,7 @@ "properties": { "azCliVersion": "2.50.0", "primaryScriptUri": "[format('{0}Deployment/scripts/create_azure_functions.sh', parameters('baseUrl'))]", - "arguments": "[format('{0} {1} {2} {3} {4} {5} {6} {7} {8} {9} {10} {11} {12} {13}', parameters('solutionName'), parameters('solutionLocation'), parameters('resourceGroupName'), parameters('baseUrl'), parameters('azureOpenAIApiKey'), parameters('azureOpenAIApiVersion'), parameters('azureOpenAIEndpoint'), parameters('azureSearchAdminKey'), parameters('azureSearchServiceEndpoint'), parameters('azureSearchIndex'), parameters('sqlServerName'), parameters('sqlDbName'), parameters('sqlDbUser'), parameters('sqlDbPwd'))]", + "arguments": "[format('{0} {1} {2} {3} {4} {5} {6} {7} {8} {9} {10} {11} {12} {13} {14}', parameters('solutionName'), parameters('solutionLocation'), parameters('resourceGroupName'), parameters('baseUrl'), parameters('azureOpenAIApiKey'), parameters('azureOpenAIApiVersion'), parameters('azureOpenAIEndpoint'), parameters('azureSearchAdminKey'), parameters('azureSearchServiceEndpoint'), parameters('azureSearchIndex'), parameters('sqlServerName'), parameters('sqlDbName'), parameters('sqlDbUser'), parameters('sqlDbPwd'), parameters('functionAppVersion'))]", "timeout": "PT1H", "retentionInterval": "PT1H", "cleanupPreference": "OnSuccess" @@ -1164,8 +1162,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.29.47.4906", - "templateHash": "17656221802073055142" + "version": "0.30.23.60470", + "templateHash": "12066912124638066503" } }, "parameters": { @@ -1281,8 +1279,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.29.47.4906", - "templateHash": "15721711795451128385" + "version": "0.30.23.60470", + "templateHash": "16513457206594462189" } }, "parameters": { @@ -1787,8 +1785,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.29.47.4906", - "templateHash": "9953522498407272740" + "version": "0.30.23.60470", + "templateHash": "17279604675307278350" } }, "parameters": { @@ -1849,15 +1847,9 @@ }, "mode": "Incremental", "parameters": { - "identity": { - "value": "[reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, resourceGroup().name), 'Microsoft.Resources/deployments', 'deploy_managed_identity'), '2022-09-01').outputs.managedIdentityOutput.value.id]" - }, "solutionName": { "value": "[parameters('solutionPrefix')]" }, - "solutionLocation": { - "value": "[variables('solutionLocation')]" - }, "AzureSearchService": { "value": "[reference(resourceId('Microsoft.Resources/deployments', 'deploy_ai_search_service'), '2022-09-01').outputs.searchServiceOutput.value.searchServiceName]" }, @@ -1969,9 +1961,6 @@ "AZURE_COSMOSDB_ACCOUNT": { "value": "[reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, resourceGroup().name), 'Microsoft.Resources/deployments', 'deploy_cosmos_db'), '2022-09-01').outputs.cosmosOutput.value.cosmosAccountName]" }, - "AZURE_COSMOSDB_ACCOUNT_KEY": { - "value": "[reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, resourceGroup().name), 'Microsoft.Resources/deployments', 'deploy_cosmos_db'), '2022-09-01').outputs.cosmosOutput.value.cosmosAccountKey]" - }, "AZURE_COSMOSDB_CONVERSATIONS_CONTAINER": { "value": "[reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, resourceGroup().name), 'Microsoft.Resources/deployments', 'deploy_cosmos_db'), '2022-09-01').outputs.cosmosOutput.value.cosmosContainerName]" }, @@ -1991,8 +1980,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.29.47.4906", - "templateHash": "5513270017559796037" + "version": "0.30.23.60470", + "templateHash": "3306232384194992378" } }, "parameters": { @@ -2004,15 +1993,6 @@ "description": "Solution Name" } }, - "solutionLocation": { - "type": "string", - "metadata": { - "description": "Solution Location" - } - }, - "identity": { - "type": "string" - }, "HostingPlanName": { "type": "string", "defaultValue": "[format('{0}-app-service-plan', parameters('solutionName'))]", @@ -2377,7 +2357,7 @@ } }, "variables": { - "WebAppImageName": "DOCKER|bycwacontainerreg.azurecr.io/byc-wa-app:latest" + "WebAppImageName": "DOCKER|bycwacontainerreg.azurecr.io/byc-wa-app:dev" }, "resources": [ { @@ -2566,10 +2546,6 @@ "name": "AZURE_COSMOSDB_ACCOUNT", "value": "[parameters('AZURE_COSMOSDB_ACCOUNT')]" }, - { - "name": "AZURE_COSMOSDB_ACCOUNT_KEY", - "value": "[parameters('AZURE_COSMOSDB_ACCOUNT_KEY')]" - }, { "name": "AZURE_COSMOSDB_CONVERSATIONS_CONTAINER", "value": "[parameters('AZURE_COSMOSDB_CONVERSATIONS_CONTAINER')]" @@ -2619,6 +2595,134 @@ "Application_Type": "web" }, "kind": "web" + }, + { + "type": "Microsoft.Resources/deployments", + "apiVersion": "2022-09-01", + "name": "cosmos-sql-role-definition", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "accountName": { + "value": "[parameters('AZURE_COSMOSDB_ACCOUNT')]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.30.23.60470", + "templateHash": "17906960830343188834" + }, + "description": "Creates a SQL role definition under an Azure Cosmos DB account." + }, + "parameters": { + "accountName": { + "type": "string" + } + }, + "resources": [ + { + "type": "Microsoft.DocumentDB/databaseAccounts/sqlRoleDefinitions", + "apiVersion": "2022-08-15", + "name": "[format('{0}/{1}', parameters('accountName'), guid(resourceId('Microsoft.DocumentDB/databaseAccounts', parameters('accountName')), parameters('accountName'), 'sql-role'))]", + "properties": { + "assignableScopes": [ + "[resourceId('Microsoft.DocumentDB/databaseAccounts', parameters('accountName'))]" + ], + "permissions": [ + { + "dataActions": [ + "Microsoft.DocumentDB/databaseAccounts/readMetadata", + "Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers/items/*", + "Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers/*" + ], + "notDataActions": [] + } + ], + "roleName": "Reader Writer", + "type": "CustomRole" + } + } + ], + "outputs": { + "id": { + "type": "string", + "value": "[resourceId('Microsoft.DocumentDB/databaseAccounts/sqlRoleDefinitions', parameters('accountName'), guid(resourceId('Microsoft.DocumentDB/databaseAccounts', parameters('accountName')), parameters('accountName'), 'sql-role'))]" + } + } + } + }, + "dependsOn": [ + "[resourceId('Microsoft.Web/sites', parameters('WebsiteName'))]" + ] + }, + { + "type": "Microsoft.Resources/deployments", + "apiVersion": "2022-09-01", + "name": "[format('cosmos-sql-user-role-{0}', parameters('WebsiteName'))]", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "accountName": { + "value": "[parameters('AZURE_COSMOSDB_ACCOUNT')]" + }, + "roleDefinitionId": { + "value": "[reference(resourceId('Microsoft.Resources/deployments', 'cosmos-sql-role-definition'), '2022-09-01').outputs.id.value]" + }, + "principalId": { + "value": "[reference(resourceId('Microsoft.Web/sites', parameters('WebsiteName')), '2020-06-01', 'full').identity.principalId]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.30.23.60470", + "templateHash": "2622922268469466870" + }, + "description": "Creates a SQL role assignment under an Azure Cosmos DB account." + }, + "parameters": { + "accountName": { + "type": "string" + }, + "roleDefinitionId": { + "type": "string" + }, + "principalId": { + "type": "string", + "defaultValue": "" + } + }, + "resources": [ + { + "type": "Microsoft.DocumentDB/databaseAccounts/sqlRoleAssignments", + "apiVersion": "2022-05-15", + "name": "[format('{0}/{1}', parameters('accountName'), guid(parameters('roleDefinitionId'), parameters('principalId'), resourceId('Microsoft.DocumentDB/databaseAccounts', parameters('accountName'))))]", + "properties": { + "principalId": "[parameters('principalId')]", + "roleDefinitionId": "[parameters('roleDefinitionId')]", + "scope": "[resourceId('Microsoft.DocumentDB/databaseAccounts', parameters('accountName'))]" + } + } + ] + } + }, + "dependsOn": [ + "[resourceId('Microsoft.Resources/deployments', 'cosmos-sql-role-definition')]", + "[resourceId('Microsoft.Web/sites', parameters('WebsiteName'))]" + ] } ] } @@ -2629,7 +2733,6 @@ "[resourceId('Microsoft.Resources/deployments', 'deploy_ai_search_service')]", "[resourceId('Microsoft.Resources/deployments', 'deploy_azure_function_script_url')]", "[extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, resourceGroup().name), 'Microsoft.Resources/deployments', 'deploy_cosmos_db')]", - "[extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, resourceGroup().name), 'Microsoft.Resources/deployments', 'deploy_managed_identity')]", "[extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, resourceGroup().name), 'Microsoft.Resources/deployments', 'deploy_sql_db')]" ] } diff --git a/ClientAdvisor/Deployment/scripts/create_azure_functions.sh b/ClientAdvisor/Deployment/scripts/create_azure_functions.sh index 89f4d90ab..3d96ffe34 100644 --- a/ClientAdvisor/Deployment/scripts/create_azure_functions.sh +++ b/ClientAdvisor/Deployment/scripts/create_azure_functions.sh @@ -15,6 +15,7 @@ sqlServerName="${11}" sqlDbName="${12}" sqlDbUser="${13}" sqlDbPwd="${14}" +functionAppVersion="${15}" azureOpenAIDeploymentModel="gpt-4" azureOpenAIEmbeddingDeployment="text-embedding-ada-002" @@ -36,7 +37,7 @@ az storage account create --name $storageAccount --location eastus --resource-gr az functionapp create --resource-group $resourceGroupName --name $functionappname \ --environment $env_name --storage-account $storageAccount \ --functions-version 4 --runtime python \ - --image bycwacontainerreg.azurecr.io/byc-wa-fn:latest + --image bycwacontainerreg.azurecr.io/byc-wa-fn:$functionAppVersion # Sleep for 120 seconds echo "Waiting for 120 seconds to ensure the Function App is properly created..." diff --git a/ClientAdvisor/PowerBIReport/WealthAdvisor-Client360Report.pbix b/ClientAdvisor/PowerBIReport/WealthAdvisor-Client360Report.pbix index 69c6ba92f..5bfefa923 100644 Binary files a/ClientAdvisor/PowerBIReport/WealthAdvisor-Client360Report.pbix and b/ClientAdvisor/PowerBIReport/WealthAdvisor-Client360Report.pbix differ diff --git a/ClientAdvisor/README.md b/ClientAdvisor/README.md index 949041e69..1b4e6f961 100644 --- a/ClientAdvisor/README.md +++ b/ClientAdvisor/README.md @@ -69,7 +69,7 @@ https://azure.microsoft.com/en-us/explore/global-infrastructure/products-by-regi 2. Click the following deployment button to create the required resources for this accelerator in your Azure Subscription. - [![Deploy to Azure](https://aka.ms/deploytoazurebutton)](https://portal.azure.com/#create/Microsoft.Template/uri/https%3A%2F%2Fraw.githubusercontent.com%2Fmicrosoft%2FBuild-your-own-copilot-Solution-Accelerator%2Fmain%2FClientAdvisor%2FDeployment%2Fbicep%2Fmain.json) + [![Deploy to Azure](https://aka.ms/deploytoazurebutton)](https://portal.azure.com/#create/Microsoft.Template/uri/https%3A%2F%2Fraw.githubusercontent.com%2FRoopan-Microsoft%2Frp0907%2Fmain%2FClientAdvisor%2FDeployment%2Fbicep%2Fmain.json) 3. You will need to select an Azure Subscription, create/select a Resource group, Region, a unique Solution Prefix and an Azure location for Cosmos DB. diff --git a/ResearchAssistant/App/.gitignore b/ResearchAssistant/App/.gitignore index 73d4e83ed..34411a69e 100644 --- a/ResearchAssistant/App/.gitignore +++ b/ResearchAssistant/App/.gitignore @@ -6,4 +6,5 @@ frontend/node_modules __pycache__/ .ipynb_checkpoints/ static -venv \ No newline at end of file +venv +frontend/coverage \ No newline at end of file diff --git a/ResearchAssistant/App/frontend/__mocks__/SampleData.ts b/ResearchAssistant/App/frontend/__mocks__/SampleData.ts new file mode 100644 index 000000000..9dfa8e437 --- /dev/null +++ b/ResearchAssistant/App/frontend/__mocks__/SampleData.ts @@ -0,0 +1,144 @@ +export const simpleConversationResponseWithCitations = { + id: "0bfe7d97-ae1b-4f19-bc34-bd3faa8e439e", + model: "gpt-35-turbo-16k", + created: 1728454565, + object: "extensions.chat.completion.chunk", + choices: [ + { + messages: [ + { + role: "tool", + content: + '{"citations":[{"content":"In contrast ,severe cases progress rapidly ,resulting in acute respiratory distress syndrome (ARDS )and septicshock which eventually leads to multiple organ fail‐ ure.Some comprehensive studies found that the most common symptoms include fever ,occurring in between 88.0% and 98.6% of the total number of cases (Chen et al.,2020;Guan et al .,2020;Wang DW et al ., 2020).In contrast ,Guan et al .(2020)found that the proportion of patients with fever was not the same at admission (43.8%)as it was during hospitalization (88.7%).A range of other symptoms include dry cough ,fatigue ,and shortness of breath ,occurring in between 60% and 70% of cases .Symptoms such as muscle soreness ,delirium ,headache ,sore throat ,con‐ gestion ,chest pain ,diarrhea ,nausea ,and vomiting remain relatively rare ,occurring in between approximately 1% and 11% of cases (Chen et al .,2020;Guan et al ., 2020;Wang DW et al .,2020).A study has shown that, compared with influenza ,chemosensory dysfunction is closely related to COVID-19 infection (Yan et al., 2020). In another study ,Tang et al. (2020) found that compared with H1N1 patients ,COVID-19 patients are more likely to develop nonproductive coughsaccompanied by obvious constitutional symptoms,such as fatigue and gastrointestinal symptoms. One recent study suggested that COVID-19 is a systemic disease that can cause multisystem lesions (Tersalvi et al., 2020). Potential hypogonadism and attention should be paid to the effects of SARS-CoV-2 on the reproductive system (Fan et al., 2020; Ma et al., 2020). Skin is one of the target organs affected by COVID-19 infection ,and a total of 5.3% of patients developed a rash before they developed any symp‐toms (Li HX et al., 2020). Influenza can also be characterized by a variety of systemic symptoms including high fever ,chills , headache ,myalgia ,discomfort ,and anorexia as well as respiratory symptoms including cough ,congestion , and sore throat .The most common symptoms are high fever and cough ,occurring in 60%β€’80% of cases . Diarrhea is relatively rare ,occurring in approximately 2.8% of cases (Cao et al .,2009);fever isthe most important and common symptom in influenza where body temperature potentially reaches 41Β°C within the first24h(Nicholson ,1992;Cox and Subbarao ,1999; Cao et al., 2009; Long et al., 2012; Bennett et al., 2015).Influenza tends to cause hyperthermia and can also manifest as eye symptoms ,including photophobia , conjunctivitis, tearing ,and eye movement pain.3.3Hematological indicators Lymphocytopenia is common in patients with COVID- 19.This occurs in more than 70% of cases and indicates that immune cell consumption and cellular immune function are both impaired .An increase in C-reactive protein occurs in approximately 50% of cases .Coagulation disorders such as thrombocyto‐ penia and prolonged prothrombin time occur in be‐ tween approximately 30% and 58% of cases ,and in‐ creases in lactate dehydrogenase and leukopenia can also occur .Increases in alanine aminotransferase ,as‐ partate aminotransferase ,and D-dimer levels are un‐ common (Guan et al .,2020;Wang DW et al .,2020)","id":null,"title":"Comparison of COVID-19 and influenza characteristics.","filepath":"33615750.pdf_03_02","url":"https://www.ncbi.nlm.nih.gov/pmc/articles/PMC7885750/#page=3","metadata":null,"image_mapping":null,"chunk_id":"0"},{"content":"www.jzus.zju.edu.cn; www.springer.com/journal/11585 E-mail: jzus_b@zju.edu.cn Journal of Zhejiang University-SCIENCE B (Biomedicine & Biotechnology) 2021 22(2):87-98 Comparison of COVID-19 and influenza characteristics Yu BAI, Xiaonan TAO* Department of Respiratory and Critical Care Medicine, Union Hospital, Tongji Medical College, Huazhong University of Science and Technology, Wuhan 430022, China Abstract: The emergence of coronavirus disease 2019 (COVID-19) not only poses a serious threat to the health of people worldwide but also affects the global economy. The outbreak of COVID-19 began in December 2019, at the same time as the influenza season. However, as the treatments and prognoses of COVID-19 and influenza are different, it is important to accuratelydifferentiate these two different respiratory tract infections on the basis of their respective early-stage characteristics. Wereviewed official documents and news released by the National Health Commission of the People ’s Republic of China, the Chinese Center for Disease Control and Prevention (China CDC), the United States CDC, and the World Health Organization(WHO), and we also searched the PubMed, Web of Science, Excerpta Medica database (Embase), China National KnowledgeInfrastructure (CNKI), Wanfang, preprinted bioRxiv and medRxiv databases for documents and guidelines from earliest available date up until October 3rd, 2020. We obtained the latest information about COVID-19 and influenza and summarizedand compared their biological characteristics, epidemiology, clinical manifestations, pathological mechanisms, treatments, and prognostic factors. We show that although COVID-19 and influenza are different in many ways, there are numerous similarities;thus, in addition to using nucleic acid-based polymerase chain reaction (PCR) and antibody-based approaches, clinicians and epidemiologists should distinguish between the two using their respective characteristics in early stages. We should utilizeexperiences from other epidemics to provide additional guidance for the treatment and prevention of COVID-19. Key words: Coronavirus disease 2019 (COVID-19); Influenza; Severe acute respiratory syndrome coronavirus 2 (SARS-CoV-2) 1 Introduction Coronavirus disease 2019 (COVID-19) was first identified at the end of 2019. The Chinese Center for Disease Control and Prevention (China CDC) as‐sessed initial patients and identified a novel corona ‐ virus, which was later named 2019 novel coronavi ‐ rus (2019-nCoV). Later, on February 11th, 2020, theWorld Health Organization (WHO) officially namedthis disease COVID-19, while the International Vi‐rus Classification Committee identified the pathogenas severe acute respiratory syndrome coronavirus 2(SARS-CoV-2) (Tan WJ et al., 2020). COVID-19poses a threat to global public health and is a chal ‐ lenge to the whole people, government, and society (Shi et al., 2020).The outbreak of COVID-19 began in December 2019, corresponding to the influenza season. It is important for clinicians to distinguish COVID-19from other respiratory infections, including influenza.One study showed that the global number of respiratoryinfluenza-related deaths was between 290 000 and 650 000 per year (Iuliano et al., 2018), while another study showed that the global number of deaths fromlower respiratory tract infections directly caused byinfluenza was between 99 000 and 200 000 per year(GBD 2017 Influenza Collaborators, 2019)","id":null,"title":"Comparison of COVID-19 and influenza characteristics.","filepath":"33615750.pdf_00_01","url":"https://www.ncbi.nlm.nih.gov/pmc/articles/PMC7885750/#page=0","metadata":null,"image_mapping":null,"chunk_id":"0"},{"content":"Niquini RP et al.2 Cad. SaΓΊde PΓΊblica 2020; 36(7):e00149420 Introduction The first case of COVID-19 in Brazil was confirmed on February 26, 2020, in the State of SΓ£o Paulo. Social distancing measures were only implemented in the state nearly a month later 1, contributing to the rapid spread of the disease in the state and in Brazil. Shortly more than a month after confirma - tion of the first case, all 26 states and the Federal District already had ten or more cases each, with the heaviest concentration in the Southeast region (62.5%), followed by the Northeast (15.4%), South (10.8%), Central (6.6%), and North (4.7%) 2. Brazil’s reality is heterogeneous, both in the epidemic’s evolution and in access to healthcare 3, since the country has continental dimensions, with different population distribution patterns, trans - portation conditions (roadways, availability, and costs), income inequalities, and education 4. By the month of May, the states of Rio de Janeiro, Amazonas, CearΓ‘, ParΓ‘, and Pernambuco were already facing critical situations, especially in the respective state capitals and metropolitan areas, overload - ing the health system 5,6, while in other states the disease was spreading more slowly. The disease has gradually spread from the state capitals into the interior, a phenomenon that could impact the country’s health system even more heavily, since many municipalities (counties) lack even a single hospital, and the population is forced to seek health treatment in the regional hub cities 7,8 (MinistΓ©rio da SaΓΊde. Painel coronavΓ­rus. https://covid.saude.gov.br, accessed on May/2020). Despite the increasing number of municipalities with cases and the growing number of hospi - talizations and deaths from COVID-19 in Brazil (MinistΓ©rio da SaΓΊde. Painel coronavΓ­rus. https:// covid.saude.gov.br, accessed on May/2020) there is still limited information for characterizing the hospitalized cases in Brazil (as elsewhere in the world). Studies in China, Italy, and the United States have analyzed the profile of patients hospitalized for COVID-19 and found high prevalence of elderly individuals, males, and preexisting comorbidities such as hypertension and diabetes 9,10,11 . In order to monitor hospitalized COVID-19 cases in Brazil, the Ministry of Health incorporated testing for SARS-CoV-2 (the virus that causes COVID-19) into surveillance of the severe acute respi - ratory illness (SARI). Case notification is compulsory, and the records are stored in the SIVEP-Gripe (Influenza Epidemiological Surveillance Information System) database 12,13. The system was created during the influenza H1N1 pandemic in 2009 and has been maintained since then to monitor SARI cases and for the surveillance of unusual events associated with this syndrome in the country. Among the cases of hospitalization for SARI reported to the national surveillance system from 2010 to 2019, the infectious agents according to the predominant laboratory test in each season were influenza A and B viruses and respiratory syncytial virus (RSV)","id":null,"title":"Description and comparison of demographic characteristics and comorbidities in SARI from COVID-19, SARI from influenza, and the Brazilian general population.","filepath":"32725087.pdf_01_01","url":"https://www.scielo.br/j/csp/a/Zgn3W4jYm6nZpCNt98K6Sdv/?lang=en#page=1","metadata":null,"image_mapping":null,"chunk_id":"0"},{"content":"COMPARISON OF SARI CASES DUE TO COVID-19 IN THE BRAZILIAN POPULATION9 Cad. SaΓΊde PΓΊblica 2020; 36(7):e00149420 18 to 39 years and for all adults. However, among patients hospitalized for SARI-FLU, possibly due to the above-mentioned limitation, it was not possible to show the association between CVD and hos - pitalization for influenza (which has been reported elsewhere in the literature) 15. Thus, importantly, the difference between the prevalence rates of CVD in the general population and in hospitalizations for SARI must be greater in all the age groups analyzed. Finally, another important limitation was the potential bias in the completion and recording of the case notification forms, a bias that is inherent to any study based on data from information systems without direct case-by-case follow-up in the hospital network. On the other hand, the use of data on hospitalizations for SARI-COVID obtained from the SIVEP-Gripe database allows an analysis of a larger population and is extremely relevant for monitoring the profile of severe cases of the disease in the country. In short, the current study corroborates the literature on more advanced age, male gender, and comorbidities as factors associated with hospitalization for COVID-19, which can be considered a marker for severity of the disease. Compared to the Brazilian general population, the high proportion of elderly patients and those 40 to 59 years of age and/or with comorbidities (diabetes, CVD, CKD, and chronic lung diseas - es) among patients hospitalized for SARI-COVID indicates that these patients may be present - ing more serious cases of the disease. This hypothesis should be confirmed through longitudinal studies to support public health policies, for example, defining these risk groups as a priority for vaccination campaigns. Contributors R. P. Niquini contributed to the study’s conception, data analysis and interpretation, and drafting and critical revision of the manuscript. R. M. Lana, A. G. Pacheco, O. G. Cruz, F. C. Coelho, L. M. Carvalho and D. A. M. Vilella contributed to the data inter - pretation and drafting and critical revision of the manuscript. M. F. C. Gomes contributed to the data collection and drafting and critical revision of the manuscript. L. S. Bastos contributed to the study’s conception, data collection, processing, analysis, and interpretation, and drafting and critical revi - sion of the manuscript. Additional informations ORCID: Roberta Pereira Niquini (0000-0003- 1075-3113); Raquel Martins Lana (0000-0002- 7573-1364); Antonio Guilherme Pacheco (0000- 0003-3095-1774); Oswaldo GonΓ§alves Cruz (0000-0002-3289-3195); FlΓ‘vio CodeΓ§o Coelho (0000-0003-3868-4391); Luiz Max Carvalho (0000- 0001-5736-5578); Daniel Antunes Maciel Villela (0000-0001-8371-2959); Marcelo Ferreira da Costa Gomes (0000-0003-4693-5402); Leonardo Soares Bastos (0000-0002-1406-0122).Acknowledgments R. M. Lana receives a scholarship from PDJ Ino - va Fiocruz. D. A. M. Villela and A. G. Pacheco receive scholarships from Brazilian National Research Council (CNPq; Ref. 309569/2019-2 and 307489/2018-3). A. G. Pacheco received a Young Scientist of the State grant from Rio de Janeiro State Research Foundation (FAPERJ; E26/203.172/2017)","id":null,"title":"Description and comparison of demographic characteristics and comorbidities in SARI from COVID-19, SARI from influenza, and the Brazilian general population.","filepath":"32725087.pdf_08_01","url":"https://www.scielo.br/j/csp/a/Zgn3W4jYm6nZpCNt98K6Sdv/?lang=en#page=8","metadata":null,"image_mapping":null,"chunk_id":"0"},{"content":"COMPARISON OF SARI CASES DUE TO COVID-19 IN THE BRAZILIAN POPULATION7 Cad. SaΓΊde PΓΊblica 2020; 36(7):e00149420 Discussion The concentration of hospitalizations for SARI-COVID in the Southeast of Brazil reflects the fact that the disease first reached the country in the State of SΓ£o Paulo, followed by Rio de Janeiro. Social dis - tancing measures were not implemented evenly in the states of Brazil, Rio de Janeiro launched social distancing measures on March 13, while SΓ£o Paulo only adopted them nearly a month after confirma - tion of the first case, which contributed to the rapid spread of the disease both in the state and in the country as a whole 1. Some three months after identification of the first case of COVID-19 in Brazil, 26% (62,345) of the cases and 30% (4,782) of the deaths from the disease were recorded in SΓ£o Paulo. The other three states of the Southeast region accounted for 14% of the cases and 20% of the deaths 18. The higher percentage of residents in the South of Brazil among patients hospitalized for SARI-FLU (22%) when compared to residents of the South as a proportion of the total Brazilian popu - lation (14%) is consistent with the fact that the South is the only region of Brazil with a subtropical climate (as opposed to tropical), which favors the higher incidence of influenza there 19. The median age of patients hospitalized for SARI-COVID was similar to that of patients hospital - ized in Wuhan, China (56, IQR: 46-67) 9 and lower than that of patients hospitalized in New York in the United States (63, IQR: 52-75) 11 and in those admitted to intensive care units in Lombardy, Italy (63, IQR: 56-70) 10. The differences can be explained by the age profiles of the general population in the respective countries. The Brazilian and Chinese populations have lower proportions of individu - als 60 years or older (14% and 17%, respectively), compared to the United States and Italy (23% and 30%, respectively) (United Nations. World population prospects 2019. Estimates: 1950-2020. https:// population.un.org/wpp/Download/Standard/Population/, accessed on 19/May/2020). The higher proportion of male patients among patients hospitalized for COVID-19 also appeared in the above-mentioned studies in China 9 and the United States 11, with an even higher percentage in patients admitted to intensive care units in Lombardy (82%) 10. Since males account for approxi - mately half of the population in these countries (United Nations. World population prospects 2019. Estimates: 1950-2020. https://population.un.org/wpp/Download/Standard/Population/, accessed on 19/May/2020) the current study’s findings and the available scientific literature point to male gender as associated with more serious evolution of the disease and death 20. There is no evidence in the international literature of any race or color at greater risk of hospital - ization for seasonal influenza 15. Thus, the higher relative frequency of self-identified whites among Brazilians hospitalized for SARI-FLU may reflect the higher proportion of hospitalized patients among individuals in the South (which has a proportionally larger white population than the rest of Brazil)","id":null,"title":"Description and comparison of demographic characteristics and comorbidities in SARI from COVID-19, SARI from influenza, and the Brazilian general population.","filepath":"32725087.pdf_06_01","url":"https://www.scielo.br/j/csp/a/Zgn3W4jYm6nZpCNt98K6Sdv/?lang=en#page=6","metadata":null,"image_mapping":null,"chunk_id":"0"}],"intent":"[\\"What is COVID-19?\\", \\"Tell me about COVID-19\\", \\"COVID-19 explained\\"]"}', + }, + ], + }, + ], + "apim-request-id": "apim_req_id", + history_metadata: {}, +}; + +export const simpleConversationResponse = { + id: "cond_id", + model: "gpt-35-turbo-16k", + created: 1728447811, + object: "extensions.chat.completion.chunk", + choices: [ + { + messages: [ + { role: "assistant", content: "AI response for user question" }, + ], + }, + ], + "apim-request-id": "apim_req_id", + history_metadata: {}, +}; + +export const simpleConversationResponseWithEmptyChunk = { + id: "conv_id", + model: "gpt-35-turbo-16k", + created: 1728461403, + object: "extensions.chat.completion.chunk", + choices: [{ messages: [{ role: "assistant", content: "" }] }], + "apim-request-id": "apim_req_id", + history_metadata: {}, +}; + +export const citationObj = { + content: + "[/documents/MSFT_FY23Q4_10K.docx](https://www.sampleurl.com?se=2024-10-01T05%3A38%3A07Z&sp=r&sv=2024-05-04&sr=c&sig=8fFfpNI/tv2rdTKAcunuWpW6zJkZuw%2BGvEGo2zQ1QSA%3D)\n\n\n

this is some long text.........

", + id: "2", + chunk_id: 8, + title: "/documents/MSFT_FY23Q4_10K.docx", + filepath: "MSFT_FY23Q4_10K.docx", + url: "https://www.sampleurl.com", + metadata: { + offset: 15580, + source: "https://www.sampleurl.com", + markdown_url: + "[/documents/MSFT_FY23Q4_10K.docx](https://www.sampleurl.com)", + title: "/documents/MSFT_FY23Q4_10K.docx", + original_url: "https://www.sampleurl.com", + chunk: 8, + key: "doc_d85da45581d92f2ff59e261197d2c70c2b6f8802", + filename: "MSFT_FY23Q4_10K", + }, + reindex_id: "1", +}; + +export const conversationResponseWithExceptionFromAI = { + error: "AI Error", +}; + +export const enterKeyCodes = { + key: "Enter", + code: "Enter", + charCode: 13, +}; +export const spaceKeyCodes = { + key: " ", + code: "Space", + charCode: 32, +}; + +export const escapeKeyCodes = { + key: "Escape", + code: "Escape", + keyCode: 27, +}; + +export const currentChat = { + id: "fe15715e-3d25-551e-d803-0803a35c2b59", + title: "conversation title", + messages: [ + { + id: "55661888-159b-038a-bc57-a8c1d8f6951b", + role: "user", + content: "hi", + date: "2024-10-10T10:27:35.335Z", + }, + { + role: "tool", + content: '{"citations":[],"intent":"[]"}', + id: "f1f9006a-d2f6-4ede-564a-fe7255abe5b6", + date: "2024-10-10T10:27:36.709Z", + }, + { + role: "assistant", + content: "Hello! How can I assist you today?", + id: "a69e71c0-35a3-a332-3a55-5519ffc826df", + date: "2024-10-10T10:27:36.862Z", + }, + ], + date: "2024-10-10T10:27:35.335Z", +}; + +export const firstQuestion = "user prompt question"; + +const expectedMessages = expect.arrayContaining([ + expect.objectContaining({ + id: expect.any(String), + role: "user", + content: firstQuestion, + date: expect.any(String), + }), +]); + +export const expectedUpdateCurrentChatActionPayload = expect.objectContaining({ + id: expect.any(String), + title: firstQuestion, + messages: expectedMessages, + date: expect.any(String), +}); + + +export const mockedUsersData = [ + { + access_token: "token", + expires_on: "2022", + id_token: "id", + provider_name: "abc", + user_claims: "abc", + user_id: "a", + }, +]; \ No newline at end of file diff --git a/ResearchAssistant/App/frontend/__mocks__/fileMock.js b/ResearchAssistant/App/frontend/__mocks__/fileMock.js new file mode 100644 index 000000000..06ad689c8 --- /dev/null +++ b/ResearchAssistant/App/frontend/__mocks__/fileMock.js @@ -0,0 +1,2 @@ +// __mocks__/fileMock.js +module.exports = 'test-file-stub'; diff --git a/ResearchAssistant/App/frontend/__mocks__/jspdf.ts b/ResearchAssistant/App/frontend/__mocks__/jspdf.ts new file mode 100644 index 000000000..24a4b55a1 --- /dev/null +++ b/ResearchAssistant/App/frontend/__mocks__/jspdf.ts @@ -0,0 +1,24 @@ +// __mocks__/jspdf.ts + +// Import the jsPDF type from the actual jsPDF package + +// import type { jsPDF as OriginalJsPDF } from 'jspdf'; + +// Mock implementation of jsPDF + +const jsPDF = jest.fn().mockImplementation(() => ({ + + text: jest.fn(), + + save: jest.fn(), + + addPage: jest.fn(), + + setFont: jest.fn(), + + setFontSize: jest.fn() + + })) + // Export the mocked jsPDF with the correct type + + export { jsPDF } \ No newline at end of file diff --git a/ResearchAssistant/App/frontend/__mocks__/react-markdown.tsx b/ResearchAssistant/App/frontend/__mocks__/react-markdown.tsx new file mode 100644 index 000000000..680829ceb --- /dev/null +++ b/ResearchAssistant/App/frontend/__mocks__/react-markdown.tsx @@ -0,0 +1,8 @@ +import React from 'react'; + +// Mock implementation of react-markdown +const ReactMarkdown: React.FC<{ children: React.ReactNode }> = ({ children }) => { + return
{children}
; // Simply render the children +}; + +export default ReactMarkdown; \ No newline at end of file diff --git a/ResearchAssistant/App/frontend/babel.config.js b/ResearchAssistant/App/frontend/babel.config.js new file mode 100644 index 000000000..a9eb8d2bb --- /dev/null +++ b/ResearchAssistant/App/frontend/babel.config.js @@ -0,0 +1,8 @@ +module.exports = { + presets: [ + '@babel/preset-env', // Transpile ES6+ syntax + '@babel/preset-react', // Transpile JSX + '@babel/preset-typescript', // Transpile TypeScript + ], + }; + \ No newline at end of file diff --git a/ResearchAssistant/App/frontend/jest.config.ts b/ResearchAssistant/App/frontend/jest.config.ts new file mode 100644 index 000000000..5f7c0a375 --- /dev/null +++ b/ResearchAssistant/App/frontend/jest.config.ts @@ -0,0 +1,51 @@ +import type { Config } from '@jest/types'; + +const config: Config.InitialOptions = { + verbose: true, + preset: 'ts-jest', + testEnvironment: "jest-environment-jsdom", + testEnvironmentOptions: { + customExportConditions: [''], + }, + moduleNameMapper: { + '\\.(css|less|scss)$': 'identity-obj-proxy', + '\\.(svg|png|jpg)$': '/__mocks__/fileMock.js', + '^lodash-es$': 'lodash', + }, + setupFilesAfterEnv: ['/setupTests.ts'], + transform: { + + '^.+\\.jsx?$': 'babel-jest', // Transform JavaScript files using babel-jest + '^.+\\.tsx?$': 'ts-jest' + }, + transformIgnorePatterns: [ + '/node_modules/(?!(react-markdown|remark-gfm|rehype-raw)/)', + ], + setupFiles: ['/jest.polyfills.js'], + collectCoverage: true, + collectCoverageFrom: ['src/**/*.{ts,tsx}'], + coverageDirectory: 'coverage', + coverageThreshold: { + global: { + branches: 80, + functions: 80, + lines: 80, + statements: 80, + }, + }, + coveragePathIgnorePatterns: [ + '/node_modules/', // Ignore node_modules + '/__mocks__/', // Ignore mocks + '/src/api/', + '/src/mocks/', + '/src/test/', + '/src/index.tsx', + '/src/vite-env.d.ts', + '/src/components/QuestionInput/index.ts', + '/src/components/Answer/index.ts', + '/src/state', + '/src/components/DraftDocumentsView' + ], +}; + +export default config; diff --git a/ResearchAssistant/App/frontend/jest.polyfills.js b/ResearchAssistant/App/frontend/jest.polyfills.js new file mode 100644 index 000000000..e4b6211dc --- /dev/null +++ b/ResearchAssistant/App/frontend/jest.polyfills.js @@ -0,0 +1,33 @@ +/** + * @note The block below contains polyfills for Node.js globals + * required for Jest to function when running JSDOM tests. + * These HAVE to be require's and HAVE to be in this exact + * order, since "undici" depends on the "TextEncoder" global API. + * + * Consider migrating to a more modern test runner if + * you don't want to deal with this. + */ + +const { TextDecoder, TextEncoder, ReadableStream } = require("node:util") + +Object.defineProperties(globalThis, { + TextDecoder: { value: TextDecoder }, + TextEncoder: { value: TextEncoder }, + ReadableStream: { value: ReadableStream }, +}) + +const { Blob } = require('node:buffer') +const { fetch, Headers, FormData, Request, Response } = require('undici') + +// if (typeof global.ReadableStream === 'undefined') { +// global.ReadableStream = require('web-streams-polyfill/ponyfill').ReadableStream; +// } + +Object.defineProperties(globalThis, { + fetch: { value: fetch, writable: true }, + Blob: { value: Blob }, + Headers: { value: Headers }, + FormData: { value: FormData }, + Request: { value: Request }, + Response: { value: Response }, +}) \ No newline at end of file diff --git a/ResearchAssistant/App/frontend/package.json b/ResearchAssistant/App/frontend/package.json index 9be0f1c8a..1805ddd1f 100644 --- a/ResearchAssistant/App/frontend/package.json +++ b/ResearchAssistant/App/frontend/package.json @@ -2,11 +2,12 @@ "name": "frontend", "private": true, "version": "0.0.0", - "type": "module", "scripts": { "dev": "vite", "build": "tsc && vite build", - "watch": "tsc && vite build --watch" + "watch": "tsc && vite build --watch", + "test": "jest --coverage --verbose", + "test-dev": "jest --coverage --watchAll --verbose" }, "dependencies": { "@fluentui/react": "^8.105.3", @@ -25,26 +26,43 @@ "react-dom": "^18.2.0", "react-markdown": "^7.0.1", "react-modal": "^3.16.1", - "react-router-dom": "^6.8.1", + "react-router-dom": "^6.26.2", "react-uuid": "^2.0.0", "rehype-raw": "^6.1.1", "remark-gfm": "^3.0.1", - "remark-supersub": "^1.0.0" + "remark-supersub": "^1.0.0", + "undici": "^6.20.0" }, "devDependencies": { + "@babel/core": "^7.25.2", + "@babel/preset-env": "^7.25.4", + "@babel/preset-react": "^7.24.7", + "@babel/preset-typescript": "^7.24.7", + "@testing-library/jest-dom": "^6.5.0", + "@testing-library/react": "^16.0.1", + "@testing-library/user-event": "^14.5.2", "@types/file-saver": "^2.0.7", + "@types/jest": "^29.5.13", "@types/lodash-es": "^4.17.7", "@types/react": "^18.0.27", "@types/react-dom": "^18.0.10", + "@types/testing-library__user-event": "^4.2.0", "@typescript-eslint/eslint-plugin": "^6.21.0", "@vitejs/plugin-react": "^3.1.0", + "babel-jest": "^29.7.0", "eslint": "^8.57.0", "eslint-config-standard-with-typescript": "^43.0.1", "eslint-plugin-import": "^2.29.1", "eslint-plugin-n": "^16.6.2", "eslint-plugin-promise": "^6.1.1", "eslint-plugin-react": "^7.33.2", + "identity-obj-proxy": "^3.0.0", + "jest": "^29.7.0", + "jest-environment-jsdom": "^29.7.0", + "msw": "2.2.2", "prettier": "^2.8.3", + "ts-jest": "^29.2.5", + "ts-node": "^10.9.2", "typescript": "^4.9.5", "vite": "^4.1.5" } diff --git a/ResearchAssistant/App/frontend/setupTests.ts b/ResearchAssistant/App/frontend/setupTests.ts new file mode 100644 index 000000000..66993e3af --- /dev/null +++ b/ResearchAssistant/App/frontend/setupTests.ts @@ -0,0 +1,15 @@ +import '@testing-library/jest-dom'; // For jest-dom matchers like toBeInTheDocument + +import { initializeIcons } from '@fluentui/react/lib/Icons'; +initializeIcons(); + +// import { server } from '../mocks/server'; + +// // Establish API mocking before all tests +// beforeAll(() => server.listen()); + +// // Reset any request handlers that are declared in a test +// afterEach(() => server.resetHandlers()); + +// // Clean up after the tests are finished +// afterAll(() => server.close()); \ No newline at end of file diff --git a/ResearchAssistant/App/frontend/src/components/Answer/Answer.test.tsx b/ResearchAssistant/App/frontend/src/components/Answer/Answer.test.tsx new file mode 100644 index 000000000..dbdd380d1 --- /dev/null +++ b/ResearchAssistant/App/frontend/src/components/Answer/Answer.test.tsx @@ -0,0 +1,375 @@ +import React from 'react'; +import { render, fireEvent,screen } from '@testing-library/react'; +import { Answer } from './Answer'; +import { type AskResponse, type Citation } from '../../api'; + + +jest.mock('lodash-es', () => ({ + cloneDeep: jest.fn((value) => { + return JSON.parse(JSON.stringify(value)); + }), +})); +jest.mock('remark-supersub', () => () => {}); +jest.mock('remark-gfm', () => () => {}); +jest.mock('rehype-raw', () => () => {}); + +const mockCitations = [ + { + chunk_id: '0', + content: 'Citation 1', + filepath: 'path/to/doc1', + id: '1', + reindex_id: '1', + title: 'Title 1', + url: 'http://example.com/doc1', + metadata: null, + }, + { + chunk_id: '1', + content: 'Citation 2', + filepath: 'path/to/doc2', + id: '2', + reindex_id: '2', + title: 'Title 2', + url: 'http://example.com/doc2', + metadata: null, + }, +]; +const answerWithCitations: AskResponse = { + answer: 'This is the answer with citations [doc1].', + citations: [ + { + id: '1', + content: 'Citation 1', + filepath: 'path/to/document', + title: 'Title 1', + url: 'http://example.com/doc1', + reindex_id: '1', + chunk_id: null, + metadata: null, + } as Citation + ], +}; +const mockAnswer: AskResponse = { + answer: 'This is the answer with citations [doc1] and [doc2].', + citations: mockCitations, +}; + +type OnCitationClicked = (citedDocument: Citation) => void; + +describe('Answer component', () => { + let onCitationClicked: OnCitationClicked; + const setup = (answerProps: AskResponse) => { + return render(); +}; + + beforeEach(() => { + onCitationClicked = jest.fn(); + }); + + test('toggles the citation accordion on chevron click', () => { + const { getByLabelText } = render(); + + const toggleButton = getByLabelText(/Open references/i); + fireEvent.click(toggleButton); + + const citationFilename1 = getByLabelText(/path\/to\/doc1 - Part 1/i); + const citationFilename2 = getByLabelText(/path\/to\/doc2 - Part 2/i); + + expect(citationFilename1).toBeInTheDocument(); + expect(citationFilename2).toBeInTheDocument(); + }); + + test('creates the citation filepath correctly', () => { + const { getByLabelText } = render(); + const toggleButton = getByLabelText(/Open references/i); + fireEvent.click(toggleButton); + + const citationFilename1 = getByLabelText(/path\/to\/doc1 - Part 1/i); + const citationFilename2 = getByLabelText(/path\/to\/doc2 - Part 2/i); + + expect(citationFilename1).toBeInTheDocument(); + expect(citationFilename2).toBeInTheDocument(); + }); + + test('initially renders with the accordion collapsed', () => { + const { getByLabelText } = render(); + const toggleButton = getByLabelText(/Open references/i); + + expect(toggleButton).not.toHaveAttribute('aria-expanded'); + }); + + test('handles keyboard events to open the accordion and click citations', () => { + const { getByText } = render(); + const toggleButton = getByText(/2 references/i); + fireEvent.click(toggleButton); + + const citationLink = getByText(/path\/to\/doc1/i); + expect(citationLink).toBeInTheDocument(); + + fireEvent.click(citationLink); + + expect(onCitationClicked).toHaveBeenCalledWith({ + chunk_id: '0', + content: 'Citation 1', + filepath: 'path/to/doc1', + id: '1', + metadata: null, + reindex_id: '1', + title: 'Title 1', + url: 'http://example.com/doc1', + }); + }); + + test('handles keyboard events to click citations', () => { + const { getByText } = render(); + const toggleButton = getByText(/2 references/i); + fireEvent.click(toggleButton); + + const citationLink = getByText(/path\/to\/doc1/i); + expect(citationLink).toBeInTheDocument(); + + fireEvent.keyDown(citationLink, { key: 'Enter', code: 'Enter' }); + expect(onCitationClicked).toHaveBeenCalledWith(mockCitations[0]); + + fireEvent.keyDown(citationLink, { key: ' ', code: 'Space' }); + expect(onCitationClicked).toHaveBeenCalledTimes(2); // Now test's called again + }); + + test('calls onCitationClicked when a citation is clicked', () => { + const { getByText } = render(); + const toggleButton = getByText('2 references'); + fireEvent.click(toggleButton); + + const citationLink = getByText('path/to/doc1 - Part 1'); + fireEvent.click(citationLink); + + expect(onCitationClicked).toHaveBeenCalledWith(mockCitations[0]); + }); + + test('renders the answer text correctly', () => { + const { getByText } = render(); + + expect(getByText(/This is the answer with citations/i)).toBeInTheDocument(); + expect(getByText(/references/i)).toBeInTheDocument(); + }); + + test('displays correct number of citations', () => { + const { getByText } = render(); + expect(getByText('2 references')).toBeInTheDocument(); + }); + + test('toggles the citation accordion on click', () => { + const { getByText, queryByText } = render(); + const toggleButton = getByText('2 references'); + + expect(queryByText('path/to/doc1 - Part 1')).not.toBeInTheDocument(); + expect(queryByText('path/to/doc2 - Part 2')).not.toBeInTheDocument(); + + + fireEvent.click(toggleButton); + + + expect(getByText('path/to/doc1 - Part 1')).toBeInTheDocument(); + expect(getByText('path/to/doc2 - Part 2')).toBeInTheDocument(); + }); + + test('displays disclaimer text', () => { + const { getByText } = render(); + expect(getByText(/AI-generated content may be incorrect/i)).toBeInTheDocument(); + }); + + test('handles fallback case for citations without filepath or ids', () => { + const answerWithFallbackCitation: AskResponse = { + answer: 'This is the answer with citations [doc1].', + citations: [{ + id: '1', + content: 'Citation 1', + filepath: '', + title: 'Title 1', + url: '', + chunk_id: '0', + reindex_id: '1', + metadata: null, + }], + }; + + const { getByLabelText } = render(); + + const toggleButton = getByLabelText(/Open references/i); + fireEvent.click(toggleButton); + + expect(screen.getByLabelText(/Citation 1/i)).toBeInTheDocument(); + }); + + + test('handles citations with long file paths', () => { + const longCitation = { + chunk_id: '0', + content: 'Citation 1', + filepath: 'path/to/very/long/document/file/path/to/doc1', + id: '1', + reindex_id: '1', + title: 'Title 1', + url: 'http://example.com/doc1', + metadata: null, + }; + + const answerWithLongCitation: AskResponse = { + answer: 'This is the answer with citations [doc1].', + citations: [longCitation], + }; + + const { getByLabelText } = render(); + const toggleButton = getByLabelText(/Open references/i); + fireEvent.click(toggleButton); + + expect(getByLabelText(/path\/to\/very\/long\/document\/file\/path\/to\/doc1 - Part 1/i)).toBeInTheDocument(); + }); + + test('renders citations with fallback text for invalid citations', () => { + const onCitationClicked = jest.fn(); + + const answerWithInvalidCitation = { + answer: 'This is the answer with citations [doc1].', + citations: [{ + id: '', + content: 'Citation 1', + filepath: '', + title: 'Title 1', + url: '', + chunk_id: '0', + reindex_id: '1', + metadata: null, + }], + }; + + const { container } = render(); + + const toggleButton = screen.getByLabelText(/Open references/i); + expect(toggleButton).toBeInTheDocument(); + + + fireEvent.click(toggleButton); + + + expect(screen.getByLabelText(/Citation 1/i)).toBeInTheDocument(); + }); + test('handles citations with reindex_id', () => { + + const answerWithCitationsReindexId: AskResponse = { + answer: 'This is the answer with citations [doc1].', + citations: [ + { + id: '1', + content: 'Citation 1', + filepath: 'path/to/document', + title: 'Title 1', + url: 'http://example.com/doc1', + reindex_id: '1', + chunk_id: null, + metadata: null, + } + ], + }; + + setup(answerWithCitationsReindexId); + + + const toggleButton = screen.getByLabelText(/Open references/i); + fireEvent.click(toggleButton); + + + const citationFilename = screen.getByLabelText(/path\/to\/document - Part 1/i); // Change to Part 1 + expect(citationFilename).toBeInTheDocument(); +}); +test('handles citation filename truncation', () => { + const answerWithCitations: AskResponse = { + answer: 'This is the answer with citations [doc1].', + citations: [ + { + id: '1', + content: 'Citation 1', + filepath: 'a_very_long_filepath_that_needs_to_be_truncated_to_fit_the_ui', + title: 'Title 1', + url: 'http://example.com/doc1', + reindex_id: null, + chunk_id: '1', + metadata: null, + } as Citation + ], + }; + + setup(answerWithCitations); + + + const toggleButton = screen.getByLabelText(/Open references/i); + fireEvent.click(toggleButton); + + + const citationFilename = screen.getByLabelText(/a_very_long_filepath_that_needs_to_be_truncated_to_fit_the_ui - Part 2/i); + expect(citationFilename).toBeInTheDocument(); +}); +test('handles citations with reindex_id and clicks citation link', () => { + setup(answerWithCitations); + + // Click to expand the citation section + const toggleButton = screen.getByLabelText(/Open references/i); + fireEvent.click(toggleButton); + + // Check if the citation filename is created correctly + const citationFilename = screen.getByLabelText(/path\/to\/document - Part 1/i); + expect(citationFilename).toBeInTheDocument(); + + // Click the citation link + fireEvent.click(citationFilename); + + // Validate onCitationClicked was called + // Note: Ensure that you have access to the onCitationClicked mock function + expect(onCitationClicked).toHaveBeenCalledWith(answerWithCitations.citations[0]); +}); + +test('toggles accordion on key press', () => { + setup(answerWithCitations); + + + const toggleButton = screen.getByLabelText(/Open references/i); + fireEvent.click(toggleButton); + + + const citationLink = screen.getByLabelText(/path\/to\/document - Part 1/i); + + fireEvent.keyDown(citationLink, { key: 'Enter', code: 'Enter' }); + + expect(onCitationClicked).toHaveBeenCalledWith(answerWithCitations.citations[0]); + + fireEvent.keyDown(citationLink, { key: ' ', code: 'Space' }); + + expect(onCitationClicked).toHaveBeenCalledTimes(2); +}); + + +test('handles keyboard events to open the accordion', () => { + setup(answerWithCitations); + + const chevronButton = screen.getByLabelText(/Open references/i); + + + fireEvent.keyDown(chevronButton, { key: 'Enter', code: 'Enter' }); + + expect(screen.getByText(/Citation/i)).toBeVisible(); + + + fireEvent.click(chevronButton); + + + fireEvent.keyDown(chevronButton, { key: ' ', code: 'Space' }); + expect(screen.getByText(/Citation/i)).toBeVisible(); +}); + + + + + + +}); diff --git a/ResearchAssistant/App/frontend/src/components/Answer/Answer.tsx b/ResearchAssistant/App/frontend/src/components/Answer/Answer.tsx index 852ad501d..e0819c338 100644 --- a/ResearchAssistant/App/frontend/src/components/Answer/Answer.tsx +++ b/ResearchAssistant/App/frontend/src/components/Answer/Answer.tsx @@ -1,7 +1,7 @@ import { useEffect, useMemo, useState } from "react"; import { useBoolean } from "@fluentui/react-hooks" import { FontIcon, Stack, Text } from "@fluentui/react"; - +import React from "react"; import styles from "./Answer.module.css"; import { AskResponse, Citation } from "../../api"; diff --git a/ResearchAssistant/App/frontend/src/components/Answer/AnswerParser.test.tsx b/ResearchAssistant/App/frontend/src/components/Answer/AnswerParser.test.tsx new file mode 100644 index 000000000..eb0eaf757 --- /dev/null +++ b/ResearchAssistant/App/frontend/src/components/Answer/AnswerParser.test.tsx @@ -0,0 +1,168 @@ +import { parseAnswer } from './AnswerParser' // Adjust the path as necessary +import { type AskResponse, type Citation } from '../../api' + +export {} + +// Mock citation data +const mockCitations: Citation[] = [ + { + id: '1', + content: 'Citation 1', + title: null, + filepath: null, + url: null, + metadata: null, + chunk_id: null, + reindex_id: null + }, + { + id: '2', + content: 'Citation 2', + title: null, + filepath: null, + url: null, + metadata: null, + chunk_id: null, + reindex_id: null + }, + { + id: '3', + content: 'Citation 3', + title: null, + filepath: null, + url: null, + metadata: null, + chunk_id: null, + reindex_id: null + } +] + +// Mock the cloneDeep function from lodash-es +jest.mock('lodash-es', () => ({ + cloneDeep: jest.fn((value) => { + if (value === undefined) { + return undefined // Return undefined if input is undefined + } + return JSON.parse(JSON.stringify(value)) // A simple deep clone + }) +})) + +// Mock other dependencies +jest.mock('remark-gfm', () => jest.fn()) +jest.mock('rehype-raw', () => jest.fn()) + +describe('parseAnswer function', () => { + test('should parse valid citations correctly', () => { + const answer: AskResponse = { + answer: 'This is the answer with citations [doc1] and [doc2].', + citations: mockCitations + } + + const result = parseAnswer(answer) + + // Adjust expected output to match actual output format + expect(result.markdownFormatText).toBe('This is the answer with citations ^1^ and ^2^ .') + expect(result.citations.length).toBe(2) + + // Update expected citations to include the correct reindex_id + const expectedCitations = [ + { ...mockCitations[0], reindex_id: '1' }, + { ...mockCitations[1], reindex_id: '2' } + ] + + expect(result.citations).toEqual(expectedCitations) + }) + + test('should handle duplicate citations correctly', () => { + const answer: AskResponse = { + answer: 'This is the answer with duplicate citations [doc1] and [doc1].', + citations: mockCitations + } + + const result = parseAnswer(answer) + + // Adjust expected output to match actual output format + expect(result.markdownFormatText).toBe('This is the answer with duplicate citations ^1^ and ^1^ .') + expect(result.citations.length).toBe(1) + + // Update expected citation to include the correct reindex_id + const expectedCitation = { ...mockCitations[0], reindex_id: '1' } + + expect(result.citations[0]).toEqual(expectedCitation) + }) + + test('should handle invalid citation links gracefully', () => { + const answer: AskResponse = { + answer: 'This answer has an invalid citation [doc99].', + citations: mockCitations + } + + const result = parseAnswer(answer) + + expect(result.markdownFormatText).toBe('This answer has an invalid citation [doc99].') + expect(result.citations.length).toBe(0) + }) + + test('should ignore invalid citation links and keep valid ones', () => { + const answer: AskResponse = { + answer: 'Valid citation [doc1] and invalid citation [doc99].', + citations: mockCitations + } + + const result = parseAnswer(answer) + + // Adjust expected output to match actual output format + expect(result.markdownFormatText).toBe('Valid citation ^1^ and invalid citation [doc99].') + expect(result.citations.length).toBe(1) + + // Update expected citation to include the correct reindex_id + const expectedCitation = { ...mockCitations[0], reindex_id: '1' } + + expect(result.citations[0]).toEqual(expectedCitation) + }) + + test('should handle empty answer gracefully', () => { + const answer: AskResponse = { + answer: '', + citations: mockCitations + } + + const result = parseAnswer(answer) + + expect(result.markdownFormatText).toBe('') + expect(result.citations.length).toBe(0) + }) + + test('should handle no citations', () => { + const answer: AskResponse = { + answer: 'This answer has no citations.', + citations: [] + } + + const result = parseAnswer(answer) + + expect(result.markdownFormatText).toBe('This answer has no citations.') + expect(result.citations.length).toBe(0) + }) + + test('should handle multiple citation types in one answer', () => { + const answer: AskResponse = { + answer: 'Mixing [doc1] and [doc2] with [doc99] invalid citations.', + citations: mockCitations + } + + const result = parseAnswer(answer) + + // Adjust expected output to match actual output format + expect(result.markdownFormatText).toBe('Mixing ^1^ and ^2^ with [doc99] invalid citations.') + expect(result.citations.length).toBe(2) + + // Update expected citations to match the actual output + const expectedCitations = [ + { ...mockCitations[0], reindex_id: '1' }, + { ...mockCitations[1], reindex_id: '2' } + ] + + expect(result.citations).toEqual(expectedCitations) + }) +}) \ No newline at end of file diff --git a/ResearchAssistant/App/frontend/src/components/ChatMessageContainer/ChatMessageContainer.module.css b/ResearchAssistant/App/frontend/src/components/ChatMessageContainer/ChatMessageContainer.module.css new file mode 100644 index 000000000..bf5b9a374 --- /dev/null +++ b/ResearchAssistant/App/frontend/src/components/ChatMessageContainer/ChatMessageContainer.module.css @@ -0,0 +1,62 @@ +.chatMessageUser { + display: flex; + justify-content: flex-end; + margin-bottom: 12px; +} + +.chatMessageUserMessage { + padding: 20px; + background: #edf5fd; + border-radius: 8px; + box-shadow: 0px 2px 4px rgba(0, 0, 0, 0.14), 0px 0px 2px rgba(0, 0, 0, 0.12); + font-style: normal; + font-weight: 400; + font-size: 14px; + line-height: 22px; + color: #242424; + flex: none; + order: 0; + flex-grow: 0; + white-space: pre-wrap; + word-wrap: break-word; + max-width: 800px; +} + +.chatMessageGpt { + margin-bottom: 12px; + max-width: 80%; + display: flex; +} + +.chatMessageError { + padding: 20px; + border-radius: 8px; + box-shadow: rgba(182, 52, 67, 1) 1px 1px 2px, rgba(182, 52, 67, 1) 0px 0px 1px; + color: #242424; + flex: none; + order: 0; + flex-grow: 0; + max-width: 800px; + margin-bottom: 12px; +} + +.chatMessageErrorContent { + font-family: "Segoe UI"; + font-style: normal; + font-weight: 400; + font-size: 14px; + line-height: 22px; + white-space: pre-wrap; + word-wrap: break-word; + gap: 12px; + align-items: center; +} + +@media screen and (-ms-high-contrast: active), (forced-colors: active) { + .chatMessageUserMessage { + border: 2px solid WindowText; + padding: 10px; + background-color: Window; + color: WindowText; + } +} diff --git a/ResearchAssistant/App/frontend/src/components/ChatMessageContainer/ChatMessageContainer.test.tsx b/ResearchAssistant/App/frontend/src/components/ChatMessageContainer/ChatMessageContainer.test.tsx new file mode 100644 index 000000000..c0a51b7aa --- /dev/null +++ b/ResearchAssistant/App/frontend/src/components/ChatMessageContainer/ChatMessageContainer.test.tsx @@ -0,0 +1,185 @@ +import React from 'react' +import { render, screen, fireEvent } from '@testing-library/react' +import { ChatMessageContainer, parseCitationFromMessage } from './ChatMessageContainer' +import { type ChatMessage } from '../../api/models' +import { Answer } from '../Answer' +jest.mock('remark-supersub', () => () => {}) +jest.mock('remark-gfm', () => () => {}) +jest.mock('rehype-raw', () => () => {}) +jest.mock('../Answer/Answer', () => ({ + Answer: jest.fn((props: any) =>
+

{props.answer.answer}

+ Mock Answer Component + {props.answer.answer == 'Generating answer...' + ? + : + } + +
) +})) + +const mockOnShowCitation = jest.fn() + +describe('ChatMessageContainer', () => { + beforeEach(() => { + global.fetch = jest.fn() + jest.spyOn(console, 'error').mockImplementation(() => { }) + }) + + afterEach(() => { + jest.clearAllMocks() + }) + + const userMessage: ChatMessage = { + role: 'user', + content: 'User message', + id: '1', + date: new Date().toDateString() + } + + const assistantMessage: ChatMessage = { + role: 'assistant', + content: 'Assistant message', + id: '2', + date: new Date().toDateString() + } + + const errorMessage: ChatMessage = { + role: 'error', + content: 'Error message', + id: '3', + date: new Date().toDateString() + } + + it('renders user and assistant messages correctly', () => { + render( + + ) + + // Check if user message is displayed + expect(screen.getByText('User message')).toBeInTheDocument() + screen.debug() + // Check if assistant message is displayed via Answer component + expect(screen.getByText('Mock Answer Component')).toBeInTheDocument() + expect(Answer).toHaveBeenCalledWith( + expect.objectContaining({ + answer: { + answer: 'Assistant message', + citations: [] + } + }), + {} + ) + }) + + it('renders an error message correctly', () => { + render( + + ) + + // Check if error message is displayed with the error icon + expect(screen.getByText('Error')).toBeInTheDocument() + expect(screen.getByText('Error message')).toBeInTheDocument() + }) + + it('displays the loading message when showLoadingMessage is true', () => { + render( + + ) + // Check if the loading message is displayed via Answer component + expect(screen.getByText('Generating answer...')).toBeInTheDocument() + }) + + it('calls onShowCitation when a citation is clicked', () => { + render( + + ) + + // Simulate a citation click + const citationButton = screen.getByText('Mock Citation') + fireEvent.click(citationButton) + + // Check if onShowCitation is called with the correct argument + expect(mockOnShowCitation).toHaveBeenCalledWith({ title: 'Test Citation' }) + }) + + test('does not call onShowCitation when citation click is a no-op', () => { + render( + + ) + // Simulate a citation click + const citationButton = screen.getByRole('button', { name: 'Mock Citation Loading' }) + fireEvent.click(citationButton) + + // Check if onShowCitation is NOT called + expect(mockOnShowCitation).not.toHaveBeenCalled() + }) + + test('calls onShowCitation when citation button is clicked', async () => { + render() + const buttonEle = await screen.findByRole('button', { name: 'citationButton' }) + fireEvent.click(buttonEle) + expect(mockOnShowCitation).toHaveBeenCalledWith({ title: 'Test Citation' }) + }) + + test('does not call onCitationClicked when citation button is clicked', async () => { + const mockOnCitationClicked = jest.fn() + render() + const buttonEle = await screen.findByRole('button', { name: 'citationButton' }) + fireEvent.click(buttonEle) + expect(mockOnCitationClicked).not.toHaveBeenCalled() + }) + + it('returns citations when message role is "tool" and content is valid JSON', () => { + const message: ChatMessage = { + role: 'tool', + content: JSON.stringify({ citations: [{ filepath: 'path/to/file', chunk_id: '1' }] }), + id: '1', + date: '' + } + const citations = parseCitationFromMessage(message) + expect(citations).toEqual([{ filepath: 'path/to/file', chunk_id: '1' }]) + }) + it('returns an empty array when message role is "tool" and content is invalid JSON', () => { + const message: ChatMessage = { + role: 'tool', + content: 'invalid JSON', + id: '1', + date: '' + } + const citations = parseCitationFromMessage(message) + expect(citations).toEqual([]) + }) + it('returns an empty array when message role is not "tool"', () => { + const message: ChatMessage = { + role: 'user', + content: JSON.stringify({ citations: [{ filepath: 'path/to/file', chunk_id: '1' }] }), + id: '1', + date: '' + } + const citations = parseCitationFromMessage(message) + expect(citations).toEqual([]) + }) +}) diff --git a/ResearchAssistant/App/frontend/src/components/ChatMessageContainer/ChatMessageContainer.tsx b/ResearchAssistant/App/frontend/src/components/ChatMessageContainer/ChatMessageContainer.tsx new file mode 100644 index 000000000..9e558fe22 --- /dev/null +++ b/ResearchAssistant/App/frontend/src/components/ChatMessageContainer/ChatMessageContainer.tsx @@ -0,0 +1,82 @@ +import { Fragment } from "react"; +import { Stack } from "@fluentui/react"; +import { ToolMessageContent, type ChatMessage, type Citation } from "../../api"; +import styles from "./ChatMessageContainer.module.css"; +import { Answer } from "../Answer/Answer"; +import { ErrorCircleRegular } from "@fluentui/react-icons"; + +type ChatMessageContainerProps = { + messages: ChatMessage[]; + onShowCitation: (citation: Citation) => void; + showLoadingMessage: boolean; +}; + +export const parseCitationFromMessage = (message: ChatMessage) => { + if (message?.role && message?.role === "tool") { + try { + const toolMessage = JSON.parse(message.content) as ToolMessageContent; + return toolMessage.citations; + } catch { + return []; + } + } + return []; +}; + +export const ChatMessageContainer = (props: ChatMessageContainerProps): JSX.Element => { + const [ASSISTANT, TOOL, ERROR, USER] = ["assistant", "tool", "error", "user"]; + const { messages, onShowCitation , showLoadingMessage} = props; + return ( + + {messages.map((answer, index) => ( + + {answer.role === USER ? ( +
+
+ {answer.content} +
+
+ ) : answer.role === ASSISTANT ? ( +
+ onShowCitation(c)} + /> +
+ ) : answer.role === ERROR ? ( +
+ + + Error + + + {answer.content} + +
+ ) : null} +
+ ))} + {showLoadingMessage && ( + <> +
+ null} + /> +
+ + )} +
+ ); +}; + +export default ChatMessageContainer; diff --git a/ResearchAssistant/App/frontend/src/components/CitationPanel/CitationPanel.module.css b/ResearchAssistant/App/frontend/src/components/CitationPanel/CitationPanel.module.css new file mode 100644 index 000000000..76d82ba24 --- /dev/null +++ b/ResearchAssistant/App/frontend/src/components/CitationPanel/CitationPanel.module.css @@ -0,0 +1,80 @@ +.citationPanel { + display: flex; + flex-direction: column; + align-items: flex-start; + padding: 16px 16px; + gap: 8px; + background: #ffffff; + box-shadow: 0px 2px 4px rgba(0, 0, 0, 0.14), 0px 0px 2px rgba(0, 0, 0, 0.12); + border-radius: 8px; + flex: auto; + order: 0; + align-self: stretch; + flex-grow: 0.3; + max-width: 30%; + overflow-y: scroll; + max-height: calc(100vh - 100px); +} + +.citationPanelHeaderContainer { + width: 100%; +} + +.citationPanelHeader { + font-style: normal; + font-weight: 600; + font-size: 18px; + line-height: 24px; + color: #000000; + flex: none; + order: 0; + flex-grow: 0; +} + +.citationPanelDismiss { + width: 18px; + height: 18px; + color: #424242; +} + +.citationPanelDismiss:hover { + background-color: #d1d1d1; + cursor: pointer; +} + +.citationPanelTitle { + font-style: normal; + font-weight: 600; + font-size: 16px; + line-height: 22px; + color: #323130; + margin-top: 12px; + margin-bottom: 12px; +} + +.citationPanelTitle:hover { + text-decoration: underline; + cursor: pointer; +} + +.citationPanelContent { + font-style: normal; + font-weight: 400; + font-size: 14px; + line-height: 20px; + color: #000000; + flex: none; + order: 1; + align-self: stretch; + flex-grow: 0; +} + +/* high constrat */ +@media screen and (-ms-high-contrast: active), (forced-colors: active) { + .citationPanel { + border: 2px solid WindowText; + padding: 10px; + background-color: Window; + color: WindowText; + } +} diff --git a/ResearchAssistant/App/frontend/src/components/CitationPanel/CitationPanel.test.tsx b/ResearchAssistant/App/frontend/src/components/CitationPanel/CitationPanel.test.tsx new file mode 100644 index 000000000..fbdda2468 --- /dev/null +++ b/ResearchAssistant/App/frontend/src/components/CitationPanel/CitationPanel.test.tsx @@ -0,0 +1,147 @@ +// CitationPanel.test.tsx +import React from 'react' +import { render, screen, fireEvent } from '@testing-library/react' +import CitationPanel from './CitationPanel' +import { type Citation } from '../../api' + +jest.mock('remark-gfm', () => jest.fn()) +jest.mock('rehype-raw', () => jest.fn()) +const mockIsCitationPanelOpen = jest.fn() +const mockOnViewSource = jest.fn() +const mockOnClickAddFavorite = jest.fn() + +const mockCitation = { + id: '123', + title: 'Sample Citation', + content: 'This is a sample citation content.', + url: 'https://example.com/sample-citation', + filepath: 'path', + metadata: '', + chunk_id: '', + reindex_id: '' +} + +describe('CitationPanel', () => { + beforeEach(() => { + // Reset mocks before each test + mockIsCitationPanelOpen.mockClear() + mockOnViewSource.mockClear() + }) + + test('renders CitationPanel with citation title and content', () => { + render( + + ) + + // Check if title is rendered + expect(screen.getByRole('heading', { name: /Sample Citation/i })).toBeInTheDocument() + }) + + test('renders CitationPanel with citation title and content without url ', () => { + render( + + ) + + expect(screen.getByRole('heading', { name: '' })).toBeInTheDocument() + }) + + test('renders CitationPanel with citation title and content includes blob.core in url ', () => { + render( + + ) + + expect(screen.getByRole('heading', { name: '' })).toBeInTheDocument() + }) + + test('renders CitationPanel with citation title and content title is null ', () => { + render( + + ) + + expect(screen.getByRole('heading', { name: 'https://example.com/sample-citation' })).toBeInTheDocument() + }) + + test('calls IsCitationPanelOpen with false when close button is clicked', () => { + render( + + ) + const closeButton = screen.getByRole('button', { name: /Close citations panel/i }) + fireEvent.click(closeButton) + + expect(mockIsCitationPanelOpen).toHaveBeenCalledWith(false) + }) + + test('calls onViewSource with citation when title is clicked', () => { + render( + + ) + + const title = screen.getByRole('heading', { name: /Sample Citation/i }) + fireEvent.click(title) + + expect(mockOnViewSource).toHaveBeenCalledWith(mockCitation) + }) + + test('renders the title correctly and sets the correct title attribute for non-blob URL', () => { + render( + + ) + + const titleElement = screen.getByRole('heading', { name: /Sample Citation/i }) + + // Ensure the title is rendered + expect(titleElement).toBeInTheDocument() + + // Ensure the title attribute is set to the URL since it's not a blob URL + expect(titleElement).toHaveAttribute('title', 'https://example.com/sample-citation') + + // Trigger the onClick event and ensure onViewSource is called with the correct citation + fireEvent.click(titleElement) + expect(mockOnViewSource).toHaveBeenCalledWith(mockCitation) + }) + + test('renders the title correctly and sets the title attribute to the citation title for blob URL', () => { + const mockCitationWithBlobUrl: Citation = { + ...mockCitation, + title: 'Test Citation with Blob URL', + url: 'https://blob.core.example.com/resource', + content: '' + } + render( + + ) + + const titleElement = screen.getByRole('heading', { name: /Test Citation with Blob URL/i }) + + // Ensure the title is rendered + expect(titleElement).toBeInTheDocument() + + // Ensure the title attribute is set to the citation title since the URL contains "blob.core" + expect(titleElement).toHaveAttribute('title', 'Test Citation with Blob URL') + + // Trigger the onClick event and ensure onViewSource is called with the correct citation + fireEvent.click(titleElement) + expect(mockOnViewSource).toHaveBeenCalledWith(mockCitationWithBlobUrl) + }) +}) diff --git a/ResearchAssistant/App/frontend/src/components/CitationPanel/CitationPanel.tsx b/ResearchAssistant/App/frontend/src/components/CitationPanel/CitationPanel.tsx new file mode 100644 index 000000000..b6e780d2c --- /dev/null +++ b/ResearchAssistant/App/frontend/src/components/CitationPanel/CitationPanel.tsx @@ -0,0 +1,89 @@ +import { IconButton, Stack } from "@fluentui/react"; +import { PrimaryButton } from "@fluentui/react/lib/Button"; +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; +import rehypeRaw from "rehype-raw"; +import { type Citation } from "../../api"; + +import styles from "./CitationPanel.module.css"; + +type citationPanelProps = { + activeCitation: Citation | undefined; + setIsCitationPanelOpen: (flag: boolean) => void; + onViewSource: (citation: Citation | undefined) => void; + onClickAddFavorite: () => void; +}; + +const CitationPanel = (props: citationPanelProps): JSX.Element => { + const { + activeCitation, + setIsCitationPanelOpen, + onViewSource, + onClickAddFavorite, + } = props; + + const title = !activeCitation?.url?.includes("blob.core") + ? activeCitation?.url ?? "" + : activeCitation?.title ?? ""; + return ( + + + + + References + + + { + setIsCitationPanelOpen(false); + }} + /> + +
onViewSource(activeCitation)} + > + {activeCitation?.title || ""} +
+ + Favorite + +
+ +
+
+ ); +}; + +export default CitationPanel; diff --git a/ResearchAssistant/App/frontend/src/components/DraftDocumentsView/Card.tsx b/ResearchAssistant/App/frontend/src/components/DraftDocumentsView/Card.tsx index 6613bc4cd..d77533c95 100644 --- a/ResearchAssistant/App/frontend/src/components/DraftDocumentsView/Card.tsx +++ b/ResearchAssistant/App/frontend/src/components/DraftDocumentsView/Card.tsx @@ -37,7 +37,7 @@ const SystemErrMessage = 'I am sorry, I don’t have this information in the kno export const ResearchTopicCard = (): JSX.Element => { const [is_bad_request, set_is_bad_request] = useState(false) const appStateContext = useContext(AppStateContext) - const [open, setOpen] = React.useState(false) + const [open, setOpen] = useState(false) const callGenerateSectionContent = async (documentSection: DocumentSection) => { if (appStateContext?.state.researchTopic === undefined || appStateContext?.state.researchTopic === '') { @@ -156,7 +156,7 @@ interface CardProps { export const Card = (props: CardProps) => { const appStateContext = useContext(AppStateContext) - const [isPopoverOpen, setIsPopoverOpen] = React.useState(false) + const [isPopoverOpen, setIsPopoverOpen] = useState(false) const index: number = props.index const sectionInformation: DocumentSection | undefined = appStateContext?.state.documentSections?.[index] const [loading, setLoading] = useState(false) diff --git a/ResearchAssistant/App/frontend/src/components/Homepage/Cards.test.tsx b/ResearchAssistant/App/frontend/src/components/Homepage/Cards.test.tsx new file mode 100644 index 000000000..f7139a0bd --- /dev/null +++ b/ResearchAssistant/App/frontend/src/components/Homepage/Cards.test.tsx @@ -0,0 +1,57 @@ +import React from 'react'; +import { renderWithContext, mockDispatch, defaultMockState } from '../../test/test.utils'; +import { FeatureCard, TextFieldCard } from './Cards'; +import { screen, fireEvent } from '@testing-library/react'; +import { SidebarOptions } from '../SidebarView/SidebarView'; +import { TextField } from '@fluentui/react/lib/TextField'; + +// Mock icon for testing +const MockIcon = () =>
Mock Icon
; + +describe('FeatureCard', () => { + const mockProps = { + title: 'Test Feature', + description: 'This is a test feature description', + icon: , + featureSelection: SidebarOptions.Article, + }; + + it('renders FeatureCard correctly', () => { + renderWithContext(); + expect(screen.getByText('Test Feature')).toBeInTheDocument(); + expect(screen.getByText('This is a test feature description')).toBeInTheDocument(); + expect(screen.getByText('Mock Icon')).toBeInTheDocument(); + }); + + it('calls dispatch with correct payload when clicked', () => { + renderWithContext(); + const cardElement = screen.getByText('Test Feature').closest('div'); + fireEvent.click(cardElement!); + expect(mockDispatch).toHaveBeenCalledWith({ + type: 'UPDATE_SIDEBAR_SELECTION', + payload: SidebarOptions.Article, + }); + }); +}); + +describe('TextFieldCard', () => { + it('renders TextFieldCard with initial state', () => { + renderWithContext(); + expect(screen.getByText('Topic')).toBeInTheDocument(); + expect(screen.getByText('Enter an initial prompt that will exist across all three modes, Articles, Grants, and Drafts.')).toBeInTheDocument(); + expect(screen.getByPlaceholderText('Research Topic')).toHaveValue(defaultMockState.researchTopic); + }); + + it('updates research topic on text input', () => { + const updatedTopic = 'New Research Topic'; + renderWithContext(); + const input = screen.getByPlaceholderText('Research Topic'); + + fireEvent.change(input, { target: { value: updatedTopic } }); + + expect(mockDispatch).toHaveBeenCalledWith({ + type: 'UPDATE_RESEARCH_TOPIC', + payload: updatedTopic, + }); + }); +}); diff --git a/ResearchAssistant/App/frontend/src/components/QuestionInput/QuestionInput.test.tsx b/ResearchAssistant/App/frontend/src/components/QuestionInput/QuestionInput.test.tsx new file mode 100644 index 000000000..582cd30d1 --- /dev/null +++ b/ResearchAssistant/App/frontend/src/components/QuestionInput/QuestionInput.test.tsx @@ -0,0 +1,176 @@ +import { render, screen, fireEvent } from "@testing-library/react"; +import { QuestionInput } from "./QuestionInput"; +import { + renderWithContext, + mockDispatch, + defaultMockState, +} from "../../test/test.utils"; +import React from "react"; +const mockOnSend = jest.fn(); +const documentSectionData = [ + { + title: "Introduction", + content: "This is the introduction section.", + metaPrompt: "Meta for Introduction", + }, + { + title: "Methods", + content: "Methods content here.", + metaPrompt: "Meta for Methods", + }, +]; + +const renderComponent = (props = {}) => { + return renderWithContext( + + ); +}; + +describe("QuestionInput Component", () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + test("renders correctly with placeholder", () => { + render( + + ); + expect(screen.getByPlaceholderText("Ask a question")).toBeInTheDocument(); + }); + + test("does not call onSend when disabled", () => { + render( + + ); + const input = screen.getByPlaceholderText("Ask a question"); + fireEvent.change(input, { target: { value: "Test question" } }); + fireEvent.keyDown(input, { key: "Enter", code: "Enter", charCode: 13 }); + expect(mockOnSend).not.toHaveBeenCalled(); + }); + test("calls onSend with question and conversationId when enter is pressed", () => { + render( + + ); + const input = screen.getByPlaceholderText("Ask a question"); + fireEvent.change(input, { target: { value: "Test question" } }); + fireEvent.keyDown(input, { key: "Enter", code: "Enter", charCode: 13 }); + expect(mockOnSend).toHaveBeenCalledWith("Test question", "123"); + }); + test("clears question input if clearOnSend is true", () => { + render( + + ); + const input = screen.getByPlaceholderText("Ask a question"); + fireEvent.change(input, { target: { value: "Test question" } }); + fireEvent.keyDown(input, { key: "Enter", code: "Enter", charCode: 13 }); + expect(input).toHaveValue(""); + }); + test("does not clear question input if clearOnSend is false", () => { + render( + + ); + const input = screen.getByPlaceholderText("Ask a question"); + fireEvent.change(input, { target: { value: "Test question" } }); + fireEvent.keyDown(input, { key: "Enter", code: "Enter", charCode: 13 }); + expect(input).toHaveValue("Test question"); + }); + + test("calls onSend on send button click when not disabled", () => { + render( + + ); + const input = screen.getByPlaceholderText("Ask a question"); + fireEvent.change(input, { target: { value: "Test question" } }); + fireEvent.click(screen.getByRole("button")); + expect(mockOnSend).toHaveBeenCalledWith("Test question"); + }); + + it("should call sendQuestion on Enter key press", () => { + const { getByRole } = renderComponent(); + + const input = getByRole("textbox"); + + fireEvent.change(input, { target: { value: "Test question" } }); + fireEvent.keyDown(input, { key: "Enter", code: "Enter" }); + + expect(mockOnSend).toHaveBeenCalledWith("Test question"); + }); + + it("should not call sendQuestion on other key press via onKeyDown", () => { + const { getByRole } = renderComponent(); + + const input = getByRole("textbox"); + + fireEvent.change(input, { target: { value: "Test question" } }); + fireEvent.keyDown(input, { key: "a", code: "KeyA" }); + + expect(mockOnSend).not.toHaveBeenCalled(); + }); + + it("should not call sendQuestion if input is empty", () => { + const { getByRole } = renderComponent(); + + const input = getByRole("textbox"); + fireEvent.change(input, { target: { value: "" } }); + fireEvent.keyDown(input, { key: "Enter", code: "Enter" }); + + expect(mockOnSend).not.toHaveBeenCalled(); + }); + + it("should not call sendQuestion if disabled", () => { + const { getByRole } = renderComponent({ disabled: true }); + + const input = getByRole("textbox"); + fireEvent.change(input, { target: { value: "Test question" } }); + fireEvent.keyDown(input, { key: "Enter", code: "Enter" }); + + expect(mockOnSend).not.toHaveBeenCalled(); + }); + it("should set the initial question and dispatch when showInitialChatMessage is true", () => { + const mockState = { + ...defaultMockState, + showInitialChatMessage: true, + researchTopic: "Test Research Topic", + }; + + const { getByRole } = renderWithContext( + , + mockState + ); + + const input = getByRole("textbox"); + expect(input).toHaveValue("test research topic"); + + expect(mockDispatch).toHaveBeenCalledWith({ + type: "SET_SHOW_INITIAL_CHAT_MESSAGE_FLAG", + payload: false, + }); + }); +}); diff --git a/ResearchAssistant/App/frontend/src/components/QuestionInput/QuestionInput.tsx b/ResearchAssistant/App/frontend/src/components/QuestionInput/QuestionInput.tsx index 6270efe79..54ee95ab6 100644 --- a/ResearchAssistant/App/frontend/src/components/QuestionInput/QuestionInput.tsx +++ b/ResearchAssistant/App/frontend/src/components/QuestionInput/QuestionInput.tsx @@ -3,7 +3,7 @@ import { Stack, TextField } from "@fluentui/react"; import { SendRegular } from "@fluentui/react-icons"; import Send from "../../assets/Send.svg"; import styles from "./QuestionInput.module.css"; - +import React from 'react'; import { AppStateContext } from "../../state/AppProvider"; import { SidebarOptions } from "../SidebarView/SidebarView"; import { set } from "lodash"; @@ -80,7 +80,7 @@ export const QuestionInput = ({ onSend, disabled, placeholder, clearOnSend, conv tabIndex={0} aria-label="Ask question button" onClick={sendQuestion} - onKeyDown={e => e.key === "Enter" || e.key === " " ? sendQuestion() : null} + onKeyDown={e => e.key === "Enter" ? sendQuestion() : null} > { sendQuestionDisabled ? diff --git a/ResearchAssistant/App/frontend/src/components/SidebarView/ArticleView/ArticleView.test.tsx b/ResearchAssistant/App/frontend/src/components/SidebarView/ArticleView/ArticleView.test.tsx new file mode 100644 index 000000000..2ec14fbc5 --- /dev/null +++ b/ResearchAssistant/App/frontend/src/components/SidebarView/ArticleView/ArticleView.test.tsx @@ -0,0 +1,170 @@ + + +import React from 'react'; +import { renderWithContext, mockDispatch, defaultMockState } from '../../../test/test.utils'; +import { ArticleView } from './ArticleView'; +import { Citation } from '../../../api/models'; +import { fireEvent } from '@testing-library/react'; +import { RenderResult } from '@testing-library/react'; + +describe('ArticleView Component', () => { + const mockCitation: Citation = { + id: '1', + type: 'Articles', + title: 'Sample Article Title', + url: 'http://example.com', + content: 'Sample content', + filepath: null, + metadata: null, + chunk_id: null, + reindex_id: null, + }; + + const initialMockState = { + ...defaultMockState, + favoritedCitations: [mockCitation], + }; + + test('renders the "Favorites" header and close button', () => { + const { getByText, getByTitle }: RenderResult = renderWithContext(, initialMockState); + + expect(getByText('Favorites')).toBeInTheDocument(); + expect(getByTitle('close')).toBeInTheDocument(); + }); + + test('displays only article citations', () => { + const { getByText }: RenderResult = renderWithContext(, initialMockState); + + expect(getByText('Sample Article Title')).toBeInTheDocument(); + }); + + test('removes citation on click and dispatches an action', () => { + const { getByLabelText, queryByText }: RenderResult = renderWithContext(, initialMockState); + + const removeButton = getByLabelText('remove'); + fireEvent.click(removeButton); + + expect(mockDispatch).toHaveBeenCalledWith({ + type: 'TOGGLE_FAVORITE_CITATION', + payload: { citation: mockCitation }, + }); + + + expect(queryByText('Sample Article Title')).not.toBeInTheDocument(); + }); + + test('toggles the sidebar on close button click', () => { + const { getByTitle }: RenderResult = renderWithContext(, initialMockState); + + // Click the close button + const closeButton = getByTitle('close'); + fireEvent.click(closeButton); + + // Verify that the dispatch was called to toggle the sidebar + expect(mockDispatch).toHaveBeenCalledWith({ type: 'TOGGLE_SIDEBAR' }); + }); + + + test('renders multiple article citations', () => { + const additionalCitation: Citation = { + id: '2', + type: 'Articles', + title: 'Another Sample Article Title', + url: 'http://example2.com', + content: 'Sample content', + filepath: null, + metadata: null, + chunk_id: null, + reindex_id: null, + }; + + const multipleCitationsState = { + ...defaultMockState, + favoritedCitations: [mockCitation, additionalCitation], + }; + + const { getByText } = renderWithContext(, multipleCitationsState); + + + expect(getByText('Sample Article Title')).toBeInTheDocument(); + expect(getByText('Another Sample Article Title')).toBeInTheDocument(); + }); + test('truncates citation title after 5 words', () => { + const longTitleCitation: Citation = { + id: '5', + type: 'Articles', + title: 'This is a very long article title that exceeds five words', + url: 'http://example.com', + content: 'Sample content', + filepath: null, + metadata: null, + chunk_id: null, + reindex_id: null, + }; + + const stateWithLongTitleCitation = { + ...defaultMockState, + favoritedCitations: [longTitleCitation], + }; + + const { getByText } = renderWithContext(, stateWithLongTitleCitation); + + // Ensure that the title is truncated after 5 words + expect(getByText('This is a very long...')).toBeInTheDocument(); + }); + test('handles citation with no URL gracefully', () => { + const citationWithoutUrl: Citation = { + id: '4', + type: 'Articles', + title: 'Article with no URL', + url: '', // No URL + content: 'Sample content', + filepath: null, + metadata: null, + chunk_id: null, + reindex_id: null, + }; + + const stateWithCitationWithoutUrl = { + ...defaultMockState, + favoritedCitations: [citationWithoutUrl], + }; + + const { getByText, queryByRole } = renderWithContext(, stateWithCitationWithoutUrl); + + + expect(getByText('Article with no URL')).toBeInTheDocument(); + + + expect(queryByRole('link')).not.toBeInTheDocument(); + }); + + + + test('handles citation with no title gracefully', () => { + const citationWithoutTitle: Citation = { + id: '3', + type: 'Articles', + title: '', // No title + url: 'http://example3.com', + content: 'Sample content', + filepath: null, + metadata: null, + chunk_id: null, + reindex_id: null, + }; + + const stateWithCitationWithoutTitle = { + ...defaultMockState, + favoritedCitations: [citationWithoutTitle], + }; + + const { container } = renderWithContext(, stateWithCitationWithoutTitle); + + + const citationTitle = container.querySelector('span.css-113')?.textContent; + expect(citationTitle).toBeFalsy(); + }); + + +}); diff --git a/ResearchAssistant/App/frontend/src/components/SidebarView/GrantView/GrantView.test.tsx b/ResearchAssistant/App/frontend/src/components/SidebarView/GrantView/GrantView.test.tsx new file mode 100644 index 000000000..100cfcd5c --- /dev/null +++ b/ResearchAssistant/App/frontend/src/components/SidebarView/GrantView/GrantView.test.tsx @@ -0,0 +1,204 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { AppStateContext } from '../../../state/AppProvider'; +import { GrantView } from './GrantView'; +import { Citation } from '../../../api/models'; +const citationWithNoTitleOrUrl: Citation = { + id: '4', + title: '', + type: 'Grants', + url: '', + content: 'Content with no title or URL', + filepath: '/path/to/file', + metadata: " ", + chunk_id: 'chunk4', + reindex_id: 'reindex4', + }; + + const mockDispatch = jest.fn(); + + const appStateWithEmptyTitleAndUrl = { + state: { + favoritedCitations: [citationWithNoTitleOrUrl], + currentChat: null, + articlesChat: null, + grantsChat: null, + frontendSettings: null, + documentSections: null, + researchTopic: '', + isSidebarExpanded: false, + isChatViewOpen: true, + sidebarSelection: null, + showInitialChatMessage: true, + }, + dispatch: mockDispatch, + }; + +// Create full Citation mock data +const grantCitation: Citation = { + id: '1', + title: 'Grant 1 Title', + type: 'Grants', + url: 'http://grant1.com', + content: 'Grant content', + filepath: '/path/to/file', + metadata: " ", + chunk_id: 'chunk1', + reindex_id: 'reindex1', +}; + +const otherCitation: Citation = { + id: '2', + title: 'Other Title', + type: 'Other', + url: 'http://other.com', + content: 'Other content', + filepath: '/path/to/file', + metadata: " ", + chunk_id: 'chunk2', + reindex_id: 'reindex2', +}; + +const longTitleCitation: Citation = { + id: '3', + title: 'This is a very long title that should be truncated', + type: 'Grants', + url: 'http://longtitle.com', + content: 'Long title content', + filepath: '/path/to/file', + metadata: " ", + chunk_id: 'chunk3', + reindex_id: 'reindex3', +}; + + + +const mockAppStateWithGrants = { + state: { + favoritedCitations: [grantCitation, longTitleCitation], + currentChat: null, + articlesChat: null, + grantsChat: null, + frontendSettings: null, + documentSections: null, + researchTopic: '', + isSidebarExpanded: false, + isChatViewOpen: true, + sidebarSelection: null, + showInitialChatMessage: true, + }, + dispatch: mockDispatch, +}; + +const mockAppStateWithoutGrants = { + state: { + favoritedCitations: [otherCitation], + currentChat: null, + articlesChat: null, + grantsChat: null, + frontendSettings: null, + documentSections: null, + researchTopic: '', + isSidebarExpanded: false, + isChatViewOpen: true, + sidebarSelection: null, + showInitialChatMessage: true, + }, + dispatch: mockDispatch, +}; + +describe('GrantView', () => { + it('renders grant citations only', () => { + render( + + + + ); + + // Verify that only grant citations are rendered + expect(screen.getByText('Grant 1 Title')).toBeInTheDocument(); + expect(screen.queryByText('Other Title')).not.toBeInTheDocument(); + expect(screen.getByText((content) => content.startsWith('This is a very long'))).toBeInTheDocument(); + }); + + it('renders message when no grant citations are available', () => { + render( + + + + ); + + // Verify that no grant citations are rendered + expect(screen.queryByText('Grant 1 Title')).not.toBeInTheDocument(); + expect(screen.queryByText('This is a very long title that should be truncated')).not.toBeInTheDocument(); + // You can add a message for no grants, or leave this as it is + }); + + it('removes a citation when the remove button is clicked', () => { + render( + + + + ); + + // Click the first remove button + const removeButton = screen.getAllByTitle('remove')[0]; + fireEvent.click(removeButton); + + // Verify that the correct dispatch action is called + expect(mockDispatch).toHaveBeenCalledWith({ + type: 'TOGGLE_FAVORITE_CITATION', + payload: { citation: grantCitation }, + }); + }); + + it('dispatches the TOGGLE_SIDEBAR action when close button is clicked', () => { + render( + + + + ); + + // Click the close button + const closeButton = screen.getByTitle('close'); + fireEvent.click(closeButton); + + // Verify that the dispatch action is called + expect(mockDispatch).toHaveBeenCalledWith({ type: 'TOGGLE_SIDEBAR' }); + }); + it('renders correctly when no grants citations are available', () => { + const emptyAppState = { + ...mockAppStateWithoutGrants, + state: { ...mockAppStateWithoutGrants.state, favoritedCitations: [] }, + }; + render( + + + + ); + + // Check that nothing is displayed when there are no grants + expect(screen.queryByText('Grant 1 Title')).not.toBeInTheDocument(); + expect(screen.queryByText('This is a very long title that should be truncated')).not.toBeInTheDocument(); + // Optionally check if you want to render a specific message when no grants are found + }); + + it('dispatches the TOGGLE_SIDEBAR action when close button is clicked', () => { + render( + + + + ); + + // Simulate clicking the close button + const closeButton = screen.getByTitle('close'); + fireEvent.click(closeButton); + + // Check if the TOGGLE_SIDEBAR action was dispatched + expect(mockDispatch).toHaveBeenCalledWith({ type: 'TOGGLE_SIDEBAR' }); + }); + + + + +}); \ No newline at end of file diff --git a/ResearchAssistant/App/frontend/src/components/SidebarView/SidebarView.test.tsx b/ResearchAssistant/App/frontend/src/components/SidebarView/SidebarView.test.tsx new file mode 100644 index 000000000..8345d4d88 --- /dev/null +++ b/ResearchAssistant/App/frontend/src/components/SidebarView/SidebarView.test.tsx @@ -0,0 +1,149 @@ +import React from 'react'; +import { screen, fireEvent, waitFor } from '../../test/test.utils'; +import { SidebarView } from './SidebarView'; +import { renderWithContext, mockDispatch } from '../../test/test.utils'; +import { getUserInfo } from '../../api'; + +jest.mock('../../api', () => ({ + getUserInfo: jest.fn(() => + Promise.resolve([{ user_claims: [{ typ: 'name', val: 'John Doe' }] }]) + ), +})); + +describe('SidebarView', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders SidebarView with expanded sidebar and user info', async () => { + renderWithContext(, { isSidebarExpanded: true, sidebarSelection: 'Articles' }); + + await waitFor(() => { + expect(screen.getByText(/John Doe/i)).toBeInTheDocument(); + expect(screen.getByText(/Articles/i)).toBeInTheDocument(); + }); + }); + + it('toggles sidebar selection when icon is clicked', async () => { + renderWithContext(, { isSidebarExpanded: false, sidebarSelection: null }); + + const grantButton = screen.getByText(/Grants/i); + fireEvent.click(grantButton); + + expect(mockDispatch).toHaveBeenCalledWith({ + type: 'UPDATE_SIDEBAR_SELECTION', + payload: 'Grants', + }); + expect(mockDispatch).toHaveBeenCalledWith({ type: 'TOGGLE_SIDEBAR' }); + }); + + it('renders avatar with correct user name', async () => { + renderWithContext(, { isSidebarExpanded: true }); + + await waitFor(() => { + expect(screen.getByLabelText('User name')).toBeInTheDocument(); + expect(screen.getByText(/John Doe/i)).toBeInTheDocument(); + }); + }); + + it('handles API errors gracefully', async () => { + const consoleErrorMock = jest.spyOn(console, 'error').mockImplementation(() => {}); + + (getUserInfo as jest.Mock).mockRejectedValue(new Error('API Error')); + + renderWithContext(); + + await waitFor(() => { + expect(consoleErrorMock).toHaveBeenCalledWith('Error fetching user info: ', expect.any(Error)); + }); + + consoleErrorMock.mockRestore(); + }); + + it('handles empty user claims gracefully', async () => { + (getUserInfo as jest.Mock).mockResolvedValueOnce([{ user_claims: [] }]); + + renderWithContext(); + + await waitFor(() => { + expect(screen.getByLabelText('User name')).toBeInTheDocument(); + expect(screen.queryByText(/John Doe/i)).not.toBeInTheDocument(); + }); + }); + + it('renders ArticleView when Articles option is selected', () => { + renderWithContext(, { isSidebarExpanded: true, sidebarSelection: 'Articles' }); + + expect(screen.getByText(/Articles/i)).toBeInTheDocument(); + }); + + it('renders GrantView when Grants option is selected', () => { + renderWithContext(, { isSidebarExpanded: true, sidebarSelection: 'Grants' }); + + expect(screen.getByText(/Grants/i)).toBeInTheDocument(); + }); + + it('toggles sidebar when an option is clicked', () => { + renderWithContext(, { isSidebarExpanded: false, sidebarSelection: null }); + + const articleButton = screen.getByText(/Articles/i); + fireEvent.click(articleButton); + + expect(mockDispatch).toHaveBeenCalledWith({ type: 'UPDATE_SIDEBAR_SELECTION', payload: 'Articles' }); + expect(mockDispatch).toHaveBeenCalledWith({ type: 'TOGGLE_SIDEBAR' }); + }); + + it('renders collapsed sidebar', () => { + renderWithContext(, { isSidebarExpanded: false }); + + expect(screen.queryByText(/John Doe/i)).not.toBeInTheDocument(); + }); + + it('renders DraftDocumentsView when Draft option is selected', () => { + renderWithContext(, { isSidebarExpanded: true, sidebarSelection: 'Draft' }); + + const draftElements = screen.getAllByText(/Draft/i); + const sidebarDraftOption = draftElements.find(element => element.tagName === 'SPAN'); + + expect(sidebarDraftOption).toBeInTheDocument(); + }); + + it('does not render selected view when sidebar is collapsed', () => { + renderWithContext(, { isSidebarExpanded: false, sidebarSelection: 'Articles' }); + + + expect(screen.queryByText(/Article details/i)).not.toBeInTheDocument(); + }); + + it('dispatches TOGGLE_SIDEBAR when DraftDocuments option is clicked and sidebar is expanded', () => { + renderWithContext(, { isSidebarExpanded: true, sidebarSelection: null }); + + const draftButtons = screen.getAllByText(/Draft/i); + fireEvent.click(draftButtons[0]); + + + expect(mockDispatch).toHaveBeenCalledWith({ type: 'UPDATE_SIDEBAR_SELECTION', payload: 'Draft' }); + expect(mockDispatch).toHaveBeenCalledWith({ type: 'TOGGLE_SIDEBAR' }); +}); +it('dispatches TOGGLE_SIDEBAR when any option other than DraftDocuments is clicked', async () => { + renderWithContext(, { isSidebarExpanded: true, sidebarSelection: 'Articles' }); + + const grantButton = screen.getByText(/Grants/i); + fireEvent.click(grantButton); + + expect(mockDispatch).toHaveBeenCalledWith({ type: 'UPDATE_SIDEBAR_SELECTION', payload: 'Grants' }); + + expect(mockDispatch).not.toHaveBeenCalledWith({ type: 'TOGGLE_SIDEBAR' }); +}); + + + + it('does not dispatch TOGGLE_SIDEBAR when DraftDocuments is selected and clicked again', () => { + renderWithContext(, { isSidebarExpanded: true, sidebarSelection: 'Draft' }); + + const draftButtons = screen.getAllByText(/Draft/i); + fireEvent.click(draftButtons[0]); + + expect(mockDispatch).not.toHaveBeenCalledWith({ type: 'TOGGLE_SIDEBAR' }); + }); +}); diff --git a/ResearchAssistant/App/frontend/src/pages/Homepage/Homepage.test.tsx b/ResearchAssistant/App/frontend/src/pages/Homepage/Homepage.test.tsx new file mode 100644 index 000000000..74d962227 --- /dev/null +++ b/ResearchAssistant/App/frontend/src/pages/Homepage/Homepage.test.tsx @@ -0,0 +1,106 @@ + +import React from 'react' +import { render, screen } from '@testing-library/react' +import Homepage from './Homepage' +import { type SidebarOptions } from '../../components/SidebarView/SidebarView' +// Mock the icon +jest.mock('../../assets/RV-Copilot.svg', () => 'mocked-icon-path.svg') +// Mock the child components +jest.mock('../../components/Homepage/Cards', () => ({ + FeatureCard: ({ title, description, featureSelection, icon }: { title: string, description: string, featureSelection: SidebarOptions, icon: JSX.Element }) => ( +
+ {title} - {description} - {featureSelection} - {icon} +
+ ), + TextFieldCard: () =>
Mocked TextFieldCard
+})) + +jest.mock('@fluentui/react-icons', () => ({ + NewsRegular: ({ style }: { style: React.CSSProperties }) => ( +
News Icon
+ ), + BookRegular: () =>
Book Icon
, + NotepadRegular: () =>
Notepad Icon
+})) + +jest.mock('@fluentui/react-components', () => ({ + Body1Strong: ({ children }: { children: React.ReactNode }) =>
{children}
+})) + +jest.mock('../../components/SidebarView/SidebarView', () => ({ + SidebarOptions: { + Article: 'Article', + Grant: 'Grant', + DraftDocuments: 'DraftDocuments' + } +})) +describe('Homepage Component', () => { + beforeEach(() => { + // Mock window.matchMedia + window.matchMedia = jest.fn().mockImplementation(query => ({ + matches: query === '(max-width:320px)', + media: query, + onchange: null, + addListener: jest.fn(), // deprecated + removeListener: jest.fn(), // deprecated + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + dispatchEvent: jest.fn() + })) + }) + test('renders Homepage component correctly', () => { + render() + + // Check if the main elements are rendered + expect(screen.getByAltText('App Icon')).toBeInTheDocument() + expect(screen.getByText('Grant')).toBeInTheDocument() + expect(screen.getByText('Writer')).toBeInTheDocument() + expect(screen.getByText('AI-powered assistant for research acceleration')).toBeInTheDocument() + + // Check if the mocked TextFieldCard is rendered + expect(screen.getByTestId('mocked-text-field-card')).toBeInTheDocument() + + // Check if the mocked FeatureCards are rendered with correct props + expect(screen.getByText('Explore scientific journals - Explore the PubMed article database for relevant scientific data - Article')).toBeInTheDocument() + expect(screen.getByText('Explore grant opportunities - Explore the PubMed grant database for available announcements - Grant')).toBeInTheDocument() + expect(screen.getByText('Draft a grant proposal - Assist in writing a comprehesive grant proposal for your research project - DraftDocuments')).toBeInTheDocument() + }) + + test('renders correctly with large screen size', () => { + window.matchMedia = jest.fn().mockImplementation(query => ({ + matches: query === '(max-width:480px)', + media: query, + onchange: null, + addListener: jest.fn(), // deprecated + removeListener: jest.fn(), // deprecated + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + dispatchEvent: jest.fn() + })) + render() + + // Check if the NewsRegular icon has the correct style for large screens + const newsIcon = screen.getByTestId('mocked-news-icon') + expect(newsIcon).toHaveStyle({ minWidth: '48px', minHeight: '48px' }) + }) + + test('renders correctly with small screen size', () => { + // Mock window.matchMedia to return true for small screen size + window.matchMedia = jest.fn().mockImplementation(query => ({ + matches: query === '(max-width:320px)', + media: query, + onchange: null, + addListener: jest.fn(), // deprecated + removeListener: jest.fn(), // deprecated + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + dispatchEvent: jest.fn() + })) + + render() + + // Check if the NewsRegular icon has the correct style for small screens + const newsIcon = screen.getByTestId('mocked-news-icon') + expect(newsIcon).toHaveStyle({ minWidth: '1rem', minHeight: '1rem' }) + }) +}) diff --git a/ResearchAssistant/App/frontend/src/pages/NoPage.test.tsx b/ResearchAssistant/App/frontend/src/pages/NoPage.test.tsx new file mode 100644 index 000000000..744f10b85 --- /dev/null +++ b/ResearchAssistant/App/frontend/src/pages/NoPage.test.tsx @@ -0,0 +1,14 @@ +import React from 'react' +import { render, screen } from '@testing-library/react' +import NoPage from './NoPage' + +describe('NoPage Component', () => { + test('renders 404 heading', () => { + render() + + const headingElement = screen.getByRole('heading', { level: 1 }) + + expect(headingElement).toBeInTheDocument() + expect(headingElement).toHaveTextContent('404') + }) +}) diff --git a/ResearchAssistant/App/frontend/src/pages/chat/Chat.module.css b/ResearchAssistant/App/frontend/src/pages/chat/Chat.module.css index 3d7ede5d4..de7603e23 100644 --- a/ResearchAssistant/App/frontend/src/pages/chat/Chat.module.css +++ b/ResearchAssistant/App/frontend/src/pages/chat/Chat.module.css @@ -27,6 +27,16 @@ max-height: calc(100vh - 100px); } +.chatContainer > h2 { + color: #72716f; + margin-left: 15px; + margin-top: 25px; + align-self: start; + font-weight: 600; + font-family: "Segoe UI", "Segoe UI Web (West European)", "Segoe UI", -apple-system, BlinkMacSystemFont, Roboto, "Helvetica Neue", sans-serif; + font-size : 20px; +} + .chatEmptyState { flex-grow: 1; display: flex; @@ -77,60 +87,6 @@ margin-top: 24px; } -.chatMessageUser { - display: flex; - justify-content: flex-end; - margin-bottom: 12px; -} - -.chatMessageUserMessage { - padding: 20px; - background: #EDF5FD; - border-radius: 8px; - box-shadow: 0px 2px 4px rgba(0, 0, 0, 0.14), 0px 0px 2px rgba(0, 0, 0, 0.12); - font-style: normal; - font-weight: 400; - font-size: 14px; - line-height: 22px; - color: #242424; - flex: none; - order: 0; - flex-grow: 0; - white-space: pre-wrap; - word-wrap: break-word; - max-width: 800px; -} - -.chatMessageGpt { - margin-bottom: 12px; - max-width: 80%; - display: flex; -} - -.chatMessageError { - padding: 20px; - border-radius: 8px; - box-shadow: rgba(182, 52, 67, 1) 1px 1px 2px, rgba(182, 52, 67, 1) 0px 0px 1px; - color: #242424; - flex: none; - order: 0; - flex-grow: 0; - max-width: 800px; - margin-bottom: 12px; -} - -.chatMessageErrorContent { - font-family: "Segoe UI"; - font-style: normal; - font-weight: 400; - font-size: 14px; - line-height: 22px; - white-space: pre-wrap; - word-wrap: break-word; - gap: 12px; - align-items: center; -} - .chatInput { position: sticky; flex: 0 0 100px; @@ -327,7 +283,7 @@ a { /* high constrat */ @media screen and (-ms-high-contrast: active), (forced-colors: active) { - .clearChatBroomNoCosmos, .chatMessageStream, .chatMessageUserMessage, .citationPanel{ + .clearChatBroomNoCosmos, .chatMessageStream { border: 2px solid WindowText;padding: 10px; background-color: Window; color: WindowText; diff --git a/ResearchAssistant/App/frontend/src/pages/chat/Chat.test.tsx b/ResearchAssistant/App/frontend/src/pages/chat/Chat.test.tsx new file mode 100644 index 000000000..97589e76a --- /dev/null +++ b/ResearchAssistant/App/frontend/src/pages/chat/Chat.test.tsx @@ -0,0 +1,648 @@ +import { screen, fireEvent } from "@testing-library/react"; +import { SidebarOptions } from "../../components/SidebarView/SidebarView"; +import Chat from "./Chat"; +import { + defaultMockState, + renderWithContext, + renderWithNoContext, + mockAppContextStateProvider, + delay, +} from "../../test/test.utils"; +import { act } from "react-dom/test-utils"; + +import * as api from "../../api"; +import { + citationObj, + conversationResponseWithExceptionFromAI, + currentChat, + enterKeyCodes, + escapeKeyCodes, + expectedUpdateCurrentChatActionPayload, + firstQuestion, + mockedUsersData, + simpleConversationResponse, + simpleConversationResponseWithCitations, + simpleConversationResponseWithEmptyChunk, + spaceKeyCodes, +} from "../../../__mocks__/SampleData"; + +jest.mock("../../api", () => ({ + conversationApi: jest.fn(), + getUserInfo: jest.fn(), +})); + +const mockConversationApi = api.conversationApi as jest.Mock; +const mockGetUserInfo = api.getUserInfo as jest.Mock; + +const createMockConversationAPI = () => { + mockConversationApi.mockResolvedValueOnce({ + body: { + getReader: jest.fn().mockReturnValue({ + read: jest + .fn() + .mockResolvedValueOnce({ + done: false, + value: new TextEncoder().encode( + JSON.stringify(simpleConversationResponseWithCitations) + ), + }) + .mockResolvedValueOnce({ + done: false, + value: new TextEncoder().encode( + JSON.stringify(simpleConversationResponseWithEmptyChunk) + ), + }) + .mockResolvedValueOnce({ + done: false, + value: new TextEncoder().encode( + JSON.stringify(simpleConversationResponse) + ), + }) + .mockResolvedValueOnce({ done: true }), // Mark the stream as done + }), + }, + }); +}; + +const createMockConversationWithDelay = () => { + mockConversationApi.mockResolvedValueOnce({ + body: { + getReader: jest.fn().mockReturnValue({ + read: jest + .fn() + .mockResolvedValueOnce( + delay(5000).then(() => ({ + done: false, + value: new TextEncoder().encode( + JSON.stringify(simpleConversationResponseWithCitations) + ), + })) + ) + .mockResolvedValueOnce({ + done: false, + value: new TextEncoder().encode( + JSON.stringify(simpleConversationResponseWithEmptyChunk) + ), + }) + .mockResolvedValueOnce({ + done: false, + value: new TextEncoder().encode( + JSON.stringify(simpleConversationResponse) + ), + }) + .mockResolvedValueOnce({ done: true }), // Mark the stream as done + }), + }, + }); +}; + +const createMockConversationAPIWithError = () => { + mockConversationApi.mockResolvedValueOnce({ + body: { + getReader: jest.fn().mockReturnValue({ + read: jest + .fn() + .mockResolvedValueOnce({ + done: false, + value: new TextEncoder().encode( + JSON.stringify(conversationResponseWithExceptionFromAI) + ), + }) + .mockResolvedValueOnce({ done: true }), // Mark the stream as done + }), + }, + }); +}; + +const createMockConversationAPIWithErrorInReader = () => { + mockConversationApi.mockResolvedValueOnce({ + body: { + getReader: jest.fn().mockReturnValue({}), + }, + }); +}; + +const createMockGetUsersAPI = () => { + mockGetUserInfo.mockResolvedValue(mockedUsersData); +}; + +jest.mock("../../components/SidebarView/SidebarView", () => ({ + SidebarView: () =>
Mocked SidebarView
, + SidebarOptions: { + DraftDocuments: "DraftDocuments", + Grant: "Grant", + Article: "Article", + }, +})); + +jest.mock( + "../../components/CitationPanel/CitationPanel", + () => (props: any) => { + const { onClickAddFavorite, onViewSource } = props; + return ( +
+
+ Citation Panel Component +
+
onClickAddFavorite()}> + Add Citation to Favorite +
+
onViewSource(citationObj)} + > + View Source +
+
+ ); + } +); + +jest.mock( + "../../components/ChatMessageContainer/ChatMessageContainer", + () => + (props: { + messages: any; + onShowCitation: any; + showLoadingMessage: any; + }) => { + const [ASSISTANT, TOOL, ERROR, USER] = [ + "assistant", + "tool", + "error", + "user", + ]; + const { messages, onShowCitation } = props; + + return ( +
+
ChatMessage Container Component
+ {messages.map((answer: any, index: number) => ( +
+ {answer.role === USER ? ( +
{answer.content}
+ ) : answer.role === ASSISTANT ? ( +
+
{answer.content}
+
+ ) : answer.role === ERROR ? ( +
+ Error + {answer.content} +
+ ) : null} +
+ ))} + +
+ ); + } +); + +jest.mock("../../components/QuestionInput", () => ({ + QuestionInput: (props: any) => { + return ( +
+
Question Input Component
+
props.onSend(firstQuestion, props.conversationId)} + > + submit-first-question +
+
props.onSend("Hello", "some-temp-conversation-id")} + > + submit-second-question +
+
+ ); + }, +})); + +const renderComponent = ( + props: { chatType: SidebarOptions | null | undefined }, + contextData = {}, + mockDispatch: any +) => { + return renderWithContext( + , + contextData, + mockDispatch + ); +}; + +const renderComponentWithNoContext = (props: { + chatType: SidebarOptions | null | undefined; +}) => { + return () => renderWithNoContext(); +}; + +describe("Chat Component", () => { + let mockDispatch = jest.fn(); + beforeEach(() => { + jest.clearAllMocks(); + Element.prototype.scrollIntoView = jest.fn(); + mockDispatch = jest.fn(); + window.open = jest.fn(); + mockConversationApi.mockClear(); + mockGetUserInfo.mockClear(); + }); + afterEach(() => { + jest.restoreAllMocks(); + jest.clearAllTimers(); + mockConversationApi.mockReset(); + mockGetUserInfo.mockReset(); + }); + + test("should show 'Explore scientific journals header' for Articles", () => { + const contextData = { sidebarSelection: SidebarOptions?.Article }; + const { getByRole } = renderComponent( + { chatType: SidebarOptions.Article }, + contextData, + mockDispatch + ); + const h2Element = getByRole("heading", { level: 2 }); + expect(h2Element).toHaveTextContent("Explore scientific journals"); + }); + + test("should show 'Explore grant documents' header for Articles", () => { + const contextData = { sidebarSelection: SidebarOptions?.Grant }; + const { getByRole } = renderComponent( + { chatType: SidebarOptions.Grant }, + contextData, + mockDispatch + ); + const h2Element = getByRole("heading", { level: 2 }); + expect(h2Element).toHaveTextContent("Explore grant documents"); + }); + + test("should call userinfo list api when frontend setting auth enabled", async () => { + const contextData = { + sidebarSelection: SidebarOptions?.Article, + frontendSettings: { auth_enabled: "false" }, + }; + const contextDataUpdated = { + sidebarSelection: SidebarOptions?.Article, + frontendSettings: { auth_enabled: "true" }, + }; + createMockGetUsersAPI(); + const { rerender } = renderComponent( + { chatType: SidebarOptions.Article }, + contextData, + mockDispatch + ); + const state = { ...defaultMockState, ...contextDataUpdated }; + rerender( + mockAppContextStateProvider( + state, + mockDispatch, + + ) + ); + const streamMessage = await screen.findByTestId("chat-stream-end"); + expect(streamMessage).toBeInTheDocument(); + }); + + test("Should be able to stop the generation by clicking Stop Generating btn", async () => { + createMockConversationWithDelay(); + const contextData = { sidebarSelection: SidebarOptions?.Article }; + const { getByTestId } = renderComponent( + { chatType: SidebarOptions.Article }, + contextData, + mockDispatch + ); + const submitQuestionElement = getByTestId("submit-first-question"); + act(() => { + fireEvent.click(submitQuestionElement); + }); + + expect(await screen.findByText("Stop generating")).toBeInTheDocument(); + const stopGeneratingBtn = screen.getByRole("button", { + name: "Stop generating", + }); + expect(stopGeneratingBtn).toBeInTheDocument(); + + await act(async () => { + fireEvent.click(stopGeneratingBtn); + }); + const stopGeneratingBtnAfterClick = screen.queryByRole("button", { + name: "Stop generating", + }); + expect(stopGeneratingBtnAfterClick).not.toBeInTheDocument(); + }); + + test("Should be able to stop the generation by Focus and Triggering Enter in Keyboard", async () => { + createMockConversationWithDelay(); + const contextData = { sidebarSelection: SidebarOptions?.Article }; + const { getByTestId } = renderComponent( + { chatType: SidebarOptions.Article }, + contextData, + mockDispatch + ); + const submitQuestionElement = getByTestId("submit-first-question"); + act(() => { + fireEvent.click(submitQuestionElement); + }); + + expect(await screen.findByText("Stop generating")).toBeInTheDocument(); + const stopGeneratingBtn = screen.getByRole("button", { + name: "Stop generating", + }); + expect(stopGeneratingBtn).toBeInTheDocument(); + + await act(async () => { + stopGeneratingBtn.focus(); + fireEvent.keyDown(stopGeneratingBtn, enterKeyCodes); + }); + const stopGeneratingBtnAfterClick = screen.queryByRole("button", { + name: "Stop generating", + }); + expect(stopGeneratingBtnAfterClick).not.toBeInTheDocument(); + }); + + test("Should be able to stop the generation by Focus and Triggering Space in Keyboard", async () => { + createMockConversationWithDelay(); + const contextData = { sidebarSelection: SidebarOptions?.Article }; + const { getByTestId } = renderComponent( + { chatType: SidebarOptions.Article }, + contextData, + mockDispatch + ); + const submitQuestionElement = getByTestId("submit-first-question"); + act(() => { + fireEvent.click(submitQuestionElement); + }); + + expect(await screen.findByText("Stop generating")).toBeInTheDocument(); + const stopGeneratingBtn = screen.getByRole("button", { + name: "Stop generating", + }); + expect(stopGeneratingBtn).toBeInTheDocument(); + + await act(async () => { + stopGeneratingBtn.focus(); + fireEvent.keyDown(stopGeneratingBtn, spaceKeyCodes); + }); + const stopGeneratingBtnAfterClick = screen.queryByRole("button", { + name: "Stop generating", + }); + expect(stopGeneratingBtnAfterClick).not.toBeInTheDocument(); + }); + + test("Focus on Stop generating btn and Triggering Any key other than Enter/Space should not hide the Stop Generating btn", async () => { + createMockConversationWithDelay(); + const contextData = { sidebarSelection: SidebarOptions?.Article }; + const { getByTestId } = renderComponent( + { chatType: SidebarOptions.Article }, + contextData, + mockDispatch + ); + const submitQuestionElement = getByTestId("submit-first-question"); + act(() => { + fireEvent.click(submitQuestionElement); + }); + + expect(await screen.findByText("Stop generating")).toBeInTheDocument(); + const stopGeneratingBtn = screen.getByRole("button", { + name: "Stop generating", + }); + expect(stopGeneratingBtn).toBeInTheDocument(); + + await act(async () => { + stopGeneratingBtn.focus(); + fireEvent.keyDown(stopGeneratingBtn, escapeKeyCodes); + }); + const stopGeneratingBtnAfterClick = screen.queryByRole("button", { + name: "Stop generating", + }); + expect(stopGeneratingBtnAfterClick).toBeInTheDocument(); + }); + + test("on user sends first question should handle conversation API call", async () => { + createMockConversationAPI(); + const contextData = { sidebarSelection: SidebarOptions?.Article }; + const { getByTestId } = renderComponent( + { chatType: SidebarOptions.Article }, + contextData, + mockDispatch + ); + + const submitQuestionElement = getByTestId("submit-first-question"); + await act(async () => { + fireEvent.click(submitQuestionElement); + }); + + expect(mockDispatch).toHaveBeenCalledWith({ + type: "UPDATE_CURRENT_CHAT", + payload: expectedUpdateCurrentChatActionPayload, + }); + }); + + test("on user sends second question (with conversation id) but conversation not exist should handle", async () => { + createMockConversationAPI(); + const consoleErrorMock = jest + .spyOn(console, "error") + .mockImplementation(() => {}); + + const contextData = { + sidebarSelection: SidebarOptions?.Article, + }; + + const { getByTestId } = renderComponent( + { chatType: SidebarOptions.Article }, + contextData, + mockDispatch + ); + const submitQuestionElement = getByTestId("submit-second-question"); + + await act(async () => { + fireEvent.click(submitQuestionElement); + }); + + expect(consoleErrorMock).toHaveBeenCalled(); + }); + + test("should handle API call when sends question conv Id exists and previous conversation chat exists ", async () => { + createMockConversationAPI(); + const contextData = { + sidebarSelection: SidebarOptions?.Article, + currentChat: currentChat, + }; + const { getByText, getByTestId } = renderComponent( + { chatType: SidebarOptions.Article }, + contextData, + mockDispatch + ); + const submitQuestionElement = getByTestId("submit-first-question"); + await act(async () => { + fireEvent.click(submitQuestionElement); + }); + const responseTextElement = getByText(/AI response for user question/i); + expect(responseTextElement).toBeInTheDocument(); + }); + + test("on Click Clear button messages should be empty", async () => { + createMockConversationAPI(); + const contextData = { sidebarSelection: SidebarOptions?.Article }; + const { getByRole, getByTestId, queryAllByTestId } = renderComponent( + { chatType: SidebarOptions.Article }, + contextData, + mockDispatch + ); + const submitQuestionElement = getByTestId("submit-first-question"); + await act(async () => { + fireEvent.click(submitQuestionElement); + }); + const chatElementsBeforeClear = queryAllByTestId("chat-message-item"); + expect(chatElementsBeforeClear.length).toBeGreaterThan(0); + + const clearChatButton = getByRole("button", { name: "clear chat button" }); + expect(clearChatButton).toBeInTheDocument(); + + await act(async () => { + fireEvent.click(clearChatButton); + }); + const chatElementsAfterClear = queryAllByTestId("chat-message-item"); + expect(chatElementsAfterClear.length).toEqual(0); + }); + + test("Exception in AI response should handle properly", async () => { + createMockConversationAPIWithError(); + const contextData = { sidebarSelection: SidebarOptions?.Article }; + const { getByTestId } = renderComponent( + { chatType: SidebarOptions.Article }, + contextData, + mockDispatch + ); + const submitQuestionElement = getByTestId("submit-first-question"); + await act(async () => { + fireEvent.click(submitQuestionElement); + }); + const errorContent = getByTestId("error-content"); + expect(errorContent).toBeInTheDocument(); + }); + + test("If Error in response body or reader not available should handle properly with error message", async () => { + createMockConversationAPIWithErrorInReader(); + const contextData = { sidebarSelection: SidebarOptions?.Article }; + const { getByText, getByTestId } = renderComponent( + { chatType: SidebarOptions.Article }, + contextData, + mockDispatch + ); + const submitQuestionElement = getByTestId("submit-first-question"); + await act(async () => { + fireEvent.click(submitQuestionElement); + }); + const errorTextElement = getByText( + "An error occurred. Please try again. If the problem persists, please contact the site administrator." + ); + expect(errorTextElement).toBeInTheDocument(); + }); + test("On Click citation should show citation panel", async () => { + createMockConversationAPI(); + const contextData = { sidebarSelection: SidebarOptions?.Article }; + const { getByTestId } = renderComponent( + { chatType: SidebarOptions.Article }, + contextData, + mockDispatch + ); + const submitQuestionElement = getByTestId("submit-first-question"); + act(() => { + fireEvent.click(submitQuestionElement); + }); + + const showCitationButton = getByTestId("show-citation-btn"); + await act(async () => { + fireEvent.click(showCitationButton); + }); + + expect( + await screen.findByTestId("citation-panel-component") + ).toBeInTheDocument(); + }); + + test("On Click view Source in citation panel should open url", async () => { + createMockConversationAPI(); + const contextData = { sidebarSelection: SidebarOptions?.Article }; + const { getByTestId } = renderComponent( + { chatType: SidebarOptions.Article }, + contextData, + mockDispatch + ); + const submitQuestionElement = getByTestId("submit-first-question"); + act(() => { + fireEvent.click(submitQuestionElement); + }); + + const showCitationButton = getByTestId("show-citation-btn"); + await act(async () => { + fireEvent.click(showCitationButton); + }); + + expect( + await screen.findByTestId("citation-panel-component") + ).toBeInTheDocument(); + + const viewSourceButton = getByTestId("view-source"); + fireEvent.click(viewSourceButton); + expect(window.open).toHaveBeenCalledTimes(1); + }); + + test("rendering with no context should throw an error", async () => { + const renderedChat = renderComponentWithNoContext({ + chatType: SidebarOptions.Article, + }); + const consoleErrorMock = jest + .spyOn(console, "error") + .mockImplementation(() => {}); + + expect(renderedChat).toThrow( + "AppStateContext is undefined. Make sure you have wrapped your component tree with AppStateProvider." + ); + expect(consoleErrorMock).toHaveBeenCalled(); + }); + + test("After view Citation Should be able to add to Favorite ", async () => { + createMockConversationAPI(); + const contextData = { sidebarSelection: SidebarOptions?.Article }; + const { getByTestId } = renderComponent( + { chatType: SidebarOptions.Article }, + contextData, + mockDispatch + ); + const submitQuestionElement = getByTestId("submit-first-question"); + act(() => { + fireEvent.click(submitQuestionElement); + }); + + const showCitationButton = getByTestId("show-citation-btn"); + await act(async () => { + fireEvent.click(showCitationButton); + }); + + expect( + await screen.findByTestId("citation-panel-component") + ).toBeInTheDocument(); + + const addFavoriteBtn = getByTestId("add-favorite"); + fireEvent.click(addFavoriteBtn); + + expect(mockDispatch).toHaveBeenCalledWith({ + type: "UPDATE_ARTICLES_CHAT", + payload: null, + }); + expect(mockDispatch).toHaveBeenCalledTimes(5); + }); +}); diff --git a/ResearchAssistant/App/frontend/src/pages/chat/Chat.tsx b/ResearchAssistant/App/frontend/src/pages/chat/Chat.tsx index e7caaa0d5..dd7bae88b 100644 --- a/ResearchAssistant/App/frontend/src/pages/chat/Chat.tsx +++ b/ResearchAssistant/App/frontend/src/pages/chat/Chat.tsx @@ -1,11 +1,7 @@ /* eslint-disable react/react-in-jsx-scope */ import { useRef, useState, useEffect, useContext, useLayoutEffect } from 'react' -import { Text, CommandBarButton, IconButton, Dialog, DialogType, Stack } from '@fluentui/react' -import { PrimaryButton } from '@fluentui/react/lib/Button'; -import { SquareRegular, ErrorCircleRegular } from '@fluentui/react-icons' -import ReactMarkdown from 'react-markdown' -import remarkGfm from 'remark-gfm' -import rehypeRaw from 'rehype-raw' +import { CommandBarButton, Dialog, DialogType, Stack } from '@fluentui/react' +import { SquareRegular } from '@fluentui/react-icons' import uuid from 'react-uuid' import { isEmpty } from 'lodash-es' @@ -22,18 +18,41 @@ import { type Conversation, type ErrorMessage } from '../../api' -import { Answer } from '../../components/Answer' import { QuestionInput } from '../../components/QuestionInput' import { AppStateContext } from '../../state/AppProvider' import { useBoolean } from '@fluentui/react-hooks' import { SidebarOptions } from '../../components/SidebarView/SidebarView' +import CitationPanel from '../../components/CitationPanel/CitationPanel'; +import ChatMessageContainer from '../../components/ChatMessageContainer/ChatMessageContainer'; const enum messageStatus { NotRunning = 'Not Running', Processing = 'Processing', Done = 'Done' } - +const clearButtonStyles = { + icon: { + color: '#FFFFFF' + }, + iconDisabled: { + color: '#BDBDBD !important' + }, + root: { + color: '#FFFFFF', + background: '#0F6CBD', + borderRadius: '100px' + }, + rootDisabled: { + background: '#F0F0F0' + }, + rootHovered: { + background: '#0F6CBD', + color: '#FFFFFF' + }, + iconHovered: { + color: '#FFFFFF' + } +} interface Props { chatType: SidebarOptions | null | undefined } @@ -292,25 +311,12 @@ const Chat = ({ chatType }: Props) => { setIsCitationPanelOpen(true) } - const onViewSource = (citation: Citation) => { - if (citation.url && !citation.url.includes('blob.core')) { + const onViewSource = (citation: Citation | undefined) => { + if (citation?.url && !citation.url.includes('blob.core')) { window.open(citation.url, '_blank') } } - const parseCitationFromMessage = (message: ChatMessage) => { - if (message?.role && message?.role === 'tool') { - try { - const toolMessage = JSON.parse(message.content) as ToolMessageContent - return toolMessage.citations - } - catch { - return [] - } - } - return [] - } - const disabledButton = () => { return isLoading || (messages && messages.length === 0) || clearingChat } @@ -330,6 +336,30 @@ const Chat = ({ chatType }: Props) => { } }) } + const getCitationProp = (val: any) => (isEmpty(val) ? "" : val); + + const onClickAddFavorite = () => { + if (activeCitation?.filepath !== null && activeCitation?.url !== null) { + const newCitation = { + id: `${activeCitation?.filepath}-${activeCitation?.url}`, // Convert id to string and provide a default value of 0 + title: getCitationProp(activeCitation?.title), + url: getCitationProp(activeCitation?.url), + content: getCitationProp(activeCitation?.content), + filepath: getCitationProp(activeCitation?.filepath), + metadata: getCitationProp(activeCitation?.metadata), + chunk_id: getCitationProp(activeCitation?.chunk_id), + reindex_id: getCitationProp(activeCitation?.reindex_id), + type: getCitationProp( + appStateContext?.state.sidebarSelection?.toString() + ), + }; + handleToggleFavorite([newCitation]); + + if (appStateContext?.state?.isSidebarExpanded === false) { + appStateContext?.dispatch({ type: "TOGGLE_SIDEBAR" }); + } + } + }; let title = '' switch (appStateContext?.state.sidebarSelection) { @@ -343,189 +373,88 @@ const Chat = ({ chatType }: Props) => { return (
- -
-

- {title} -

-
- {messages.map((answer, index) => ( - <> - {answer.role === 'user' - ? (
-
{answer.content}
-
- ) : (answer.role === 'assistant' ? -
- onShowCitation(c)} - /> -
- : answer.role === ERROR ? -
- - - Error - - {answer.content} -
- : null)} - - ))} - {showLoadingMessage && ( - <> -
- null} - /> -
- - )} -
-
- - - {isLoading && ( - e.key === 'Enter' || e.key === ' ' ? stopGenerating() : null} - > - - )} - - - - - { makeApiRequestWithoutCosmosDB(question, id) }} - conversationId={appStateContext?.state.currentChat?.id ? appStateContext?.state.currentChat?.id : undefined} - chatType={chatType} - /> - -
- - {/* Citation Panel */} - {messages && messages.length > 0 && isCitationPanelOpen && activeCitation && ( - - - - References - - setIsCitationPanelOpen(false)} /> - -
onViewSource(activeCitation)}>{activeCitation.title}
- { - if (activeCitation.filepath && activeCitation.url) { - const newCitation = { - id: `${activeCitation.filepath}-${activeCitation.url}`, // Convert id to string and provide a default value of 0 - title: activeCitation.title, - url: activeCitation.url, - content: activeCitation.content, - filepath: activeCitation.filepath, - metadata: activeCitation.metadata, - chunk_id: activeCitation.chunk_id, - reindex_id: activeCitation.reindex_id, - type: appStateContext?.state.sidebarSelection?.toString() ?? '', - } - handleToggleFavorite([newCitation]) - - if (!appStateContext?.state.isSidebarExpanded) { - appStateContext?.dispatch({ type: 'TOGGLE_SIDEBAR' }); - } - } - }} - styles={{ - root: { borderRadius: '4px', marginTop: '10px', padding: '12px 24px' } - }} - > - Favorite - -
- -
- -
- )} + +
+

{title}

+
+ +
+
+ + + {isLoading && ( + + e.key === "Enter" || e.key === " " ? stopGenerating() : null + } + > + + )} + + + + { + makeApiRequestWithoutCosmosDB(question, id); + }} + conversationId={ + appStateContext?.state.currentChat?.id + ? appStateContext?.state.currentChat?.id + : undefined + } + chatType={chatType} + /> +
- ) + + {/* Citation Panel */} + {messages.length > 0 && + isCitationPanelOpen && + Boolean(activeCitation?.id) && ( + + )} + +
+ ); } export default Chat diff --git a/ResearchAssistant/App/frontend/src/pages/layout/Layout.test.tsx b/ResearchAssistant/App/frontend/src/pages/layout/Layout.test.tsx new file mode 100644 index 000000000..2aa64665e --- /dev/null +++ b/ResearchAssistant/App/frontend/src/pages/layout/Layout.test.tsx @@ -0,0 +1,79 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import Layout from './Layout'; +import { SidebarOptions } from '../../components/SidebarView/SidebarView'; +import { AppStateContext } from '../../state/AppProvider'; +import { MemoryRouter } from 'react-router-dom'; +import { DraftDocumentsView } from '../../components/DraftDocumentsView/DraftDocumentsView'; +// Mock child components +jest.mock('../../components/SidebarView/SidebarView', () => ({ + SidebarView: () =>
Mocked SidebarView
, + SidebarOptions: { + DraftDocuments: 'DraftDocuments', + Grant: 'Grant', + Article: 'Article', + }, +})); + +jest.mock('../Homepage/Homepage', () => () =>
Mocked Homepage
); +jest.mock('../chat/Chat', () => ({ chatType }: { chatType: SidebarOptions }) => ( +
Mocked Chat Component for {chatType}
+)); +jest.mock('../../components/DraftDocumentsView/DraftDocumentsView', () => ({ + DraftDocumentsView: () =>
Mocked DraftDocumentsView
, +})); + + +// Mock the SVG and CSS modules to avoid errors during testing +jest.mock('../../assets/M365.svg', () => 'mocked-icon'); +jest.mock('./Layout.module.css', () => ({})); + +describe('Layout Component', () => { + const mockDispatch = jest.fn(); + + const initialState = { + sidebarSelection: SidebarOptions.Article, + isSidebarExpanded: true, + }; + + const renderWithContext = (state: any) => { + return render( + + + + + + ); + }; + + it('renders Homepage by default when no sidebarSelection is made', () => { + const noSelectionState = { ...initialState, sidebarSelection: null }; + renderWithContext(noSelectionState); + expect(screen.getByText('Mocked Homepage')).toBeInTheDocument(); + }); + + test('renders DraftDocumentsView when sidebarSelection is DraftDocuments', () => { + renderWithContext({ sidebarSelection: SidebarOptions.DraftDocuments }); + expect(screen.getByText('Mocked DraftDocumentsView')).toBeInTheDocument(); + }); + + it('renders Chat component for Grant when sidebarSelection is Grant', () => { + const grantState = { ...initialState, sidebarSelection: SidebarOptions.Grant }; + renderWithContext(grantState); + expect(screen.getByText('Mocked Chat Component for Grant')).toBeInTheDocument(); + }); + + it('renders Chat component for Article when sidebarSelection is Article', () => { + const articleState = { ...initialState, sidebarSelection: SidebarOptions.Article }; + renderWithContext(articleState); + expect(screen.getByText('Mocked Chat Component for Article')).toBeInTheDocument(); + }); + + it('dispatches actions when Link is clicked', () => { + renderWithContext(initialState); + const link = screen.getByRole('link', { name: /Grant Writer/i }); + fireEvent.click(link); + expect(mockDispatch).toHaveBeenCalledWith({ type: 'UPDATE_SIDEBAR_SELECTION', payload: null }); + expect(mockDispatch).toHaveBeenCalledWith({ type: 'TOGGLE_SIDEBAR' }); + }); +}); diff --git a/ResearchAssistant/App/frontend/src/state/AppProvider.tsx b/ResearchAssistant/App/frontend/src/state/AppProvider.tsx index cdc5bfe49..679b6eaeb 100644 --- a/ResearchAssistant/App/frontend/src/state/AppProvider.tsx +++ b/ResearchAssistant/App/frontend/src/state/AppProvider.tsx @@ -36,7 +36,8 @@ const initialState: AppState = { articlesChat: null, grantsChat: null, frontendSettings: null, - documentSections: JSON.parse(JSON.stringify(documentSectionData)), + //documentSections: JSON.parse(JSON.stringify(documentSectionData)), + documentSections: documentSectionData, researchTopic: '', favoritedCitations: [], isSidebarExpanded: false, diff --git a/ResearchAssistant/App/frontend/src/test/test.utils.tsx b/ResearchAssistant/App/frontend/src/test/test.utils.tsx new file mode 100644 index 000000000..996505838 --- /dev/null +++ b/ResearchAssistant/App/frontend/src/test/test.utils.tsx @@ -0,0 +1,84 @@ +import React from "react"; +import { render, RenderResult } from "@testing-library/react"; +import { AppStateContext } from "../state/AppProvider"; +import { Conversation, ChatMessage } from "../api/models"; + +// Default mock state +const defaultMockState = { + currentChat: null, + articlesChat: null, + grantsChat: null, + frontendSettings: null, + documentSections: null, + researchTopic: "Test topic", + favoritedCitations: [], + isSidebarExpanded: false, + isChatViewOpen: true, + sidebarSelection: null, + showInitialChatMessage: false, +}; + +const mockDispatch = jest.fn(); + +const mockAppContextStateProvider = ( + state: any, + mockedDispatch: any, + component: any +) => { + return ( + + {component} + + ); +}; + +// Create a custom render function +const renderWithContext = ( + component: React.ReactElement, + updatedContext = {}, + mockDispatchFunc = mockDispatch +): RenderResult => { + const state = { ...defaultMockState, ...updatedContext }; + return render( + mockAppContextStateProvider(state, mockDispatchFunc, component) + ); +}; + +const renderWithNoContext = (component: React.ReactElement): RenderResult => { + return render( + + {component} + + ); +}; + +// Mocked conversation and chat message +const mockChatMessage: ChatMessage = { + id: "msg1", + role: "user", + content: "Test message content", + date: new Date().toISOString(), +}; + +const mockConversation: Conversation = { + id: "1", + title: "Test Conversation", + messages: [mockChatMessage], + date: new Date().toISOString(), +}; + +const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +export { + defaultMockState, + renderWithContext, + mockDispatch, + mockChatMessage, + mockConversation, + renderWithNoContext, + mockAppContextStateProvider, + delay +}; +export * from "@testing-library/react"; diff --git a/ResearchAssistant/App/frontend/tsconfig.json b/ResearchAssistant/App/frontend/tsconfig.json index 59b2e957c..4afb8d417 100644 --- a/ResearchAssistant/App/frontend/tsconfig.json +++ b/ResearchAssistant/App/frontend/tsconfig.json @@ -5,7 +5,7 @@ "lib": ["DOM", "DOM.Iterable", "ESNext"], "allowJs": false, "skipLibCheck": true, - "esModuleInterop": false, + "esModuleInterop": true, "allowSyntheticDefaultImports": true, "strict": true, "forceConsistentCasingInFileNames": true, @@ -15,8 +15,8 @@ "isolatedModules": true, "noEmit": true, "jsx": "react-jsx", - "types": ["vite/client"] + "types": ["vite/client", "jest", "node","@testing-library/jest-dom", "@testing-library/react"] }, - "include": ["src"], + "include": ["src", "__mocks__", "setupTests.ts"], "references": [{ "path": "./tsconfig.node.json" }] } diff --git a/ResearchAssistant/Deployment/bicep/deploy_app_service.bicep b/ResearchAssistant/Deployment/bicep/deploy_app_service.bicep index 69bc0c1ee..f733d9f0a 100644 --- a/ResearchAssistant/Deployment/bicep/deploy_app_service.bicep +++ b/ResearchAssistant/Deployment/bicep/deploy_app_service.bicep @@ -162,7 +162,7 @@ param AIStudioDraftFlowDeploymentName string = '' param AIStudioUse string = 'False' -var WebAppImageName = 'DOCKER|byoaiacontainerreg.azurecr.io/byoaia-app:latest' +var WebAppImageName = 'DOCKER|byoaiacontainerreg.azurecr.io/byoaia-app:dev' resource HostingPlan 'Microsoft.Web/serverfarms@2020-06-01' = { name: HostingPlanName diff --git a/ResearchAssistant/Deployment/bicep/main.bicep b/ResearchAssistant/Deployment/bicep/main.bicep index c81d19624..ea5f564c2 100644 --- a/ResearchAssistant/Deployment/bicep/main.bicep +++ b/ResearchAssistant/Deployment/bicep/main.bicep @@ -14,7 +14,7 @@ var resourceGroupName = resourceGroup().name var subscriptionId = subscription().subscriptionId var solutionLocation = resourceGroupLocation -var baseUrl = 'https://raw.githubusercontent.com/microsoft/Build-your-own-copilot-Solution-Accelerator/main/' +var baseUrl = 'https://raw.githubusercontent.com/Roopan-Microsoft/Build-your-own-copilot-Solution-Accelerator/main/' // ========== Managed Identity ========== // module managedIdentityModule 'deploy_managed_identity.bicep' = { diff --git a/ResearchAssistant/Deployment/bicep/main.json b/ResearchAssistant/Deployment/bicep/main.json index 6d4cacd0c..a64e3bfd8 100644 --- a/ResearchAssistant/Deployment/bicep/main.json +++ b/ResearchAssistant/Deployment/bicep/main.json @@ -5,7 +5,7 @@ "_generator": { "name": "bicep", "version": "0.29.47.4906", - "templateHash": "7163812400877459703" + "templateHash": "10711406236308727919" } }, "parameters": { @@ -23,7 +23,7 @@ "resourceGroupName": "[resourceGroup().name]", "subscriptionId": "[subscription().subscriptionId]", "solutionLocation": "[variables('resourceGroupLocation')]", - "baseUrl": "https://raw.githubusercontent.com/microsoft/Build-your-own-copilot-Solution-Accelerator/main/" + "baseUrl": "https://raw.githubusercontent.com/Roopan-Microsoft/Build-your-own-copilot-Solution-Accelerator/main/" }, "resources": [ { @@ -1508,7 +1508,7 @@ "_generator": { "name": "bicep", "version": "0.29.47.4906", - "templateHash": "7109834445090495169" + "templateHash": "1558876662595106054" } }, "parameters": { @@ -1878,7 +1878,7 @@ } }, "variables": { - "WebAppImageName": "DOCKER|byoaiacontainerreg.azurecr.io/byoaia-app:latest" + "WebAppImageName": "DOCKER|byoaiacontainerreg.azurecr.io/byoaia-app:dev" }, "resources": [ {