From 58bda486822f9a58982fa1a8ff218ff441e6caef Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Thu, 1 Oct 2026 13:00:52 -0700 Subject: [PATCH] Enhancement: more control over suggestion requests (#14258) --- docs/advanced_usage.md | 16 ++- .../admin/settings/settings.component.html | 18 +++ .../admin/settings/settings.component.spec.ts | 2 +- .../admin/settings/settings.component.ts | 14 ++ .../suggestions-dropdown.component.html | 128 +++++++++++------- .../suggestions-dropdown.component.scss | 4 + .../suggestions-dropdown.component.spec.ts | 60 +++++++- .../suggestions-dropdown.component.ts | 43 +++++- .../document-detail.component.html | 6 +- .../document-detail.component.spec.ts | 109 ++++++++++++++- .../document-detail.component.ts | 76 ++++++++--- src-ui/src/app/data/document-suggestions.ts | 30 ++++ src-ui/src/app/data/ui-settings.ts | 13 ++ src-ui/src/main.ts | 4 + src-ui/src/styles.scss | 1 + 15 files changed, 439 insertions(+), 85 deletions(-) diff --git a/docs/advanced_usage.md b/docs/advanced_usage.md index 105add465..90dc50c65 100644 --- a/docs/advanced_usage.md +++ b/docs/advanced_usage.md @@ -136,13 +136,15 @@ for suggested generation and embedding models. ### AI-assisted suggestions With AI enabled, Paperless-ngx can suggest a title, tags, correspondent, document type, -storage path and dates by sending the document to the LLM. This is **opt-in per request** -and surfaces through the "Suggest" control on the document detail page, alongside the -classic classifier-based suggestions — it does not disable them. Suggestions are requested -automatically when you open a document that carries an inbox tag unless "Automatically request -suggestions for inbox documents" under Settings > Documents is disabled. Suggestion output -language can be steered with -[`PAPERLESS_AI_LLM_OUTPUT_LANGUAGE`](configuration.md#PAPERLESS_AI_LLM_OUTPUT_LANGUAGE) +storage path and dates by sending the document to the LLM using "Suggest" button on the document +detail page. You can choose which type of suggestions are requested by default under Settings > +Documents, either ML (classifier-based) suggestions, AI suggestions, or both. When both are requested +the results are combined. + +Suggestions are requested automatically when you open a document that carries an inbox tag +unless "Automatically request suggestions for inbox documents" under Settings > Documents is disabled. + +Suggestion output language can be steered with [`PAPERLESS_AI_LLM_OUTPUT_LANGUAGE`](configuration.md#PAPERLESS_AI_LLM_OUTPUT_LANGUAGE) (otherwise it follows the user's UI language). ### The LLM index (RAG) and similar documents diff --git a/src-ui/src/app/components/admin/settings/settings.component.html b/src-ui/src/app/components/admin/settings/settings.component.html index b520e8218..4ff3e671d 100644 --- a/src-ui/src/app/components/admin/settings/settings.component.html +++ b/src-ui/src/app/components/admin/settings/settings.component.html @@ -253,6 +253,24 @@ + @if (aiEnabled) { +
+
+ Suggestions default to +
+
+
+ + + + + + +
+
+
+ } +
diff --git a/src-ui/src/app/components/admin/settings/settings.component.spec.ts b/src-ui/src/app/components/admin/settings/settings.component.spec.ts index 71889e74b..1389a290d 100644 --- a/src-ui/src/app/components/admin/settings/settings.component.spec.ts +++ b/src-ui/src/app/components/admin/settings/settings.component.spec.ts @@ -307,7 +307,7 @@ describe('SettingsComponent', () => { expect(toastErrorSpy).toHaveBeenCalled() expect(storeSpy).toHaveBeenCalled() expect(appearanceSettingsSpy).not.toHaveBeenCalled() - expect(setSpy).toHaveBeenCalledTimes(34) + expect(setSpy).toHaveBeenCalledTimes(35) expect(setSpy).toHaveBeenCalledWith(SETTINGS_KEYS.SIDEBAR_HIDDEN_ITEMS, [ HideableSidebarItemID.Workflows, ]) diff --git a/src-ui/src/app/components/admin/settings/settings.component.ts b/src-ui/src/app/components/admin/settings/settings.component.ts index a4ead8373..2fe40e540 100644 --- a/src-ui/src/app/components/admin/settings/settings.component.ts +++ b/src-ui/src/app/components/admin/settings/settings.component.ts @@ -44,6 +44,7 @@ import { HIDEABLE_SIDEBAR_ITEM_IDS, HideableSidebarItemID, 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' @@ -184,6 +185,7 @@ export class SettingsComponent documentEditingRemoveInboxTags: new FormControl(null), documentEditingOverlayThumbnail: new FormControl(null), documentEditingAutoSuggest: new FormControl(null), + documentEditingSuggestionSource: new FormControl(null), documentDetailsHiddenFields: new FormControl([]), searchDbOnly: new FormControl(null), searchLink: new FormControl(null), @@ -217,6 +219,11 @@ export class SettingsComponent public readonly PdfZoomScale = PdfZoomScale public readonly PdfEditorEditMode = PdfEditorEditMode + public readonly SuggestionSource = SuggestionSource + + get aiEnabled(): boolean { + return this.settings.get(SETTINGS_KEYS.AI_ENABLED) + } public readonly documentDetailFieldOptions = documentDetailFieldOptions public readonly sidebarItemOptions = HIDEABLE_SIDEBAR_ITEM_IDS.map((id) => ({ @@ -404,6 +411,9 @@ export class SettingsComponent documentEditingAutoSuggest: this.settings.get( SETTINGS_KEYS.DOCUMENT_EDITING_AUTO_SUGGEST ), + documentEditingSuggestionSource: this.settings.get( + SETTINGS_KEYS.DOCUMENT_EDITING_SUGGESTION_SOURCE + ), documentDetailsHiddenFields: this.settings.get( SETTINGS_KEYS.DOCUMENT_DETAILS_HIDDEN_FIELDS ), @@ -625,6 +635,10 @@ export class SettingsComponent SETTINGS_KEYS.DOCUMENT_EDITING_AUTO_SUGGEST, this.settingsForm.value.documentEditingAutoSuggest ) + this.settings.set( + SETTINGS_KEYS.DOCUMENT_EDITING_SUGGESTION_SOURCE, + this.settingsForm.value.documentEditingSuggestionSource + ) this.settings.set( SETTINGS_KEYS.DOCUMENT_DETAILS_HIDDEN_FIELDS, this.settingsForm.value.documentDetailsHiddenFields diff --git a/src-ui/src/app/components/common/suggestions-dropdown/suggestions-dropdown.component.html b/src-ui/src/app/components/common/suggestions-dropdown/suggestions-dropdown.component.html index ad2b72986..8d0ce0547 100644 --- a/src-ui/src/app/components/common/suggestions-dropdown/suggestions-dropdown.component.html +++ b/src-ui/src/app/components/common/suggestions-dropdown/suggestions-dropdown.component.html @@ -1,58 +1,84 @@ -
- + + @if (aiEnabled()) { +
+ + +
+
+ @if (novelSuggestions === 0 && reusableSuggestions === 0) { +
+ No novel suggestions +
+ } + @if (suggestions()?.suggested_tags?.length > 0) { + Tags + @for (tag of suggestions().suggested_tags; track tag) { + + } + } + @if (suggestions()?.suggested_document_types?.length > 0) { +
Document Types
+ @for (type of suggestions().suggested_document_types; track type) { + + } + } + @if (suggestions()?.suggested_correspondents?.length > 0) { +
Correspondents
+ @for (correspondent of suggestions().suggested_correspondents; track correspondent) { + + } + } + @if (reusableSuggestions > 0) { +
+ {reusableSuggestions, plural, =1 {1 existing value suggested below} other {{{reusableSuggestions}} existing values suggested below}} +
+ } +
+
+
} - @if (noSuggestions) { - No suggestions - } @else { - Suggest - } - @if (totalSuggestions > 0) { - {{ totalSuggestions }} - } - +
@if (aiEnabled()) { -
- - -
-
- @if (novelSuggestions === 0 && reusableSuggestions === 0) { -
- No novel suggestions -
- } - @if (suggestions()?.suggested_tags?.length > 0) { - Tags - @for (tag of suggestions().suggested_tags; track tag) { - - } - } - @if (suggestions()?.suggested_document_types?.length > 0) { -
Document Types
- @for (type of suggestions().suggested_document_types; track type) { - - } - } - @if (suggestions()?.suggested_correspondents?.length > 0) { -
Correspondents
- @for (correspondent of suggestions().suggested_correspondents; track correspondent) { - - } - } - @if (reusableSuggestions > 0) { -
- {reusableSuggestions, plural, =1 {1 existing value suggested below} other {{{reusableSuggestions}} existing values suggested below}} -
- } +
+
Suggest using:
+
+ + +
+
+ +
diff --git a/src-ui/src/app/components/common/suggestions-dropdown/suggestions-dropdown.component.scss b/src-ui/src/app/components/common/suggestions-dropdown/suggestions-dropdown.component.scss index 19aa1dc7d..eafc734b3 100644 --- a/src-ui/src/app/components/common/suggestions-dropdown/suggestions-dropdown.component.scss +++ b/src-ui/src/app/components/common/suggestions-dropdown/suggestions-dropdown.component.scss @@ -1,3 +1,7 @@ .suggestions-dropdown { min-width: 250px; } + +.btn-link.dropdown-toggle::after { + display: none; +} diff --git a/src-ui/src/app/components/common/suggestions-dropdown/suggestions-dropdown.component.spec.ts b/src-ui/src/app/components/common/suggestions-dropdown/suggestions-dropdown.component.spec.ts index 6b93d48af..b43c0cba7 100644 --- a/src-ui/src/app/components/common/suggestions-dropdown/suggestions-dropdown.component.spec.ts +++ b/src-ui/src/app/components/common/suggestions-dropdown/suggestions-dropdown.component.spec.ts @@ -1,6 +1,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing' import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap' import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons' +import { SuggestionSource } from 'src/app/data/ui-settings' import { SuggestionsDropdownComponent } from './suggestions-dropdown.component' describe('SuggestionsDropdownComponent', () => { @@ -179,14 +180,71 @@ describe('SuggestionsDropdownComponent', () => { it('should toggle dropdown when clickSuggest is called and suggestions are not null', () => { fixture.componentRef.setInput('aiEnabled', true) + fixture.componentRef.setInput('fetchedSources', [SuggestionSource.ML]) fixture.detectChanges() fixture.componentRef.setInput('suggestions', { suggested_correspondents: [], suggested_tags: [], suggested_document_types: [], }) + fixture.detectChanges() component.clickSuggest() - expect(component.dropdown.open).toBeTruthy() + expect(component.dropdown.isOpen()).toBeTruthy() expect(fixture.nativeElement.textContent).toContain('No novel suggestions') }) + + it('should fetch unfetched sources and show existing suggestions', () => { + jest.spyOn(component.getSuggestions, 'emit') + fixture.componentRef.setInput('aiEnabled', true) + fixture.componentRef.setInput('source', SuggestionSource.Both) + fixture.componentRef.setInput('fetchedSources', [SuggestionSource.ML]) + fixture.componentRef.setInput('suggestions', { tags: [1] }) + fixture.detectChanges() + component.clickSuggest() + expect(component.getSuggestions.emit).toHaveBeenCalledWith( + SuggestionSource.Both + ) + expect(component.dropdown.isOpen()).toBeTruthy() + }) + + it('should only show source options when AI is enabled', () => { + expect( + fixture.nativeElement.querySelector('#suggestionSourceML') + ).toBeNull() + fixture.componentRef.setInput('aiEnabled', true) + fixture.detectChanges() + fixture.nativeElement + .querySelector('button[title="Suggestion options"]') + .click() + fixture.detectChanges() + expect( + fixture.nativeElement.querySelector('#suggestionSourceML') + ).not.toBeNull() + }) + + it('should emit source changes and never allow no source', () => { + const emitSpy = jest.spyOn(component.sourceChange, 'emit') + component.setSources(true, true) + expect(emitSpy).toHaveBeenCalledWith(SuggestionSource.Both) + component.setSources(true, false) + expect(emitSpy).toHaveBeenCalledWith(SuggestionSource.ML) + component.setSources(false, true) + expect(emitSpy).toHaveBeenCalledWith(SuggestionSource.AI) + + emitSpy.mockClear() + component.setSources(false, false) + expect(emitSpy).not.toHaveBeenCalled() + }) + + it('should indicate a non-default source', () => { + fixture.componentRef.setInput('aiEnabled', true) + fixture.componentRef.setInput('source', SuggestionSource.AI) + fixture.componentRef.setInput('defaultSource', SuggestionSource.AI) + fixture.detectChanges() + expect(fixture.nativeElement.textContent).not.toContain('Not using default') + + fixture.componentRef.setInput('source', SuggestionSource.Both) + fixture.detectChanges() + expect(fixture.nativeElement.textContent).toContain('Not using default') + }) }) diff --git a/src-ui/src/app/components/common/suggestions-dropdown/suggestions-dropdown.component.ts b/src-ui/src/app/components/common/suggestions-dropdown/suggestions-dropdown.component.ts index ebb14ff79..1a525f131 100644 --- a/src-ui/src/app/components/common/suggestions-dropdown/suggestions-dropdown.component.ts +++ b/src-ui/src/app/components/common/suggestions-dropdown/suggestions-dropdown.component.ts @@ -8,6 +8,7 @@ import { import { NgbDropdown, NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap' import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' import { DocumentSuggestions } from 'src/app/data/document-suggestions' +import { SuggestionSource } from 'src/app/data/ui-settings' import { pngxPopperOptions } from 'src/app/utils/popper-options' @Component({ @@ -18,12 +19,16 @@ import { pngxPopperOptions } from 'src/app/utils/popper-options' }) export class SuggestionsDropdownComponent { public popperOptions = pngxPopperOptions + public readonly SuggestionSource = SuggestionSource @ViewChild('dropdown') dropdown: NgbDropdown readonly suggestions = input(null) readonly aiEnabled = input(false) readonly loading = input(false) readonly disabled = input(false) + readonly source = input(SuggestionSource.ML) + readonly defaultSource = input(SuggestionSource.ML) + readonly fetchedSources = input([]) readonly appliedTags = input([]) readonly appliedCorrespondent = input(null) @@ -31,8 +36,10 @@ export class SuggestionsDropdownComponent { readonly appliedStoragePath = input(null) @Output() - getSuggestions: EventEmitter = - new EventEmitter() + getSuggestions: EventEmitter = new EventEmitter() + + @Output() + sourceChange: EventEmitter = new EventEmitter() @Output() addTag: EventEmitter = new EventEmitter() @@ -53,12 +60,42 @@ export class SuggestionsDropdownComponent { } if (!this.suggestions()) { - this.getSuggestions.emit(this) + this.getSuggestions.emit(this.source()) + } else if (this.hasUnfetchedSources) { + // sources changed, fetch the rest and show what we have meanwhile + this.getSuggestions.emit(this.source()) + this.dropdown?.open() } else { this.dropdown?.toggle() } } + get useML(): boolean { + return this.source() !== SuggestionSource.AI + } + + get useAI(): boolean { + return this.source() !== SuggestionSource.ML + } + + get hasUnfetchedSources(): boolean { + const fetched = this.fetchedSources() + return ( + (this.useML && !fetched.includes(SuggestionSource.ML)) || + (this.useAI && !fetched.includes(SuggestionSource.AI)) + ) + } + + public setSources(ml: boolean, ai: boolean) { + if (ml && ai) { + this.sourceChange.emit(SuggestionSource.Both) + } else if (ml) { + this.sourceChange.emit(SuggestionSource.ML) + } else if (ai) { + this.sourceChange.emit(SuggestionSource.AI) + } + } + get novelSuggestions(): number { return ( (this.suggestions()?.suggested_correspondents?.length ?? 0) + diff --git a/src-ui/src/app/components/document-detail/document-detail.component.html b/src-ui/src/app/components/document-detail/document-detail.component.html index c3f7e199d..64d0776aa 100644 --- a/src-ui/src/app/components/document-detail/document-detail.component.html +++ b/src-ui/src/app/components/document-detail/document-detail.component.html @@ -134,11 +134,15 @@ [loading]="suggestionsLoading()" [suggestions]="suggestions()" [aiEnabled]="aiEnabled" + [source]="suggestionSource" + [defaultSource]="defaultSuggestionSource" + [fetchedSources]="fetchedSuggestionSources()" [appliedTags]="documentForm.value.tags" [appliedCorrespondent]="documentForm.value.correspondent" [appliedDocumentType]="documentForm.value.document_type" [appliedStoragePath]="documentForm.value.storage_path" - (getSuggestions)="getSuggestions()" + (getSuggestions)="getSuggestions($event)" + (sourceChange)="suggestionSourceOverride.set($event)" (addTag)="createTag($event)" (addDocumentType)="createDocumentType($event)" (addCorrespondent)="createCorrespondent($event)"> 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..5b12ca98b 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,15 @@ export class DocumentDetailComponent return this.autoSuggestSetting() } + get defaultSuggestionSource(): SuggestionSource { + return this.aiEnabled ? this.suggestionSourceSetting() : SuggestionSource.ML + } + + get suggestionSource(): SuggestionSource { + if (!this.aiEnabled) return SuggestionSource.ML + return this.suggestionSourceOverride() ?? this.defaultSuggestionSource + } + get archiveContentRenderType(): ContentRenderType { const hasArchiveVersion = this.metadata()?.has_archive_version ?? @@ -590,6 +609,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 +1098,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), + } +} diff --git a/src-ui/src/app/data/ui-settings.ts b/src-ui/src/app/data/ui-settings.ts index 5bc500872..32c5242bd 100644 --- a/src-ui/src/app/data/ui-settings.ts +++ b/src-ui/src/app/data/ui-settings.ts @@ -20,6 +20,12 @@ export enum GlobalSearchType { TITLE_CONTENT = 'title-content', } +export enum SuggestionSource { + ML = 'ml', + AI = 'ai', + Both = 'both', +} + export enum CollapsibleSection { ATTRIBUTES = 'attributes', } @@ -98,6 +104,8 @@ export const SETTINGS_KEYS = { 'general-settings:document-editing:overlay-thumbnail', DOCUMENT_EDITING_AUTO_SUGGEST: 'general-settings:document-editing:auto-suggest', + DOCUMENT_EDITING_SUGGESTION_SOURCE: + 'general-settings:document-editing:suggestion-source', DOCUMENT_DETAILS_HIDDEN_FIELDS: 'general-settings:document-details:hidden-fields', SEARCH_DB_ONLY: 'general-settings:search:db-only', @@ -326,6 +334,11 @@ export const SETTINGS: UiSetting[] = [ type: 'boolean', default: true, }, + { + key: SETTINGS_KEYS.DOCUMENT_EDITING_SUGGESTION_SOURCE, + type: 'string', + default: SuggestionSource.AI, + }, { key: SETTINGS_KEYS.DOCUMENT_DETAILS_HIDDEN_FIELDS, type: 'array', diff --git a/src-ui/src/main.ts b/src-ui/src/main.ts index dc8d8d3f5..83990a3f9 100644 --- a/src-ui/src/main.ts +++ b/src-ui/src/main.ts @@ -74,6 +74,7 @@ import { clipboardCheckFill, clipboardFill, clockHistory, + cpu, creditCard, dash, dashCircle, @@ -118,6 +119,7 @@ import { infoCircle, journalBookmarkFill, journals, + lightbulb, link, list, listNested, @@ -322,6 +324,7 @@ const icons = { clipboardCheckFill, clipboardFill, clockHistory, + cpu, cash, creditCard, dash, @@ -367,6 +370,7 @@ const icons = { infoCircle, journalBookmarkFill, journals, + lightbulb, link, list, listNested, diff --git a/src-ui/src/styles.scss b/src-ui/src/styles.scss index 8c0fec3e8..c135d343b 100644 --- a/src-ui/src/styles.scss +++ b/src-ui/src/styles.scss @@ -292,6 +292,7 @@ a.btn-link:active, a.btn-link:focus-visible, .btn-link:hover, .btn-link:active, +.btn-link.show, .btn-link:focus-visible { color: var(--pngx-primary-lighten-10) !important; .primary-light & {