diff --git a/projects/aca-content/src/lib/components/knowledge-retrieval/search-ai/agents-button/agents-button.component.spec.ts b/projects/aca-content/src/lib/components/knowledge-retrieval/search-ai/agents-button/agents-button.component.spec.ts index 08d723f6c..ad900b03b 100644 --- a/projects/aca-content/src/lib/components/knowledge-retrieval/search-ai/agents-button/agents-button.component.spec.ts +++ b/projects/aca-content/src/lib/components/knowledge-retrieval/search-ai/agents-button/agents-button.component.spec.ts @@ -39,6 +39,7 @@ import { MatSelectionListHarness } from '@angular/material/list/testing'; import { MatMenuHarness } from '@angular/material/menu/testing'; import { MatSelectionList } from '@angular/material/list'; import { MatSnackBarRef } from '@angular/material/snack-bar'; +import { ChangeDetectorRef } from '@angular/core'; describe('AgentsButtonComponent', () => { let component: AgentsButtonComponent; @@ -113,6 +114,16 @@ describe('AgentsButtonComponent', () => { expect(notificationServiceSpy).not.toHaveBeenCalled(); }); + it('should run detectChanges when getting the agents', () => { + const changeDetectorRef2 = fixture.debugElement.injector.get(ChangeDetectorRef); + const detectChangesSpy = spyOn(changeDetectorRef2.constructor.prototype, 'detectChanges'); + + component.ngOnInit(); + agents$.next(agentsWithAvatar); + + expect(detectChangesSpy).toHaveBeenCalled(); + }); + it('should show notification error on getAgents error', () => { agents$.error('error'); component.ngOnInit(); @@ -291,10 +302,22 @@ describe('AgentsButtonComponent', () => { expect(getAvatar('2')).toBeTruthy(); }); - it('should assign correct initials to each avatar for each agent', () => { + it('should assign correct initials to each avatar for each agent with double section name', () => { expect(getAvatar('1').initials).toBe('HA'); expect(getAvatar('2').initials).toBe('PA'); }); + + it('should assign correct initials to each avatar for each agent with single section name', () => { + const newAgentWithAvatarList = [ + { ...agentsWithAvatar[0], name: 'Adam' }, + { ...agentsWithAvatar[1], name: 'Bob' } + ]; + agents$.next(newAgentWithAvatarList); + fixture.detectChanges(); + + expect(getAvatar('1').initials).toBe('A'); + expect(getAvatar('2').initials).toBe('B'); + }); }); }); }); diff --git a/projects/aca-content/src/lib/components/knowledge-retrieval/search-ai/agents-button/agents-button.component.ts b/projects/aca-content/src/lib/components/knowledge-retrieval/search-ai/agents-button/agents-button.component.ts index 7090a20b8..94b1bc47d 100644 --- a/projects/aca-content/src/lib/components/knowledge-retrieval/search-ai/agents-button/agents-button.component.ts +++ b/projects/aca-content/src/lib/components/knowledge-retrieval/search-ai/agents-button/agents-button.component.ts @@ -22,7 +22,7 @@ * from Hyland Software. If not, see . */ -import { Component, Input, OnDestroy, OnInit, ViewEncapsulation } from '@angular/core'; +import { ChangeDetectorRef, Component, Input, OnDestroy, OnInit, ViewEncapsulation } from '@angular/core'; import { CommonModule } from '@angular/common'; import { SelectionState } from '@alfresco/adf-extensions'; import { Store } from '@ngrx/store'; @@ -73,7 +73,8 @@ export class AgentsButtonComponent implements OnInit, OnDestroy { private notificationService: NotificationService, private searchAiService: SearchAiService, private agentService: AgentService, - private translateService: TranslateService + private translateService: TranslateService, + private cd: ChangeDetectorRef ) {} ngOnInit(): void { @@ -90,10 +91,12 @@ export class AgentsButtonComponent implements OnInit, OnDestroy { .subscribe( (agents) => { this._agents = agents; + this.cd.detectChanges(); + if (this.agents.length) { this._initialsByAgentId = this.agents.reduce((initials, agent) => { const words = agent.name.split(' ').filter((word) => !word.match(/[^a-zA-Z]+/g)); - initials[agent.id] = `${words[0][0]}${words[1][0] || ''}`; + initials[agent.id] = `${words[0][0]}${words[1]?.[0] || ''}`; return initials; }, {}); } diff --git a/projects/aca-content/src/lib/components/knowledge-retrieval/search-ai/search-ai-input/search-ai-input.component.spec.ts b/projects/aca-content/src/lib/components/knowledge-retrieval/search-ai/search-ai-input/search-ai-input.component.spec.ts index 4e508168a..c8538456e 100644 --- a/projects/aca-content/src/lib/components/knowledge-retrieval/search-ai/search-ai-input/search-ai-input.component.spec.ts +++ b/projects/aca-content/src/lib/components/knowledge-retrieval/search-ai/search-ai-input/search-ai-input.component.spec.ts @@ -185,6 +185,18 @@ describe('SearchAiInputComponent', () => { expect(getAvatarForAgent('1').initials).toBe('HA'); expect(getAvatarForAgent('2').initials).toBe('PA'); }); + + it('should assign correct initials to each avatar for each agent with single section name', () => { + const newAgentWithAvatarList = [ + { ...agentWithAvatarList[0], name: 'Adam' }, + { ...agentWithAvatarList[1], name: 'Bob' } + ]; + agents$.next(newAgentWithAvatarList); + fixture.detectChanges(); + + expect(getAvatarForAgent('1').initials).toBe('A'); + expect(getAvatarForAgent('2').initials).toBe('B'); + }); }); }); diff --git a/projects/aca-content/src/lib/components/knowledge-retrieval/search-ai/search-ai-input/search-ai-input.component.ts b/projects/aca-content/src/lib/components/knowledge-retrieval/search-ai/search-ai-input/search-ai-input.component.ts index e61413af0..848e2858c 100644 --- a/projects/aca-content/src/lib/components/knowledge-retrieval/search-ai/search-ai-input/search-ai-input.component.ts +++ b/projects/aca-content/src/lib/components/knowledge-retrieval/search-ai/search-ai-input/search-ai-input.component.ts @@ -147,7 +147,7 @@ export class SearchAiInputComponent implements OnInit, OnDestroy { this.agentControl.setValue(agents.find((agent) => agent.id === this.agentId)); this._initialsByAgentId = this.agents.reduce((initials, agent) => { const words = agent.name.split(' ').filter((word) => !word.match(/[^a-zA-Z]+/g)); - initials[agent.id] = `${words[0][0]}${words[1][0] || ''}`; + initials[agent.id] = `${words[0][0]}${words[1]?.[0] || ''}`; return initials; }, {}); }, diff --git a/projects/aca-content/src/lib/components/knowledge-retrieval/search-ai/search-ai-results/search-ai-results.component.spec.ts b/projects/aca-content/src/lib/components/knowledge-retrieval/search-ai/search-ai-results/search-ai-results.component.spec.ts index f4107f499..cab6019bb 100644 --- a/projects/aca-content/src/lib/components/knowledge-retrieval/search-ai/search-ai-results/search-ai-results.component.spec.ts +++ b/projects/aca-content/src/lib/components/knowledge-retrieval/search-ai/search-ai-results/search-ai-results.component.spec.ts @@ -22,10 +22,10 @@ * from Hyland Software. If not, see . */ -import { TestBed, ComponentFixture } from '@angular/core/testing'; +import { TestBed, ComponentFixture, tick, fakeAsync } from '@angular/core/testing'; import { SearchAiResultsComponent } from './search-ai-results.component'; import { ActivatedRoute, Params } from '@angular/router'; -import { of, Subject } from 'rxjs'; +import { of, Subject, throwError } from 'rxjs'; import { MatSnackBarModule } from '@angular/material/snack-bar'; import { UserPreferencesService } from '@alfresco/adf-core'; import { MatDialogModule } from '@angular/material/dialog'; @@ -37,12 +37,12 @@ import { ModalAiService } from '../../../../services/modal-ai.service'; import { delay } from 'rxjs/operators'; import { AiAnswer, AiAnswerPaging, QuestionModel } from '@alfresco/js-api/typings'; -const questionMock: QuestionModel = { question: 'test', questionId: 'testId', restrictionQuery: '' }; +const questionMock: QuestionModel = { question: 'test', questionId: 'testId', restrictionQuery: { nodesIds: [] } }; const aiAnswerMock: AiAnswer = { answer: 'Some answer', questionId: 'some id', references: [] }; -const getAiAnswerPaging = (withEntry: boolean): AiAnswerPaging => { +const getAiAnswerPaging = (withEntry: boolean, noAnswer?: boolean): AiAnswerPaging => { return { list: { - entries: withEntry ? [{ entry: { answer: 'Some answer', questionId: 'some id', references: [] } }] : [], + entries: withEntry ? [{ entry: { answer: noAnswer ? '' : 'Some answer', questionId: 'some id', references: [] } }] : [], pagination: { hasMoreItems: false, maxItems: 0, totalItems: 0, skipCount: 0 } } }; @@ -128,6 +128,75 @@ describe('SearchAiResultsComponent', () => { expect(component.agentId).toBe(undefined); expect(component.hasError).toBeTrue(); }); + + it('should not get query answer and display an error when getAnswer throws error', fakeAsync(() => { + spyOn(userPreferencesService, 'get').and.returnValue(knowledgeRetrievalNodes); + spyOn(searchAiService, 'getAnswer').and.returnValue(throwError('error').pipe(delay(100))); + mockQueryParams.next({ query: 'test', agentId: 'agentId1' }); + + tick(30000); + + expect(component.queryAnswer).toBeUndefined(); + expect(component.hasAnsweringError).toBeTrue(); + expect(component.loading).toBeFalse(); + })); + + it('should get query answer and not display an error when getAnswer throws one error and one successful response', fakeAsync(() => { + spyOn(userPreferencesService, 'get').and.returnValue(knowledgeRetrievalNodes); + spyOn(searchAiService, 'getAnswer').and.returnValues(throwError('error'), of(getAiAnswerPaging(true))); + mockQueryParams.next({ query: 'test', agentId: 'agentId1' }); + + tick(3000); + + expect(component.queryAnswer).toEqual({ answer: 'Some answer', questionId: 'some id', references: [] }); + expect(component.hasAnsweringError).toBeFalse(); + })); + + it('should display and answer and not display an error when getAnswer throws nine errors and one successful response', fakeAsync(() => { + spyOn(userPreferencesService, 'get').and.returnValue(knowledgeRetrievalNodes); + spyOn(searchAiService, 'getAnswer').and.returnValues(...Array(9).fill(throwError('error')), of(getAiAnswerPaging(true))); + mockQueryParams.next({ query: 'test', agentId: 'agentId1' }); + + tick(50000); + + expect(component.queryAnswer).toEqual({ answer: 'Some answer', questionId: 'some id', references: [] }); + expect(component.hasAnsweringError).toBeFalse(); + })); + + it('should not display an answer and display an error when getAnswer throws ten errors', fakeAsync(() => { + spyOn(userPreferencesService, 'get').and.returnValue(knowledgeRetrievalNodes); + spyOn(searchAiService, 'getAnswer').and.returnValues(...Array(14).fill(throwError('error')), of(getAiAnswerPaging(true))); + mockQueryParams.next({ query: 'test', agentId: 'agentId1' }); + + tick(30000); + + expect(component.queryAnswer).toBeUndefined(); + expect(component.hasAnsweringError).toBeTrue(); + expect(component.loading).toBeFalse(); + })); + + it('should not display answer and display an error if received AiAnswerPaging without answer ten times', fakeAsync(() => { + spyOn(userPreferencesService, 'get').and.returnValue(knowledgeRetrievalNodes); + spyOn(searchAiService, 'getAnswer').and.returnValues(...Array(10).fill(of(getAiAnswerPaging(true, true)))); + mockQueryParams.next({ query: 'test', agentId: 'agentId1' }); + + tick(30000); + + expect(component.queryAnswer).toBeUndefined(); + expect(component.hasAnsweringError).toBeTrue(); + expect(component.loading).toBeFalse(); + })); + + it('should not display error and display and answer if received AiAnswerPaging without answer nine times and with answer one time', fakeAsync(() => { + spyOn(userPreferencesService, 'get').and.returnValue(knowledgeRetrievalNodes); + spyOn(searchAiService, 'getAnswer').and.returnValues(...Array(9).fill(of(getAiAnswerPaging(true, true))), of(getAiAnswerPaging(true))); + mockQueryParams.next({ query: 'test', agentId: 'agentId1' }); + + tick(30000); + + expect(component.queryAnswer).toEqual({ answer: 'Some answer', questionId: 'some id', references: [] }); + expect(component.hasAnsweringError).toBeFalse(); + })); }); describe('skeleton loader', () => { @@ -149,18 +218,18 @@ describe('SearchAiResultsComponent', () => { expect(getSkeletonElementsLength()).toBe(3); }); - it('should not display skeleton when loading is false', () => { + it('should not display skeleton when loading is false', fakeAsync(() => { mockQueryParams.next({ query: 'test', agentId: 'agentId1' }); spyOn(searchAiService, 'ask').and.returnValue(of(questionMock)); spyOn(searchAiService, 'getAnswer').and.returnValue(of(getAiAnswerPaging(false))); component.performAiSearch(); - fixture.detectChanges(); + tick(30000); expect(component.loading).toBeFalse(); expect(getSkeletonElementsLength()).toBe(0); - }); + })); }); describe('Unsaved Changes Modal', () => { diff --git a/projects/aca-content/src/lib/components/knowledge-retrieval/search-ai/search-ai-results/search-ai-results.component.ts b/projects/aca-content/src/lib/components/knowledge-retrieval/search-ai/search-ai-results/search-ai-results.component.ts index 05a9f22f9..dd735fcd5 100644 --- a/projects/aca-content/src/lib/components/knowledge-retrieval/search-ai/search-ai-results/search-ai-results.component.ts +++ b/projects/aca-content/src/lib/components/knowledge-retrieval/search-ai/search-ai-results/search-ai-results.component.ts @@ -25,7 +25,7 @@ import { Component, OnDestroy, OnInit, ViewEncapsulation } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; import { PageComponent, PageLayoutComponent, ToolbarActionComponent, ToolbarComponent } from '@alfresco/aca-shared'; -import { finalize, switchMap, takeUntil } from 'rxjs/operators'; +import { concatMap, delay, finalize, retryWhen, skipWhile, switchMap, takeUntil } from 'rxjs/operators'; import { AvatarComponent, ClipboardService, @@ -40,7 +40,7 @@ import { CommonModule } from '@angular/common'; import { SearchAiInputContainerComponent } from '../search-ai-input-container/search-ai-input-container.component'; import { TranslateModule, TranslateService } from '@ngx-translate/core'; import { NodesApiService } from '@alfresco/adf-content-services'; -import { forkJoin } from 'rxjs'; +import { forkJoin, Observable, of, throwError } from 'rxjs'; import { SelectionState } from '@alfresco/adf-extensions'; import { MatIconModule } from '@angular/material/icon'; import { MatButtonModule } from '@angular/material/button'; @@ -168,17 +168,23 @@ export class SearchAiResultsComponent extends PageComponent implements OnInit, O performAiSearch(): void { this._loading = true; + this.searchAiService .ask({ question: this.searchQuery, - nodeIds: this._selectedNodesState?.nodes?.length ? this._selectedNodesState.nodes.map((node) => node.entry.id) : [] + nodeIds: this._selectedNodesState?.nodes?.length ? this._selectedNodesState.nodes.map((node) => node.entry.id) : [], + agentId: this._agentId }) .pipe( switchMap((response) => this.searchAiService.getAnswer(response.questionId)), switchMap((response) => { + if (!response.list.entries[0].entry?.answer) { + return throwError((e) => e); + } this._queryAnswer = response.list.entries[0].entry; return forkJoin(this.queryAnswer.references.map((reference) => this.nodesApiService.getNode(reference.referenceId))); }), + retryWhen((errors: Observable) => this.aiSearchRetryWhen(errors)), finalize(() => (this._loading = false)), takeUntil(this.onDestroy$) ) @@ -192,4 +198,23 @@ export class SearchAiResultsComponent extends PageComponent implements OnInit, O () => (this._hasAnsweringError = true) ); } + + private aiSearchRetryWhen(errors: Observable): Observable { + this._hasAnsweringError = false; + const delayBetweenRetries = 3000; + const maxRetries = 9; + + return errors.pipe( + skipWhile(() => this.hasAnsweringError), + delay(delayBetweenRetries), + concatMap((e, index) => { + if (index === maxRetries) { + this._hasAnsweringError = true; + this._loading = false; + return throwError(e); + } + return of(null); + }) + ); + } }