[ACS-8399] Integrate all changes with backend (#4076)

* [ACS-8399] Integrate all changes with backend

* [ACS-8399] Integrate all changes with backend - review fixes
This commit is contained in:
jacekpluta
2024-09-04 12:40:38 +02:00
committed by Aleksander Sklorz
parent 5b8d1d4088
commit 867874cf16
6 changed files with 148 additions and 16 deletions

View File

@@ -39,6 +39,7 @@ import { MatSelectionListHarness } from '@angular/material/list/testing';
import { MatMenuHarness } from '@angular/material/menu/testing'; import { MatMenuHarness } from '@angular/material/menu/testing';
import { MatSelectionList } from '@angular/material/list'; import { MatSelectionList } from '@angular/material/list';
import { MatSnackBarRef } from '@angular/material/snack-bar'; import { MatSnackBarRef } from '@angular/material/snack-bar';
import { ChangeDetectorRef } from '@angular/core';
describe('AgentsButtonComponent', () => { describe('AgentsButtonComponent', () => {
let component: AgentsButtonComponent; let component: AgentsButtonComponent;
@@ -113,6 +114,16 @@ describe('AgentsButtonComponent', () => {
expect(notificationServiceSpy).not.toHaveBeenCalled(); 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', () => { it('should show notification error on getAgents error', () => {
agents$.error('error'); agents$.error('error');
component.ngOnInit(); component.ngOnInit();
@@ -291,10 +302,22 @@ describe('AgentsButtonComponent', () => {
expect(getAvatar('2')).toBeTruthy(); 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('1').initials).toBe('HA');
expect(getAvatar('2').initials).toBe('PA'); 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');
});
}); });
}); });
}); });

View File

@@ -22,7 +22,7 @@
* from Hyland Software. If not, see <http://www.gnu.org/licenses/>. * from Hyland Software. If not, see <http://www.gnu.org/licenses/>.
*/ */
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 { CommonModule } from '@angular/common';
import { SelectionState } from '@alfresco/adf-extensions'; import { SelectionState } from '@alfresco/adf-extensions';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
@@ -73,7 +73,8 @@ export class AgentsButtonComponent implements OnInit, OnDestroy {
private notificationService: NotificationService, private notificationService: NotificationService,
private searchAiService: SearchAiService, private searchAiService: SearchAiService,
private agentService: AgentService, private agentService: AgentService,
private translateService: TranslateService private translateService: TranslateService,
private cd: ChangeDetectorRef
) {} ) {}
ngOnInit(): void { ngOnInit(): void {
@@ -90,10 +91,12 @@ export class AgentsButtonComponent implements OnInit, OnDestroy {
.subscribe( .subscribe(
(agents) => { (agents) => {
this._agents = agents; this._agents = agents;
this.cd.detectChanges();
if (this.agents.length) { if (this.agents.length) {
this._initialsByAgentId = this.agents.reduce((initials, agent) => { this._initialsByAgentId = this.agents.reduce((initials, agent) => {
const words = agent.name.split(' ').filter((word) => !word.match(/[^a-zA-Z]+/g)); 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; return initials;
}, {}); }, {});
} }

View File

@@ -185,6 +185,18 @@ describe('SearchAiInputComponent', () => {
expect(getAvatarForAgent('1').initials).toBe('HA'); expect(getAvatarForAgent('1').initials).toBe('HA');
expect(getAvatarForAgent('2').initials).toBe('PA'); 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');
});
}); });
}); });

View File

@@ -147,7 +147,7 @@ export class SearchAiInputComponent implements OnInit, OnDestroy {
this.agentControl.setValue(agents.find((agent) => agent.id === this.agentId)); this.agentControl.setValue(agents.find((agent) => agent.id === this.agentId));
this._initialsByAgentId = this.agents.reduce((initials, agent) => { this._initialsByAgentId = this.agents.reduce((initials, agent) => {
const words = agent.name.split(' ').filter((word) => !word.match(/[^a-zA-Z]+/g)); 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; return initials;
}, {}); }, {});
}, },

View File

