]> git.ipfire.org Git - thirdparty/paperless-ngx.git/commitdiff
Suggestions dropdown
authorshamoon <4887959+shamoon@users.noreply.github.com>
Mon, 21 Apr 2025 08:04:34 +0000 (01:04 -0700)
committershamoon <4887959+shamoon@users.noreply.github.com>
Wed, 2 Jul 2025 18:01:46 +0000 (11:01 -0700)
src-ui/src/app/components/common/custom-fields-dropdown/custom-fields-dropdown.component.html
src-ui/src/app/components/common/suggestions-dropdown/suggestions-dropdown.component.html [new file with mode: 0644]
src-ui/src/app/components/common/suggestions-dropdown/suggestions-dropdown.component.scss [new file with mode: 0644]
src-ui/src/app/components/common/suggestions-dropdown/suggestions-dropdown.component.spec.ts [new file with mode: 0644]
src-ui/src/app/components/common/suggestions-dropdown/suggestions-dropdown.component.ts [new file with mode: 0644]
src-ui/src/app/components/document-detail/document-detail.component.html
src-ui/src/app/components/document-detail/document-detail.component.spec.ts
src-ui/src/app/components/document-detail/document-detail.component.ts
src-ui/src/app/data/document-suggestions.ts
src/documents/ai/matching.py
src/documents/views.py

index f3023860b4ed55c82a0b9dd88ae2bcc10ae2b99c..f06f37dd04fdc664019d0764c1148d1638da314e 100644 (file)
@@ -1,4 +1,4 @@
-<div ngbDropdown #fieldDropdown="ngbDropdown" (openChange)="onOpenClose($event)" [popperOptions]="popperOptions" placement="bottom-end">
+<div ngbDropdown #fieldDropdown="ngbDropdown" (openChange)="onOpenClose($event)" [popperOptions]="popperOptions">
     <button type="button" class="btn btn-sm btn-outline-primary" id="customFieldsDropdown" [disabled]="disabled" ngbDropdownToggle>
       <i-bs name="ui-radios"></i-bs>
       <div class="d-none d-lg-inline">&nbsp;<ng-container i18n>Custom Fields</ng-container></div>
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
new file mode 100644 (file)
index 0000000..377a23f
--- /dev/null
@@ -0,0 +1,37 @@
+<div ngbDropdown [popperOptions]="popperOptions">
+
+  <button type="button" class="btn btn-sm btn-outline-primary" (click)="getSuggestions.emit(this)" [disabled]="loading" ngbDropdownToggle>
+    @if (loading) {
+      <div class="spinner-border spinner-border-sm" role="status"></div>
+    } @else {
+      <i-bs width="1.2em" height="1.2em" name="stars"></i-bs>
+    }
+    <span class="d-none d-lg-inline ps-1" i18n>Suggest</span>
+    @if (totalSuggestions > 0) {
+      <span class="badge bg-primary ms-2">{{ totalSuggestions }}</span>
+    }
+  </button>
+
+  <div ngbDropdownMenu aria-labelledby="suggestionsDropdown" class="shadow suggestions-dropdown">
+      <div class="list-group list-group-flush">
+        @if (suggestions?.suggested_tags.length > 0) {
+          <div class="list-group-item text-uppercase text-muted small">Tags</div>
+          @for (tag of suggestions.suggested_tags; track tag) {
+            <a class="list-group-item list-group-item-action bg-light small" (click)="addTag.emit(tag)" i18n>{{ tag }}</a>
+          }
+        }
+        @if (suggestions?.suggested_document_types.length > 0) {
+          <div class="list-group-item text-uppercase text-muted small">Document Types</div>
+          @for (type of suggestions.suggested_document_types; track type) {
+            <a class="list-group-item list-group-item-action bg-light small" (click)="addDocumentType.emit(type)" i18n>{{ type }}</a>
+          }
+        }
+        @if (suggestions?.suggested_correspondents.length > 0) {
+          <div class="list-group-item text-uppercase text-muted small">Correspondents</div>
+          @for (correspondent of suggestions.suggested_correspondents; track correspondent) {
+            <a class="list-group-item list-group-item-action bg-light small" (click)="addCorrespondent.emit(correspondent)" i18n>{{ correspondent }}</a>
+          }
+        }
+      </div>
+  </div>
+</div>
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 (file)
index 0000000..19aa1dc
--- /dev/null
@@ -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 (file)
index 0000000..01407df
--- /dev/null
@@ -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<SuggestionsDropdownComponent>
+
+  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 (file)
index 0000000..bbdb12c
--- /dev/null
@@ -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<SuggestionsDropdownComponent> =
+    new EventEmitter()
+
+  @Output()
+  addTag: EventEmitter<string> = new EventEmitter()
+
+  @Output()
+  addDocumentType: EventEmitter<string> = new EventEmitter()
+
+  @Output()
+  addCorrespondent: EventEmitter<string> = new EventEmitter()
+
+  get totalSuggestions(): number {
+    return (
+      this.suggestions?.suggested_correspondents?.length +
+        this.suggestions?.suggested_tags?.length +
+        this.suggestions?.suggested_document_types?.length || 0
+    )
+  }
+}
index 78ad17700c0772d2189375dc9f1ea8eb778276db..21cf39d448424615f6ee9fe118c2532857833ac9 100644 (file)
 
         <ng-container *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Document }">
           <div class="btn-group pb-3 ms-auto">
-            <button type="button" class="btn btn-sm btn-outline-primary" (click)="getSuggestions()" [disabled]="!userCanEdit || suggestions || suggestionsLoading" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Document }">
-              @if (suggestionsLoading) {
-                <div class="spinner-border spinner-border-sm" role="status"></div>
-              } @else {
-                <i-bs width="1.2em" height="1.2em" name="stars"></i-bs>
-              }
-              <span class="d-none d-lg-inline ps-1" i18n>Suggest</span>
-            </button>
+            <pngx-suggestions-dropdown *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Document }"
+              [disabled]="!userCanEdit || suggestionsLoading"
+              [loading]="suggestionsLoading"
+              [suggestions]="suggestions"
+              (getSuggestions)="getSuggestions()">
+            </pngx-suggestions-dropdown>
           </div>
 
           <div class="btn-group pb-3 ms-2">
index 22d4a04c8143f2148b4d75bee0829e90d46241fa..eb4a0bad741846b2e6bde33460c639ab80bfc9f0 100644 (file)
@@ -1068,10 +1068,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', () => {
index 2f4563b2b25c3f880c5fde1d9509e7f93dfd2bb3..1bfe568b3113a6af3e5a55cc3f7a14d9f4195aa4 100644 (file)
@@ -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,
index 8c7aca5157d63bb931cb843543fbe6cf58dc5f3f..447c4402bc8740c79d2e7ad76e3f550c8e5503b6 100644 (file)
@@ -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
 }
index 900fb8ac70ff313b35f5d84bd4bcc8f7d21bff7b..9267850df47cc0c1c10a137bfa91bd3814c9e071 100644 (file)
@@ -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]
index 03e7fd91071166db7a791609bc56455834b913b2..426676d1bba9955b48861af31180fad2d878db6a 100644 (file)
@@ -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
@@ -778,32 +779,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", []),
             }