diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 86e3c905..64340629 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -34,4 +34,4 @@ Verify that the following are valid * ... ## Other Information - \ 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 00000000..3a7333e0 --- /dev/null +++ b/.github/workflows/test_research_assistant.yml @@ -0,0 +1,67 @@ +name: Unit Tests - Research Assistant + +on: + push: + branches: main + 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 + + 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 --upgrade pip + python -m pip install -r requirements.txt + python -m pip install coverage pytest pytest-cov pytest-asyncio + + - name: Run Backend Tests with Coverage + run: | + cd ResearchAssistant/App + python -m pytest -vv --disable-warnings --cov=. --cov-report=xml --cov-report=html --cov-report=term-missing --junitxml=coverage-junit.xml || true + + - uses: actions/upload-artifact@v4 + with: + name: research-assistant-coverage + path: | + ResearchAssistant/App/coverage.xml + ResearchAssistant/App/coverage-junit.xml + ResearchAssistant/App/htmlcov/ + # - 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/.gitignore b/.gitignore index 8a30d258..895ee007 100644 --- a/.gitignore +++ b/.gitignore @@ -2,7 +2,7 @@ ## files generated by popular Visual Studio add-ons. ## ## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore - +.venv/ # User-specific files *.rsuser *.suo diff --git a/ClientAdvisor/App/.flake8 b/ClientAdvisor/App/.flake8 index 74ee71d5..60c80c9a 100644 --- a/ClientAdvisor/App/.flake8 +++ b/ClientAdvisor/App/.flake8 @@ -1,4 +1,5 @@ [flake8] max-line-length = 88 extend-ignore = E501, E203 -exclude = .venv, frontend, \ No newline at end of file +exclude = .venv, frontend + diff --git a/ClientAdvisor/App/app.py b/ClientAdvisor/App/app.py index e8221243..82c0a80d 100644 --- a/ClientAdvisor/App/app.py +++ b/ClientAdvisor/App/app.py @@ -963,6 +963,7 @@ async def stream_chat_request(request_body, request_headers): 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 = "" @@ -1546,12 +1547,12 @@ def get_users(): 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 ( @@ -1584,7 +1585,6 @@ def get_users(): """ cursor.execute(sql_stmt) rows = cursor.fetchall() - if len(rows) <= 6: # update ClientMeetings,Assets,Retirement tables sample data to current date cursor = conn.cursor() diff --git a/ClientAdvisor/AzureFunction/function_app.py b/ClientAdvisor/AzureFunction/function_app.py index f9bfd8dc..b16c757e 100644 --- a/ClientAdvisor/AzureFunction/function_app.py +++ b/ClientAdvisor/AzureFunction/function_app.py @@ -22,6 +22,7 @@ # Azure Function App app = func.FunctionApp(http_auth_level=func.AuthLevel.ANONYMOUS) + endpoint = os.environ.get("AZURE_OPEN_AI_ENDPOINT") api_key = os.environ.get("AZURE_OPEN_AI_API_KEY") api_version = os.environ.get("OPENAI_API_VERSION") @@ -100,6 +101,16 @@ 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 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: @@ -157,8 +168,11 @@ def get_answers_from_calltranscripts( 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''' + You have access to the client’s meeting call transcripts. + 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. + if asked related to count of call transcripts,**Always** respond the total number of sourceurid involved for the {ClientId} consistently, Do never change if question is reframed or contains "so far" or if the case is altered or having first name or full name of the client present with so far or case altered with first name of the client or case altered with first name of client and so far. + You can use this information to answer questions about the clients + ''' completion = client.chat.completions.create( model = deployment, @@ -182,7 +196,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,20 +272,25 @@ async def stream_openai_text(req: Request) -> StreamingResponse: settings.max_tokens = 800 settings.temperature = 0 - system_message = '''you are a helpful assistant to a wealth advisor. + # 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. + 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. + Always return time in "HH:mm" format for the client in response. ''' - - user_query = query.replace('?',' ') + system_message += html_content + + user_query = query.replace('?','') user_query_prompt = f'''{user_query}. Always send clientId as {user_query.split(':::')[-1]} ''' query_prompt = f'''{system_message}{user_query_prompt}''' - - + sk_response = kernel.invoke_prompt_stream( function_name="prompt_test", plugin_name="weather_test", diff --git a/ClientAdvisor/AzureFunction/table.html b/ClientAdvisor/AzureFunction/table.html new file mode 100644 index 00000000..51ded0be --- /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/deploy_azure_function.bicep b/ClientAdvisor/Deployment/bicep/deploy_azure_function.bicep new file mode 100644 index 00000000..3703db25 --- /dev/null +++ b/ClientAdvisor/Deployment/bicep/deploy_azure_function.bicep @@ -0,0 +1,149 @@ +@description('Specifies the location for resources.') +param solutionName string +param solutionLocation string +@secure() +param azureOpenAIApiKey string +param azureOpenAIApiVersion string +param azureOpenAIEndpoint string +@secure() +param azureSearchAdminKey string +param azureSearchServiceEndpoint string +param azureSearchIndex string +param sqlServerName string +param sqlDbName string +param sqlDbUser string +@secure() +param sqlDbPwd string +param functionAppVersion string + +var functionAppName = '${solutionName}fn' +var azureOpenAIDeploymentModel = 'gpt-4' +var azureOpenAIEmbeddingDeployment = 'text-embedding-ada-002' +var valueOne = '1' + +resource storageAccount 'Microsoft.Storage/storageAccounts@2021-04-01' = { + name: '${solutionName}fnstorage' + location: solutionLocation + sku: { + name: 'Standard_LRS' + } + kind: 'StorageV2' + properties: { + allowSharedKeyAccess: false + } +} + +resource logAnalyticsWorkspace 'Microsoft.OperationalInsights/workspaces@2022-10-01' = { + name: 'workspace-${solutionName}' + location: solutionLocation +} + +resource ApplicationInsights 'Microsoft.Insights/components@2020-02-02' = { + name: functionAppName + location: solutionLocation + kind: 'web' + properties: { + Application_Type: 'web' + publicNetworkAccessForIngestion: 'Enabled' + publicNetworkAccessForQuery: 'Enabled' + WorkspaceResourceId: logAnalyticsWorkspace.id + } +} + +resource containerAppEnv 'Microsoft.App/managedEnvironments@2022-06-01-preview' = { + name: '${solutionName}env' + location: solutionLocation + sku: { + name: 'Consumption' + } + properties: { + appLogsConfiguration: { + destination: 'log-analytics' + logAnalyticsConfiguration: { + customerId: logAnalyticsWorkspace.properties.customerId + sharedKey: logAnalyticsWorkspace.listKeys().primarySharedKey + } + } + } +} + +resource functionApp 'Microsoft.Web/sites@2024-04-01' = { + name: functionAppName + location: solutionLocation + kind: 'functionapp' + identity: { + type: 'SystemAssigned' + } + properties: { + managedEnvironmentId: containerAppEnv.id + siteConfig: { + linuxFxVersion: 'DOCKER|bycwacontainerreg.azurecr.io/byc-wa-fn:${functionAppVersion}' + appSettings: [ + { + name: 'APPINSIGHTS_INSTRUMENTATIONKEY' + value: reference(ApplicationInsights.id, '2015-05-01').InstrumentationKey + } + { + name: 'AZURE_OPEN_AI_API_KEY' + value: azureOpenAIApiKey + } + { + name: 'AZURE_OPEN_AI_DEPLOYMENT_MODEL' + value: azureOpenAIDeploymentModel + } + { + name: 'AZURE_OPEN_AI_ENDPOINT' + value: azureOpenAIEndpoint + } + { + name: 'AZURE_OPENAI_EMBEDDING_DEPLOYMENT' + value: azureOpenAIEmbeddingDeployment + } + { + name: 'OPENAI_API_VERSION' + value: azureOpenAIApiVersion + } + { + name: 'AZURE_AI_SEARCH_API_KEY' + value: azureSearchAdminKey + } + { + name: 'AZURE_AI_SEARCH_ENDPOINT' + value: azureSearchServiceEndpoint + } + { + name: 'AZURE_SEARCH_INDEX' + value: azureSearchIndex + } + { + name: 'PYTHON_ENABLE_INIT_INDEXING' + value: valueOne + } + { + name: 'PYTHON_ISOLATE_WORKER_DEPENDENCIES' + value: valueOne + } + { + name: 'SQLDB_CONNECTION_STRING' + value: 'TBD' + } + { + name: 'SQLDB_SERVER' + value: sqlServerName + } + { + name: 'SQLDB_DATABASE' + value: sqlDbName + } + { + name: 'SQLDB_USERNAME' + value: sqlDbUser + } + { + name: 'SQLDB_PASSWORD' + value: sqlDbPwd + } + ] + } + } +} diff --git a/ClientAdvisor/Deployment/bicep/deploy_azure_function_script.bicep b/ClientAdvisor/Deployment/bicep/deploy_azure_function_script.bicep deleted file mode 100644 index 2ad7ff55..00000000 --- a/ClientAdvisor/Deployment/bicep/deploy_azure_function_script.bicep +++ /dev/null @@ -1,42 +0,0 @@ -@description('Specifies the location for resources.') -param solutionName string -param solutionLocation string -param resourceGroupName string -param identity string -param baseUrl string -@secure() -param azureOpenAIApiKey string -param azureOpenAIApiVersion string -param azureOpenAIEndpoint string -@secure() -param azureSearchAdminKey string -param azureSearchServiceEndpoint string -param azureSearchIndex string -param sqlServerName string -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' - name: 'deploy_azure_function' - location: solutionLocation // Replace with your desired location - identity:{ - type:'UserAssigned' - userAssignedIdentities: { - '${identity}' : {} - } - } - 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} ${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/deploy_cosmos_db.bicep b/ClientAdvisor/Deployment/bicep/deploy_cosmos_db.bicep deleted file mode 100644 index d44abb71..00000000 --- a/ClientAdvisor/Deployment/bicep/deploy_cosmos_db.bicep +++ /dev/null @@ -1,81 +0,0 @@ -@minLength(3) -@maxLength(15) -@description('Solution Name') -param solutionName string -param solutionLocation string - -@description('Name') -param accountName string = '${ solutionName }-cosmos' -param databaseName string = 'db_conversation_history' -param collectionName string = 'conversations' - -param identity string - -param containers array = [ - { - name: collectionName - id: collectionName - partitionKey: '/userId' - } -] - -@allowed([ 'GlobalDocumentDB', 'MongoDB', 'Parse' ]) -param kind string = 'GlobalDocumentDB' - -param tags object = {} - -resource cosmos 'Microsoft.DocumentDB/databaseAccounts@2022-08-15' = { - name: accountName - kind: kind - location: solutionLocation - tags: tags - properties: { - consistencyPolicy: { defaultConsistencyLevel: 'Session' } - locations: [ - { - locationName: solutionLocation - failoverPriority: 0 - isZoneRedundant: false - } - ] - databaseAccountOfferType: 'Standard' - enableAutomaticFailover: false - enableMultipleWriteLocations: false - apiProperties: (kind == 'MongoDB') ? { serverVersion: '4.0' } : {} - capabilities: [ { name: 'EnableServerless' } ] - } -} - - -resource database 'Microsoft.DocumentDB/databaseAccounts/sqlDatabases@2022-05-15' = { - name: '${accountName}/${databaseName}' - properties: { - resource: { id: databaseName } - } - - resource list 'containers' = [for container in containers: { - name: container.name - properties: { - resource: { - id: container.id - partitionKey: { paths: [ container.partitionKey ] } - } - options: {} - } - }] - - dependsOn: [ - cosmos - ] -} - -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/main.bicep b/ClientAdvisor/Deployment/bicep/main.bicep index 0bbf984f..56d7f26b 100644 --- a/ClientAdvisor/Deployment/bicep/main.bicep +++ b/ClientAdvisor/Deployment/bicep/main.bicep @@ -13,7 +13,7 @@ param cosmosLocation string // param fabricWorkspaceId string var resourceGroupLocation = resourceGroup().location -var resourceGroupName = resourceGroup().name +// var resourceGroupName = resourceGroup().name // var subscriptionId = subscription().subscriptionId var solutionLocation = resourceGroupLocation @@ -101,12 +101,11 @@ module uploadFiles 'deploy_upload_files_script.bicep' = { dependsOn:[storageAccountModule] } -module azureFunctions 'deploy_azure_function_script.bicep' = { - name : 'deploy_azure_function_script' +module azureFunctions 'deploy_azure_function.bicep' = { + name : 'deploy_azure_function' params:{ solutionName: solutionPrefix solutionLocation: solutionLocation - resourceGroupName:resourceGroupName azureOpenAIApiKey:azOpenAI.outputs.openAIOutput.openAPIKey azureOpenAIApiVersion:'2024-02-15-preview' azureOpenAIEndpoint:azOpenAI.outputs.openAIOutput.openAPIEndpoint @@ -117,8 +116,6 @@ module azureFunctions 'deploy_azure_function_script.bicep' = { sqlDbName:sqlDBModule.outputs.sqlDbOutput.sqlDbName sqlDbUser:sqlDBModule.outputs.sqlDbOutput.sqlDbUser sqlDbPwd:sqlDBModule.outputs.sqlDbOutput.sqlDbPwd - identity:managedIdentityModule.outputs.managedIdentityOutput.id - baseUrl:baseUrl functionAppVersion: appversion } dependsOn:[storageAccountModule] diff --git a/ClientAdvisor/Deployment/bicep/main.json b/ClientAdvisor/Deployment/bicep/main.json index 275c12cb..db2fea96 100644 --- a/ClientAdvisor/Deployment/bicep/main.json +++ b/ClientAdvisor/Deployment/bicep/main.json @@ -4,8 +4,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.31.34.60546", - "templateHash": "15258689682412466487" + "version": "0.31.92.45157", + "templateHash": "5323335081815035013" } }, "parameters": { @@ -26,7 +26,6 @@ }, "variables": { "resourceGroupLocation": "[resourceGroup().location]", - "resourceGroupName": "[resourceGroup().name]", "solutionLocation": "[variables('resourceGroupLocation')]", "baseUrl": "https://raw.githubusercontent.com/microsoft/Build-your-own-copilot-Solution-Accelerator/main/ClientAdvisor/", "appversion": "latest" @@ -56,8 +55,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.31.34.60546", - "templateHash": "12508267066278938117" + "version": "0.31.92.45157", + "templateHash": "14275103612814336681" } }, "parameters": { @@ -145,8 +144,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.31.34.60546", - "templateHash": "11828480827139544665" + "version": "0.31.92.45157", + "templateHash": "11252542125482186403" } }, "parameters": { @@ -308,8 +307,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.31.34.60546", - "templateHash": "3549774837624453156" + "version": "0.31.92.45157", + "templateHash": "9069896720095886117" } }, "parameters": { @@ -466,8 +465,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.31.34.60546", - "templateHash": "12627712322898660298" + "version": "0.31.92.45157", + "templateHash": "17433323364688871256" } }, "parameters": { @@ -624,8 +623,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.31.34.60546", - "templateHash": "17239529093958196867" + "version": "0.31.92.45157", + "templateHash": "5436140332954494822" } }, "parameters": { @@ -706,8 +705,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.31.34.60546", - "templateHash": "5720304969592308179" + "version": "0.31.92.45157", + "templateHash": "8039754860846909988" } }, "parameters": { @@ -794,8 +793,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.31.34.60546", - "templateHash": "2043450591502080061" + "version": "0.31.92.45157", + "templateHash": "8396159558351441447" } }, "parameters": { @@ -925,8 +924,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.31.34.60546", - "templateHash": "6087269027573253102" + "version": "0.31.92.45157", + "templateHash": "4576939703012923038" } }, "parameters": { @@ -982,7 +981,7 @@ { "type": "Microsoft.Resources/deployments", "apiVersion": "2022-09-01", - "name": "deploy_azure_function_script", + "name": "deploy_azure_function", "properties": { "expressionEvaluationOptions": { "scope": "inner" @@ -995,9 +994,6 @@ "solutionLocation": { "value": "[variables('solutionLocation')]" }, - "resourceGroupName": { - "value": "[variables('resourceGroupName')]" - }, "azureOpenAIApiKey": { "value": "[reference(resourceId('Microsoft.Resources/deployments', 'deploy_azure_open_ai'), '2022-09-01').outputs.openAIOutput.value.openAPIKey]" }, @@ -1028,12 +1024,6 @@ "sqlDbPwd": { "value": "[reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, resourceGroup().name), 'Microsoft.Resources/deployments', 'deploy_sql_db'), '2022-09-01').outputs.sqlDbOutput.value.sqlDbPwd]" }, - "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]" - }, - "baseUrl": { - "value": "[variables('baseUrl')]" - }, "functionAppVersion": { "value": "[variables('appversion')]" } @@ -1044,8 +1034,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.31.34.60546", - "templateHash": "10231530585958508769" + "version": "0.31.92.45157", + "templateHash": "15513736071622247788" } }, "parameters": { @@ -1058,15 +1048,6 @@ "solutionLocation": { "type": "string" }, - "resourceGroupName": { - "type": "string" - }, - "identity": { - "type": "string" - }, - "baseUrl": { - "type": "string" - }, "azureOpenAIApiKey": { "type": "securestring" }, @@ -1101,27 +1082,154 @@ "type": "string" } }, + "variables": { + "functionAppName": "[format('{0}fn', parameters('solutionName'))]", + "azureOpenAIDeploymentModel": "gpt-4", + "azureOpenAIEmbeddingDeployment": "text-embedding-ada-002", + "valueOne": "1" + }, "resources": [ { - "type": "Microsoft.Resources/deploymentScripts", - "apiVersion": "2020-10-01", - "name": "deploy_azure_function", - "kind": "AzureCLI", + "type": "Microsoft.Storage/storageAccounts", + "apiVersion": "2021-04-01", + "name": "[format('{0}fnstorage', parameters('solutionName'))]", "location": "[parameters('solutionLocation')]", - "identity": { - "type": "UserAssigned", - "userAssignedIdentities": { - "[format('{0}', parameters('identity'))]": {} - } + "sku": { + "name": "Standard_LRS" }, + "kind": "StorageV2", "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} {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" + "allowSharedKeyAccess": false } + }, + { + "type": "Microsoft.OperationalInsights/workspaces", + "apiVersion": "2022-10-01", + "name": "[format('workspace-{0}', parameters('solutionName'))]", + "location": "[parameters('solutionLocation')]" + }, + { + "type": "Microsoft.Insights/components", + "apiVersion": "2020-02-02", + "name": "[variables('functionAppName')]", + "location": "[parameters('solutionLocation')]", + "kind": "web", + "properties": { + "Application_Type": "web", + "publicNetworkAccessForIngestion": "Enabled", + "publicNetworkAccessForQuery": "Enabled", + "WorkspaceResourceId": "[resourceId('Microsoft.OperationalInsights/workspaces', format('workspace-{0}', parameters('solutionName')))]" + }, + "dependsOn": [ + "[resourceId('Microsoft.OperationalInsights/workspaces', format('workspace-{0}', parameters('solutionName')))]" + ] + }, + { + "type": "Microsoft.App/managedEnvironments", + "apiVersion": "2022-06-01-preview", + "name": "[format('{0}env', parameters('solutionName'))]", + "location": "[parameters('solutionLocation')]", + "sku": { + "name": "Consumption" + }, + "properties": { + "appLogsConfiguration": { + "destination": "log-analytics", + "logAnalyticsConfiguration": { + "customerId": "[reference(resourceId('Microsoft.OperationalInsights/workspaces', format('workspace-{0}', parameters('solutionName'))), '2022-10-01').customerId]", + "sharedKey": "[listKeys(resourceId('Microsoft.OperationalInsights/workspaces', format('workspace-{0}', parameters('solutionName'))), '2022-10-01').primarySharedKey]" + } + } + }, + "dependsOn": [ + "[resourceId('Microsoft.OperationalInsights/workspaces', format('workspace-{0}', parameters('solutionName')))]" + ] + }, + { + "type": "Microsoft.Web/sites", + "apiVersion": "2024-04-01", + "name": "[variables('functionAppName')]", + "location": "[parameters('solutionLocation')]", + "kind": "functionapp", + "identity": { + "type": "SystemAssigned" + }, + "properties": { + "managedEnvironmentId": "[resourceId('Microsoft.App/managedEnvironments', format('{0}env', parameters('solutionName')))]", + "siteConfig": { + "linuxFxVersion": "[format('DOCKER|bycwacontainerreg.azurecr.io/byc-wa-fn:{0}', parameters('functionAppVersion'))]", + "appSettings": [ + { + "name": "APPINSIGHTS_INSTRUMENTATIONKEY", + "value": "[reference(resourceId('Microsoft.Insights/components', variables('functionAppName')), '2015-05-01').InstrumentationKey]" + }, + { + "name": "AZURE_OPEN_AI_API_KEY", + "value": "[parameters('azureOpenAIApiKey')]" + }, + { + "name": "AZURE_OPEN_AI_DEPLOYMENT_MODEL", + "value": "[variables('azureOpenAIDeploymentModel')]" + }, + { + "name": "AZURE_OPEN_AI_ENDPOINT", + "value": "[parameters('azureOpenAIEndpoint')]" + }, + { + "name": "AZURE_OPENAI_EMBEDDING_DEPLOYMENT", + "value": "[variables('azureOpenAIEmbeddingDeployment')]" + }, + { + "name": "OPENAI_API_VERSION", + "value": "[parameters('azureOpenAIApiVersion')]" + }, + { + "name": "AZURE_AI_SEARCH_API_KEY", + "value": "[parameters('azureSearchAdminKey')]" + }, + { + "name": "AZURE_AI_SEARCH_ENDPOINT", + "value": "[parameters('azureSearchServiceEndpoint')]" + }, + { + "name": "AZURE_SEARCH_INDEX", + "value": "[parameters('azureSearchIndex')]" + }, + { + "name": "PYTHON_ENABLE_INIT_INDEXING", + "value": "[variables('valueOne')]" + }, + { + "name": "PYTHON_ISOLATE_WORKER_DEPENDENCIES", + "value": "[variables('valueOne')]" + }, + { + "name": "SQLDB_CONNECTION_STRING", + "value": "TBD" + }, + { + "name": "SQLDB_SERVER", + "value": "[parameters('sqlServerName')]" + }, + { + "name": "SQLDB_DATABASE", + "value": "[parameters('sqlDbName')]" + }, + { + "name": "SQLDB_USERNAME", + "value": "[parameters('sqlDbUser')]" + }, + { + "name": "SQLDB_PASSWORD", + "value": "[parameters('sqlDbPwd')]" + } + ] + } + }, + "dependsOn": [ + "[resourceId('Microsoft.Insights/components', variables('functionAppName'))]", + "[resourceId('Microsoft.App/managedEnvironments', format('{0}env', parameters('solutionName')))]" + ] } ] } @@ -1129,7 +1237,6 @@ "dependsOn": [ "[resourceId('Microsoft.Resources/deployments', 'deploy_azure_open_ai')]", "[resourceId('Microsoft.Resources/deployments', 'deploy_ai_search_service')]", - "[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')]", "[extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, resourceGroup().name), 'Microsoft.Resources/deployments', 'deploy_storage_account')]" ] @@ -1157,8 +1264,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.31.34.60546", - "templateHash": "14069568468347897481" + "version": "0.31.92.45157", + "templateHash": "9449321404712906545" } }, "parameters": { @@ -1186,7 +1293,7 @@ } }, "dependsOn": [ - "[resourceId('Microsoft.Resources/deployments', 'deploy_azure_function_script')]", + "[resourceId('Microsoft.Resources/deployments', 'deploy_azure_function')]", "[extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, resourceGroup().name), 'Microsoft.Resources/deployments', 'deploy_managed_identity')]" ] }, @@ -1271,8 +1378,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.31.34.60546", - "templateHash": "5271454688668874284" + "version": "0.31.92.45157", + "templateHash": "66098434643239722" } }, "parameters": { @@ -1763,8 +1870,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.31.34.60546", - "templateHash": "17388889661434191538" + "version": "0.31.92.45157", + "templateHash": "5707240720200724295" } }, "parameters": { @@ -1961,8 +2068,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.31.34.60546", - "templateHash": "16594210839276135691" + "version": "0.31.92.45157", + "templateHash": "15940154560282078894" } }, "parameters": { @@ -2606,8 +2713,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.31.34.60546", - "templateHash": "8033637033572984239" + "version": "0.31.92.45157", + "templateHash": "17264897636350402451" }, "description": "Creates a SQL role assignment under an Azure Cosmos DB account." }, diff --git a/ClientAdvisor/Deployment/scripts/create_azure_functions.sh b/ClientAdvisor/Deployment/scripts/create_azure_functions.sh deleted file mode 100644 index d7d1a3b9..00000000 --- a/ClientAdvisor/Deployment/scripts/create_azure_functions.sh +++ /dev/null @@ -1,54 +0,0 @@ -#!/bin/bash - -# Variables -solutionName="$1" -solutionLocation="$2" -resourceGroupName="$3" -baseUrl="$4" -azureOpenAIApiKey="$5" -azureOpenAIApiVersion="$6" -azureOpenAIEndpoint="$7" -azureSearchAdminKey="$8" -azureSearchServiceEndpoint="$9" -azureSearchIndex="${10}" -sqlServerName="${11}" -sqlDbName="${12}" -sqlDbUser="${13}" -sqlDbPwd="${14}" -functionAppVersion="${15}" - -azureOpenAIDeploymentModel="gpt-4" -azureOpenAIEmbeddingDeployment="text-embedding-ada-002" - -env_name=${solutionName}"env" -storageAccount=${solutionName}"fnstorage" -functionappname=${solutionName}"fn" -valueone="1" - -# sqlDBConn="DRIVER={ODBC Driver 18 for SQL Server};SERVER="${sqlServerName}".database.windows.net;DATABASE="${sqlDbName}";UID="${sqlDbUser}";PWD="${sqlDbPwd} - -#sqlDBConn="DRIVER={ODBC Driver 18 for SQL Server};SERVER=${sqlServerName}.database.windows.net;DATABASE=${sqlDbName};UID=${sqlDbUser};PWD=${sqlDbPwd}" -sqlDBConn="TBD" - -az containerapp env create --name $env_name --enable-workload-profiles --resource-group $resourceGroupName --location $solutionLocation - -az storage account create --name $storageAccount --location eastus --resource-group $resourceGroupName --sku Standard_LRS --allow-shared-key-access false - -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:$functionAppVersion - -# Sleep for 120 seconds -echo "Waiting for 120 seconds to ensure the Function App is properly created..." -sleep 60 - -az functionapp config appsettings set --name $functionappname -g $resourceGroupName \ - --settings AZURE_OPEN_AI_API_KEY=$azureOpenAIApiKey AZURE_OPEN_AI_DEPLOYMENT_MODEL=$azureOpenAIDeploymentModel \ - AZURE_OPEN_AI_ENDPOINT=$azureOpenAIEndpoint AZURE_OPENAI_EMBEDDING_DEPLOYMENT=$azureOpenAIEmbeddingDeployment \ - OPENAI_API_VERSION=$azureOpenAIApiVersion \ - AZURE_AI_SEARCH_API_KEY=$azureSearchAdminKey AZURE_AI_SEARCH_ENDPOINT=$azureSearchServiceEndpoint \ - AZURE_SEARCH_INDEX=$azureSearchIndex \ - PYTHON_ENABLE_INIT_INDEXING=$valueone PYTHON_ISOLATE_WORKER_DEPENDENCIES=$valueone \ - SQLDB_CONNECTION_STRING=$sqlDBConn \ - SQLDB_SERVER=$sqlServerName SQLDB_DATABASE=$sqlDbName SQLDB_USERNAME=$sqlDbUser SQLDB_PASSWORD=$sqlDbPwd \ No newline at end of file diff --git a/ClientAdvisor/PowerBIReport/WealthAdvisor-Client360Report.pbix b/ClientAdvisor/PowerBIReport/WealthAdvisor-Client360Report.pbix index 69c6ba92..5bfefa92 100644 Binary files a/ClientAdvisor/PowerBIReport/WealthAdvisor-Client360Report.pbix and b/ClientAdvisor/PowerBIReport/WealthAdvisor-Client360Report.pbix differ diff --git a/ClientAdvisor/package-lock.json b/ClientAdvisor/package-lock.json new file mode 100644 index 00000000..9ff92046 --- /dev/null +++ b/ClientAdvisor/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "ClientAdvisor", + "lockfileVersion": 3, + "requires": true, + "packages": {} +} diff --git a/ResearchAssistant/App/.gitignore b/ResearchAssistant/App/.gitignore index 73d4e83e..74f362b8 100644 --- a/ResearchAssistant/App/.gitignore +++ b/ResearchAssistant/App/.gitignore @@ -1,4 +1,4 @@ -.venv +.venv/ frontend/node_modules .env # static @@ -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 00000000..9dfa8e43 --- /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 00000000..06ad689c --- /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 00000000..24a4b55a --- /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 00000000..680829ce --- /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 00000000..a9eb8d2b --- /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 00000000..d55ebb82 --- /dev/null +++ b/ResearchAssistant/App/frontend/jest.config.ts @@ -0,0 +1,50 @@ +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' + ], +}; + +export default config; diff --git a/ResearchAssistant/App/frontend/jest.polyfills.js b/ResearchAssistant/App/frontend/jest.polyfills.js new file mode 100644 index 00000000..e4b6211d --- /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 9be0f1c8..1805ddd1 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 00000000..66993e3a --- /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 00000000..dbdd380d --- /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 852ad501..e0819c33 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 00000000..eb0eaf75 --- /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 00000000..bf5b9a37 --- /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 00000000..c0a51b7a --- /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 00000000..9e558fe2 --- /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 00000000..76d82ba2 --- /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 00000000..fbdda246 --- /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 00000000..b6e780d2 --- /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.test.tsx b/ResearchAssistant/App/frontend/src/components/DraftDocumentsView/Card.test.tsx new file mode 100644 index 00000000..f4e0879b --- /dev/null +++ b/ResearchAssistant/App/frontend/src/components/DraftDocumentsView/Card.test.tsx @@ -0,0 +1,151 @@ +import React from 'react' +import { render, screen, fireEvent, waitFor } from '@testing-library/react' +import { Card } from './Card' +import { type AppState, AppStateContext } from '../../state/AppProvider' +import { documentSectionGenerate } from '../../api' +import { SidebarOptions } from '../SidebarView/SidebarView' + +jest.mock('../../api') + +const mockDispatch = jest.fn() + +export const mockState: AppState = { + documentSections: [ + { title: 'Section 1', content: 'Initial content', metaPrompt: '' } + ], + researchTopic: 'Test Topic', + currentChat: null, + articlesChat: { id: '1', title: 'Chat 1', messages: [], date: '' }, + grantsChat: { id: '1', title: 'Chat 1', messages: [], date: '' }, + frontendSettings: {}, + favoritedCitations: [], + isSidebarExpanded: false, + isChatViewOpen: false, + sidebarSelection: SidebarOptions.Article, + showInitialChatMessage: false +} +const renderWithContext = (component: any) => { + return render( + + {component} + + ) +} + +describe('Card Component', () => { + test('renders without crashing', () => { + renderWithContext() + expect(screen.getByText('Section 1')).toBeInTheDocument() + }) + + test('initial state is correct', () => { + renderWithContext() + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + // expect(screen.getByText('Regenerate')).toBeDisabled() + }) + + test('opens and closes popover', async () => { + renderWithContext() + fireEvent.click(screen.getByText('Regenerate')) + expect(await screen.findByTestId('popupsummary')).toBeInTheDocument() + const dismissbtn = await screen.findByTestId('dismiss') + fireEvent.click(dismissbtn) + expect(screen.queryByTestId('popupsummary')).not.toBeInTheDocument() + }) + + test('handles generate click', async () => { + (documentSectionGenerate as jest.Mock).mockResolvedValue({ + body: {}, + json: async () => ({ content: 'Generated content' }), + status: 200 + }) + renderWithContext() + fireEvent.click(screen.getByText('Regenerate')) + fireEvent.click(screen.getByText('Generate')) + + await waitFor(() => { + expect(mockDispatch).toHaveBeenCalledWith({ + type: 'UPDATE_DRAFT_DOCUMENTS_SECTIONS', + payload: [{ title: 'Section 1', content: 'Generated content', metaPrompt: '' }] + }) + }) + }) + + test('section is empty', async () => { + (documentSectionGenerate as jest.Mock).mockResolvedValue({ + body: {}, + json: async () => ({ content: 'Generated content' }), + status: 200 + }) + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}) + const documentSectionEmpty = { ...mockState, documentSections: [] } + render( + + + + ) + + fireEvent.click(screen.getByText('Regenerate')) + fireEvent.click(screen.getByText('Generate')) + expect(consoleErrorSpy).toHaveBeenCalledWith('Section information is undefined.') + }) + + test('handles error during generate click', async () => { + (documentSectionGenerate as jest.Mock).mockResolvedValue({ + body: {}, + status: 400 + }) + + renderWithContext() + fireEvent.click(screen.getByText('Regenerate')) + fireEvent.click(screen.getByText('Generate')) + + await waitFor(() => { + expect(mockDispatch).toHaveBeenCalledWith({ + type: 'UPDATE_DRAFT_DOCUMENTS_SECTIONS', + payload: [{ title: 'Section 1', content: 'I am sorry, I don’t have this information in the knowledge repository. Please ask another question.', metaPrompt: '' }] + }) + }) + }) + + test('handles content change', async () => { + renderWithContext() + const editableContainer = await screen.findByTestId('editable_container') + fireEvent.blur(editableContainer, { target: { textContent: 'Updated content' } }) + + expect(mockDispatch).toHaveBeenCalledWith({ + type: 'UPDATE_DRAFT_DOCUMENTS_SECTIONS', + payload: [{ title: 'Section 1', content: 'Updated content', metaPrompt: '' }] + }) + }) + + test('empty space', async () => { + (documentSectionGenerate as jest.Mock).mockResolvedValue({ + body: {}, + json: async () => ({ content: 'Generated content' }), + status: 200 + }) + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}) + const documentSectionEmpty = { ...mockState, documentSections: null } + render( + + + + ) + + fireEvent.click(screen.getByText('Regenerate')) + fireEvent.click(screen.getByText('Generate')) + expect(consoleErrorSpy).toHaveBeenCalledWith('Section information is undefined.') + }) + + test('handles content change onblur textcontent emty', async () => { + renderWithContext() + const editableContainer = await screen.findByTestId('editable_container') + fireEvent.blur(editableContainer, { target: { textContent: '' } }) + + expect(mockDispatch).toHaveBeenCalledWith({ + type: 'UPDATE_DRAFT_DOCUMENTS_SECTIONS', + payload: [{ title: 'Section 1', content: '', metaPrompt: '' }] + }) + }) +}) diff --git a/ResearchAssistant/App/frontend/src/components/DraftDocumentsView/Card.tsx b/ResearchAssistant/App/frontend/src/components/DraftDocumentsView/Card.tsx index 6613bc4c..3809a50b 100644 --- a/ResearchAssistant/App/frontend/src/components/DraftDocumentsView/Card.tsx +++ b/ResearchAssistant/App/frontend/src/components/DraftDocumentsView/Card.tsx @@ -20,7 +20,7 @@ import { Stack } from '@fluentui/react' import { createSvgIcon } from '@fluentui/react-icons-mdl2' import { Dismiss24Regular } from '@fluentui/react-icons' -const RegenerateIcon = createSvgIcon({ +export const RegenerateIcon = createSvgIcon({ svg: ({ classes }) => ( @@ -28,135 +28,19 @@ const RegenerateIcon = createSvgIcon({ ), displayName: 'RegenerateIcon' }) -export function documentSectionPrompt(title: string, topic: string): any { +export function documentSectionPrompt (title: string, topic: string): any { return `Create ${title} section of research grant application for - ${topic}.` } -const SystemErrMessage = 'I am sorry, I don’t have this information in the knowledge repository. Please ask another question.' +export const SystemErrMessage = 'I am sorry, I don’t have this information in the knowledge repository. Please ask another question.' -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 callGenerateSectionContent = async (documentSection: DocumentSection) => { - if (appStateContext?.state.researchTopic === undefined || appStateContext?.state.researchTopic === '') { - console.error('No research topic') - return '' - } - - if (documentSection.metaPrompt !== '') { - documentSection.metaPrompt = '' - } - - const generatedSection = await documentSectionGenerate(appStateContext?.state.researchTopic, documentSection) - if ((generatedSection?.body) != null && (generatedSection?.status) != 400) { - set_is_bad_request(false) - const response = await generatedSection.json() - return response.content - } else { - setTimeout(() => { - set_is_bad_request(true) - }, 2000) - return '' - } - } - - return ( - - - Topic} /> - What subject matter does your proposal cover? - - -
-