diff --git a/src-ui/src/app/components/document-detail/document-detail.component.spec.ts b/src-ui/src/app/components/document-detail/document-detail.component.spec.ts index 9b285b1a0..1412ced27 100644 --- a/src-ui/src/app/components/document-detail/document-detail.component.spec.ts +++ b/src-ui/src/app/components/document-detail/document-detail.component.spec.ts @@ -43,7 +43,7 @@ import { } from 'src/app/data/filter-rule-type' import { StoragePath } from 'src/app/data/storage-path' import { Tag } from 'src/app/data/tag' -import { SETTINGS_KEYS } from 'src/app/data/ui-settings' +import { SETTINGS_KEYS, SuggestionSource } from 'src/app/data/ui-settings' import { PermissionsGuard } from 'src/app/guards/permissions.guard' import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe' import { DocumentTitlePipe } from 'src/app/pipes/document-title.pipe' @@ -1528,6 +1528,113 @@ describe('DocumentDetailComponent', () => { expect(component.suggestionsLoading()).toBeFalsy() }) + it('should get and merge ML and AI suggestions when source is both', () => { + settingsService.set( + SETTINGS_KEYS.DOCUMENT_EDITING_SUGGESTION_SOURCE, + SuggestionSource.Both + ) + const getSetting = settingsService.get.bind(settingsService) + jest + .spyOn(settingsService, 'get') + .mockImplementation((key) => + key === SETTINGS_KEYS.AI_ENABLED ? true : getSetting(key) + ) + const suggestionsSpy = jest + .spyOn(documentService, 'getSuggestions') + .mockReturnValue(of({ tags: [42], dates: ['2024-01-01'] })) + const aiSuggestionsSpy = jest + .spyOn(documentService, 'getAiSuggestions') + .mockReturnValue( + of({ title: 'AI title', tags: [42, 43], suggested_tags: ['New'] }) + ) + initNormally() + expect(suggestionsSpy).toHaveBeenCalled() + expect(aiSuggestionsSpy).toHaveBeenCalled() + expect(component.suggestions().title).toEqual('AI title') + expect(component.suggestions().tags).toEqual([42, 43]) + expect(component.suggestions().suggested_tags).toEqual(['New']) + expect(component.suggestions().dates).toEqual(['2024-01-01']) + }) + + it('should only fetch sources not yet fetched for the document', () => { + settingsService.set(SETTINGS_KEYS.DOCUMENT_EDITING_AUTO_SUGGEST, false) + settingsService.set( + SETTINGS_KEYS.DOCUMENT_EDITING_SUGGESTION_SOURCE, + SuggestionSource.ML + ) + const getSetting = settingsService.get.bind(settingsService) + jest + .spyOn(settingsService, 'get') + .mockImplementation((key) => + key === SETTINGS_KEYS.AI_ENABLED ? true : getSetting(key) + ) + const suggestionsSpy = jest + .spyOn(documentService, 'getSuggestions') + .mockReturnValue(of({ tags: [42] })) + const aiSuggestionsSpy = jest + .spyOn(documentService, 'getAiSuggestions') + .mockReturnValue(of({ tags: [43] })) + initNormally() + + component.getSuggestions() + expect(suggestionsSpy).toHaveBeenCalledTimes(1) + expect(aiSuggestionsSpy).not.toHaveBeenCalled() + + component.getSuggestions(SuggestionSource.Both) + expect(suggestionsSpy).toHaveBeenCalledTimes(1) + expect(aiSuggestionsSpy).toHaveBeenCalledTimes(1) + expect(component.suggestions().tags).toEqual([42, 43]) + + component.getSuggestions(SuggestionSource.Both) + expect(suggestionsSpy).toHaveBeenCalledTimes(1) + expect(aiSuggestionsSpy).toHaveBeenCalledTimes(1) + }) + + it('should use the per-document source override and reset it on document change', () => { + settingsService.set(SETTINGS_KEYS.DOCUMENT_EDITING_AUTO_SUGGEST, false) + const getSetting = settingsService.get.bind(settingsService) + jest + .spyOn(settingsService, 'get') + .mockImplementation((key) => + key === SETTINGS_KEYS.AI_ENABLED ? true : getSetting(key) + ) + initNormally() + expect(component.suggestionSource).toEqual(SuggestionSource.AI) + component.suggestionSourceOverride.set(SuggestionSource.ML) + expect(component.suggestionSource).toEqual(SuggestionSource.ML) + + jest + .spyOn(documentService, 'get') + .mockReturnValueOnce(of(Object.assign({}, doc))) + ;(component as any).loadDocument(doc.id, true) + expect(component.suggestionSourceOverride()).toBeNull() + expect(component.fetchedSuggestionSources()).toEqual([]) + }) + + it('should keep suggestions from one source if the other fails', () => { + settingsService.set( + SETTINGS_KEYS.DOCUMENT_EDITING_SUGGESTION_SOURCE, + SuggestionSource.Both + ) + const getSetting = settingsService.get.bind(settingsService) + jest + .spyOn(settingsService, 'get') + .mockImplementation((key) => + key === SETTINGS_KEYS.AI_ENABLED ? true : getSetting(key) + ) + const errorSpy = jest.spyOn(toastService, 'showError') + jest + .spyOn(documentService, 'getSuggestions') + .mockReturnValue(of({ tags: [42] })) + jest + .spyOn(documentService, 'getAiSuggestions') + .mockReturnValue(throwError(() => new Error('failed'))) + initNormally() + expect(errorSpy).toHaveBeenCalled() + expect(component.suggestions().tags).toEqual([42]) + expect(component.fetchedSuggestionSources()).toEqual([SuggestionSource.ML]) + }) + it('should show error if needed for get suggestions', () => { const suggestionsSpy = jest.spyOn(documentService, 'getSuggestions') const errorSpy = jest.spyOn(toastService, 'showError') diff --git a/src-ui/src/app/components/document-detail/document-detail.component.ts b/src-ui/src/app/components/document-detail/document-detail.component.ts index b9ec0bb76..ccb8e1bca 100644 --- a/src-ui/src/app/components/document-detail/document-detail.component.ts +++ b/src-ui/src/app/components/document-detail/document-detail.component.ts @@ -28,7 +28,7 @@ import { import { dirtyCheck, DirtyComponent } from '@ngneat/dirty-check-forms' import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' import { DeviceDetectorService } from 'ngx-device-detector' -import { BehaviorSubject, Observable, of, Subject, timer } from 'rxjs' +import { BehaviorSubject, merge, Observable, of, Subject, timer } from 'rxjs' import { catchError, debounceTime, @@ -48,7 +48,10 @@ import { DataType } from 'src/app/data/datatype' import { Document, DocumentVersionInfo } from 'src/app/data/document' import { DocumentMetadata } from 'src/app/data/document-metadata' import { DocumentNote } from 'src/app/data/document-note' -import { DocumentSuggestions } from 'src/app/data/document-suggestions' +import { + DocumentSuggestions, + mergeSuggestions, +} from 'src/app/data/document-suggestions' import { DocumentType } from 'src/app/data/document-type' import { FilterRule } from 'src/app/data/filter-rule' import { @@ -63,7 +66,7 @@ import { import { ObjectWithId } from 'src/app/data/object-with-id' import { StoragePath } from 'src/app/data/storage-path' import { Tag } from 'src/app/data/tag' -import { SETTINGS_KEYS } from 'src/app/data/ui-settings' +import { SETTINGS_KEYS, SuggestionSource } from 'src/app/data/ui-settings' import { User } from 'src/app/data/user' import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive' import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe' @@ -240,6 +243,10 @@ export class DocumentDetailComponent private readonly autoSuggestSetting = this.settings.getSignal( SETTINGS_KEYS.DOCUMENT_EDITING_AUTO_SUGGEST ) + private readonly suggestionSourceSetting = + this.settings.getSignal( + SETTINGS_KEYS.DOCUMENT_EDITING_SUGGESTION_SOURCE + ) private readonly hiddenFieldsSetting = this.settings.getSignal< DocumentDetailFieldID[] >(SETTINGS_KEYS.DOCUMENT_DETAILS_HIDDEN_FIELDS) @@ -261,6 +268,9 @@ export class DocumentDetailComponent readonly metadata = signal(undefined) readonly suggestions = signal(undefined) readonly suggestionsLoading = signal(false) + // per-document, resets on navigation + readonly suggestionSourceOverride = signal(null) + readonly fetchedSuggestionSources = signal([]) readonly users = signal(undefined) readonly title = signal(undefined) @@ -365,6 +375,11 @@ export class DocumentDetailComponent return this.autoSuggestSetting() } + get suggestionSource(): SuggestionSource { + if (!this.aiEnabled) return SuggestionSource.ML + return this.suggestionSourceOverride() ?? this.suggestionSourceSetting() + } + get archiveContentRenderType(): ContentRenderType { const hasArchiveVersion = this.metadata()?.has_archive_version ?? @@ -590,6 +605,8 @@ export class DocumentDetailComponent } this.documentId.set(doc.id) this.suggestions.set(null) + this.suggestionSourceOverride.set(null) + this.fetchedSuggestionSources.set([]) const openDocument = this.openDocumentService.getOpenDocument( this.documentId() ) @@ -1077,29 +1094,44 @@ export class DocumentDetailComponent return this.documentForm.get('custom_fields') as FormArray } - getSuggestions() { + getSuggestions(source: SuggestionSource = this.suggestionSource) { + const sources = ( + source === SuggestionSource.Both + ? [SuggestionSource.ML, SuggestionSource.AI] + : [source] + ).filter((s) => !this.fetchedSuggestionSources().includes(s)) + if (!sources.length) return + this.suggestionsLoading.set(true) - const suggestionsObservable = this.aiEnabled - ? this.documentsService.getAiSuggestions(this.documentId()) - : this.documentsService.getSuggestions(this.documentId()) - suggestionsObservable + merge( + ...sources.map((s) => + (s === SuggestionSource.AI + ? this.documentsService.getAiSuggestions(this.documentId()) + : this.documentsService.getSuggestions(this.documentId()) + ).pipe( + first(), + map((result) => ({ source: s, result })), + catchError((error) => { + this.toastService.showError( + $localize`Error retrieving suggestions.`, + error + ) + return of(null) + }) + ) + ) + ) .pipe( - first(), takeUntil(this.unsubscribeNotifier), takeUntil(this.docChangeNotifier), finalize(() => this.suggestionsLoading.set(false)) ) - .subscribe({ - next: (result) => { - this.suggestions.set(result) - }, - error: (error) => { - this.suggestions.set(null) - this.toastService.showError( - $localize`Error retrieving suggestions.`, - error - ) - }, + .subscribe((response) => { + if (!response) return + this.fetchedSuggestionSources.update((f) => [...f, response.source]) + this.suggestions.set( + mergeSuggestions(this.suggestions(), response.result) + ) }) } diff --git a/src-ui/src/app/data/document-suggestions.ts b/src-ui/src/app/data/document-suggestions.ts index 447c4402b..f29daff57 100644 --- a/src-ui/src/app/data/document-suggestions.ts +++ b/src-ui/src/app/data/document-suggestions.ts @@ -15,3 +15,33 @@ export interface DocumentSuggestions { dates?: string[] // ISO-formatted date string e.g. 2022-11-03 } + +const union = (a: T[] = [], b: T[] = []): T[] => [...new Set([...a, ...b])] + +export function mergeSuggestions( + a: DocumentSuggestions, + b: DocumentSuggestions +): DocumentSuggestions { + if (!a) return b + return { + title: a.title || b.title, + tags: union(a.tags, b.tags), + suggested_tags: union(a.suggested_tags, b.suggested_tags), + correspondents: union(a.correspondents, b.correspondents), + suggested_correspondents: union( + a.suggested_correspondents, + b.suggested_correspondents + ), + document_types: union(a.document_types, b.document_types), + suggested_document_types: union( + a.suggested_document_types, + b.suggested_document_types + ), + storage_paths: union(a.storage_paths, b.storage_paths), + suggested_storage_paths: union( + a.suggested_storage_paths, + b.suggested_storage_paths + ), + dates: union(a.dates, b.dates), + } +}