From f9ffc979705453df11f64e4b22c0ce19b72c3bad Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Mon, 21 Apr 2025 01:04:34 -0700 Subject: [PATCH] Suggestions dropdown --- .../custom-fields-dropdown.component.html | 2 +- .../suggestions-dropdown.component.html | 37 ++++++++++++ .../suggestions-dropdown.component.scss | 3 + .../suggestions-dropdown.component.spec.ts | 32 ++++++++++ .../suggestions-dropdown.component.ts | 45 ++++++++++++++ .../document-detail.component.html | 14 ++--- .../document-detail.component.spec.ts | 16 ++++- .../document-detail.component.ts | 2 + src-ui/src/app/data/document-suggestions.ts | 4 ++ src/documents/ai/matching.py | 9 +++ src/documents/views.py | 59 +++++++++++-------- 11 files changed, 188 insertions(+), 35 deletions(-) create mode 100644 src-ui/src/app/components/common/suggestions-dropdown/suggestions-dropdown.component.html create mode 100644 src-ui/src/app/components/common/suggestions-dropdown/suggestions-dropdown.component.scss create mode 100644 src-ui/src/app/components/common/suggestions-dropdown/suggestions-dropdown.component.spec.ts create mode 100644 src-ui/src/app/components/common/suggestions-dropdown/suggestions-dropdown.component.ts diff --git a/src-ui/src/app/components/common/custom-fields-dropdown/custom-fields-dropdown.component.html b/src-ui/src/app/components/common/custom-fields-dropdown/custom-fields-dropdown.component.html index f3023860b..f06f37dd0 100644 --- a/src-ui/src/app/components/common/custom-fields-dropdown/custom-fields-dropdown.component.html +++ b/src-ui/src/app/components/common/custom-fields-dropdown/custom-fields-dropdown.component.html @@ -1,4 +1,4 @@ -
+
+ +
+
+ @if (suggestions?.suggested_tags.length > 0) { +
Tags
+ @for (tag of suggestions.suggested_tags; track tag) { + {{ tag }} + } + } + @if (suggestions?.suggested_document_types.length > 0) { +
Document Types
+ @for (type of suggestions.suggested_document_types; track type) { + {{ type }} + } + } + @if (suggestions?.suggested_correspondents.length > 0) { +
Correspondents
+ @for (correspondent of suggestions.suggested_correspondents; track correspondent) { + {{ correspondent }} + } + } +
+
+
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 new file mode 100644 index 000000000..19aa1dc7d --- /dev/null +++ b/src-ui/src/app/components/common/suggestions-dropdown/suggestions-dropdown.component.scss @@ -0,0 +1,3 @@ +.suggestions-dropdown { + min-width: 250px; +} 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 new file mode 100644 index 000000000..01407dfc6 --- /dev/null +++ b/src-ui/src/app/components/common/suggestions-dropdown/suggestions-dropdown.component.spec.ts @@ -0,0 +1,32 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing' +import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap' +import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons' +import { SuggestionsDropdownComponent } from './suggestions-dropdown.component' + +describe('SuggestionsDropdownComponent', () => { + let component: SuggestionsDropdownComponent + let fixture: ComponentFixture + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ + NgbDropdownModule, + NgxBootstrapIconsModule.pick(allIcons), + SuggestionsDropdownComponent, + ], + providers: [], + }) + fixture = TestBed.createComponent(SuggestionsDropdownComponent) + component = fixture.componentInstance + fixture.detectChanges() + }) + + it('should calculate totalSuggestions', () => { + component.suggestions = { + suggested_correspondents: ['John Doe'], + suggested_tags: ['Tag1', 'Tag2'], + suggested_document_types: ['Type1'], + } + expect(component.totalSuggestions).toBe(4) + }) +}) 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 new file mode 100644 index 000000000..bbdb12c60 --- /dev/null +++ b/src-ui/src/app/components/common/suggestions-dropdown/suggestions-dropdown.component.ts @@ -0,0 +1,45 @@ +import { Component, EventEmitter, Input, Output } from '@angular/core' +import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap' +import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' +import { DocumentSuggestions } from 'src/app/data/document-suggestions' +import { pngxPopperOptions } from 'src/app/utils/popper-options' + +@Component({ + selector: 'pngx-suggestions-dropdown', + imports: [NgbDropdownModule, NgxBootstrapIconsModule], + templateUrl: './suggestions-dropdown.component.html', + styleUrl: './suggestions-dropdown.component.scss', +}) +export class SuggestionsDropdownComponent { + public popperOptions = pngxPopperOptions + + @Input() + suggestions: DocumentSuggestions = null + + @Input() + loading: boolean = false + + @Input() + disabled: boolean = false + + @Output() + getSuggestions: EventEmitter = + new EventEmitter() + + @Output() + addTag: EventEmitter = new EventEmitter() + + @Output() + addDocumentType: EventEmitter = new EventEmitter() + + @Output() + addCorrespondent: EventEmitter = new EventEmitter() + + get totalSuggestions(): number { + return ( + this.suggestions?.suggested_correspondents?.length + + this.suggestions?.suggested_tags?.length + + this.suggestions?.suggested_document_types?.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 a95341b9d..f875ceeac 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 @@ -111,14 +111,12 @@
- + +
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 6f386ad73..1dbafd27d 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 @@ -1030,10 +1030,22 @@ describe('DocumentDetailComponent', () => { it('should get suggestions', () => { const suggestionsSpy = jest.spyOn(documentService, 'getSuggestions') - suggestionsSpy.mockReturnValue(of({ tags: [42, 43] })) + suggestionsSpy.mockReturnValue( + of({ + tags: [42, 43], + suggested_tags: [], + suggested_document_types: [], + suggested_correspondents: [], + }) + ) initNormally() expect(suggestionsSpy).toHaveBeenCalled() - expect(component.suggestions).toEqual({ tags: [42, 43] }) + expect(component.suggestions).toEqual({ + tags: [42, 43], + suggested_tags: [], + suggested_document_types: [], + suggested_correspondents: [], + }) }) it('should show error if needed for get suggestions', () => { 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 4f0893474..323f0f612 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 @@ -103,6 +103,7 @@ import { TextComponent } from '../common/input/text/text.component' import { UrlComponent } from '../common/input/url/url.component' import { PageHeaderComponent } from '../common/page-header/page-header.component' import { ShareLinksDialogComponent } from '../common/share-links-dialog/share-links-dialog.component' +import { SuggestionsDropdownComponent } from '../common/suggestions-dropdown/suggestions-dropdown.component' import { DocumentHistoryComponent } from '../document-history/document-history.component' import { DocumentNotesComponent } from '../document-notes/document-notes.component' import { ComponentWithPermissions } from '../with-permissions/with-permissions.component' @@ -159,6 +160,7 @@ export enum ZoomSetting { NumberComponent, MonetaryComponent, UrlComponent, + SuggestionsDropdownComponent, CustomDatePipe, FileSizePipe, IfPermissionsDirective, diff --git a/src-ui/src/app/data/document-suggestions.ts b/src-ui/src/app/data/document-suggestions.ts index 8c7aca515..447c4402b 100644 --- a/src-ui/src/app/data/document-suggestions.ts +++ b/src-ui/src/app/data/document-suggestions.ts @@ -2,12 +2,16 @@ export interface DocumentSuggestions { title?: string tags?: number[] + suggested_tags?: string[] correspondents?: number[] + suggested_correspondents?: string[] document_types?: number[] + suggested_document_types?: string[] storage_paths?: number[] + suggested_storage_paths?: string[] dates?: string[] // ISO-formatted date string e.g. 2022-11-03 } diff --git a/src/documents/ai/matching.py b/src/documents/ai/matching.py index 900fb8ac7..9267850df 100644 --- a/src/documents/ai/matching.py +++ b/src/documents/ai/matching.py @@ -80,3 +80,12 @@ def _match_names_to_queryset(names: list[str], queryset, attr: str): logging.debug(f"No match for: '{name}' in {attr} list") return results + + +def extract_unmatched_names( + llm_names: list[str], + matched_objects: list, + attr="name", +) -> list[str]: + matched_names = {getattr(obj, attr).lower() for obj in matched_objects} + return [name for name in llm_names if name.lower() not in matched_names] diff --git a/src/documents/views.py b/src/documents/views.py index ed13bf245..b475cbff1 100644 --- a/src/documents/views.py +++ b/src/documents/views.py @@ -78,6 +78,7 @@ from rest_framework.viewsets import ViewSet from documents import bulk_edit from documents import index from documents.ai.llm_classifier import get_ai_document_classification +from documents.ai.matching import extract_unmatched_names from documents.ai.matching import match_correspondents_by_name from documents.ai.matching import match_document_types_by_name from documents.ai.matching import match_storage_paths_by_name @@ -745,32 +746,42 @@ class DocumentViewSet( return Response(cached.suggestions) llm_resp = get_ai_document_classification(doc) + + matched_tags = match_tags_by_name(llm_resp.get("tags", []), request.user) + matched_correspondents = match_correspondents_by_name( + llm_resp.get("correspondents", []), + request.user, + ) + matched_types = match_document_types_by_name( + llm_resp.get("document_types", []), + ) + matched_paths = match_storage_paths_by_name( + llm_resp.get("storage_paths", []), + request.user, + ) + resp_data = { "title": llm_resp.get("title"), - "tags": [ - t.id - for t in match_tags_by_name(llm_resp.get("tags", []), request.user) - ], - "correspondents": [ - c.id - for c in match_correspondents_by_name( - llm_resp.get("correspondents", []), - request.user, - ) - ], - "document_types": [ - d.id - for d in match_document_types_by_name( - llm_resp.get("document_types", []), - ) - ], - "storage_paths": [ - s.id - for s in match_storage_paths_by_name( - llm_resp.get("storage_paths", []), - request.user, - ) - ], + "tags": [t.id for t in matched_tags], + "suggested_tags": extract_unmatched_names( + llm_resp.get("tags", []), + matched_tags, + ), + "correspondents": [c.id for c in matched_correspondents], + "suggested_correspondents": extract_unmatched_names( + llm_resp.get("correspondents", []), + matched_correspondents, + ), + "document_types": [d.id for d in matched_types], + "suggested_document_types": extract_unmatched_names( + llm_resp.get("document_types", []), + matched_types, + ), + "storage_paths": [s.id for s in matched_paths], + "suggested_storage_paths": extract_unmatched_names( + llm_resp.get("storage_paths", []), + matched_paths, + ), "dates": llm_resp.get("dates", []), }