@@ -22,10 +22,10 @@
* from Hyland Software. If not, see <http://www.gnu.org/licenses/>. * from Hyland Software. If not, see <http://www.gnu.org/licenses/>.
*/ */
import { TestBed, ComponentFixture } from '@angular/core/testing'; import { TestBed, ComponentFixture, tick, fakeAsync } from '@angular/core/testing';
import { SearchAiResultsComponent } from './search-ai-results.component'; import { SearchAiResultsComponent } from './search-ai-results.component';
import { ActivatedRoute, Params } from '@angular/router'; 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 { MatSnackBarModule } from '@angular/material/snack-bar';
import { UserPreferencesService } from '@alfresco/adf-core'; import { UserPreferencesService } from '@alfresco/adf-core';
import { MatDialogModule } from '@angular/material/dialog'; import { MatDialogModule } from '@angular/material/dialog';
@@ -37,12 +37,12 @@ import { ModalAiService } from '../../../../services/modal-ai.service';
import { delay } from 'rxjs/operators'; import { delay } from 'rxjs/operators';
import { AiAnswer, AiAnswerPaging, QuestionModel } from '@alfresco/js-api/typings'; 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 aiAnswerMock: AiAnswer = { answer: 'Some answer', questionId: 'some id', references: [] };
const getAiAnswerPaging = (withEntry: boolean): AiAnswerPaging => { const getAiAnswerPaging = (withEntry: boolean, noAnswer?: boolean): AiAnswerPaging => {
return { return {
list: { 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 } pagination: { hasMoreItems: false, maxItems: 0, totalItems: 0, skipCount: 0 }
} }
}; };
@@ -128,6 +128,75 @@ describe('SearchAiResultsComponent', () => {
expect(component.agentId).toBe(undefined); expect(component.agentId).toBe(undefined);
expect(component.hasError).toBeTrue(); 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', () => { describe('skeleton loader', () => {
@@ -149,18 +218,18 @@ describe('SearchAiResultsComponent', () => {
expect(getSkeletonElementsLength()).toBe(3); 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' }); mockQueryParams.next({ query: 'test', agentId: 'agentId1' });
spyOn(searchAiService, 'ask').and.returnValue(of(questionMock)); spyOn(searchAiService, 'ask').and.returnValue(of(questionMock));
spyOn(searchAiService, 'getAnswer').and.returnValue(of(getAiAnswerPaging(false))); spyOn(searchAiService, 'getAnswer').and.returnValue(of(getAiAnswerPaging(false)));
component.performAiSearch(); component.performAiSearch();
fixture.detectChanges(); tick(30000);
expect(component.loading).toBeFalse(); expect(component.loading).toBeFalse();
expect(getSkeletonElementsLength()).toBe(0); expect(getSkeletonElementsLength()).toBe(0);
}); }));
}); });
describe('Unsaved Changes Modal', () => { describe('Unsaved Changes Modal', () => {

View File

@@ -25,7 +25,7 @@
import { Component, OnDestroy, OnInit, ViewEncapsulation } from '@angular/core'; import { Component, OnDestroy, OnInit, ViewEncapsulation } from '@angular/core';
import { ActivatedRoute } from '@angular/router'; import { ActivatedRoute } from '@angular/router';
import { PageComponent, PageLayoutComponent, ToolbarActionComponent, ToolbarComponent } from '@alfresco/aca-shared'; 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 { import {
AvatarComponent, AvatarComponent,
ClipboardService, ClipboardService,
@@ -40,7 +40,7 @@ import { CommonModule } from '@angular/common';
import { SearchAiInputContainerComponent } from '../search-ai-input-container/search-ai-input-container.component'; import { SearchAiInputContainerComponent } from '../search-ai-input-container/search-ai-input-container.component';
import { TranslateModule, TranslateService } from '@ngx-translate/core'; import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { NodesApiService } from '@alfresco/adf-content-services'; 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 { SelectionState } from '@alfresco/adf-extensions';
import { MatIconModule } from '@angular/material/icon'; import { MatIconModule } from '@angular/material/icon';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
@@ -168,17 +168,23 @@ export class SearchAiResultsComponent extends PageComponent implements OnInit, O
performAiSearch(): void { performAiSearch(): void {
this._loading = true; this._loading = true;
this.searchAiService this.searchAiService
.ask({ .ask({
question: this.searchQuery, 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( .pipe(
switchMap((response) => this.searchAiService.getAnswer(response.questionId)), switchMap((response) => this.searchAiService.getAnswer(response.questionId)),
switchMap((response) => { switchMap((response) => {
if (!response.list.entries[0].entry?.answer) {
return throwError((e) => e);
}
this._queryAnswer = response.list.entries[0].entry; this._queryAnswer = response.list.entries[0].entry;
return forkJoin(this.queryAnswer.references.map((reference) => this.nodesApiService.getNode(reference.referenceId))); return forkJoin(this.queryAnswer.references.map((reference) => this.nodesApiService.getNode(reference.referenceId)));
}), }),
retryWhen((errors: Observable<Error>) => this.aiSearchRetryWhen(errors)),
finalize(() => (this._loading = false)), finalize(() => (this._loading = false)),
takeUntil(this.onDestroy$) takeUntil(this.onDestroy$)
) )
@@ -192,4 +198,23 @@ export class SearchAiResultsComponent extends PageComponent implements OnInit, O
() => (this._hasAnsweringError = true) () => (this._hasAnsweringError = true)
); );
} }
private aiSearchRetryWhen(errors: Observable<Error>): Observable<Error> {
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);
})
);
}
} }