mirror of
				https://github.com/paperless-ngx/paperless-ngx.git
				synced 2025-10-31 10:37:12 -04:00 
			
		
		
		
	Suggestions dropdown
This commit is contained in:
		
							parent
							
								
									8d1a8c2c42
								
							
						
					
					
						commit
						faa5d3e5b9
					
				| @ -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> |     <button type="button" class="btn btn-sm btn-outline-primary" id="customFieldsDropdown" [disabled]="disabled" ngbDropdownToggle> | ||||||
|       <i-bs name="ui-radios"></i-bs> |       <i-bs name="ui-radios"></i-bs> | ||||||
|       <div class="d-none d-lg-inline"> <ng-container i18n>Custom Fields</ng-container></div> |       <div class="d-none d-lg-inline"> <ng-container i18n>Custom Fields</ng-container></div> | ||||||
|  | |||||||
| @ -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> | ||||||
| @ -0,0 +1,3 @@ | |||||||
|  | .suggestions-dropdown { | ||||||
|  |   min-width: 250px; | ||||||
|  | } | ||||||
| @ -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) | ||||||
|  |   }) | ||||||
|  | }) | ||||||
| @ -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 | ||||||
|  |     ) | ||||||
|  |   } | ||||||
|  | } | ||||||
| @ -111,14 +111,12 @@ | |||||||
| 
 | 
 | ||||||
|         <ng-container *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Document }"> |         <ng-container *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Document }"> | ||||||
|           <div class="btn-group pb-3 ms-auto"> |           <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 }"> |             <pngx-suggestions-dropdown *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Document }" | ||||||
|               @if (suggestionsLoading) { |               [disabled]="!userCanEdit || suggestionsLoading" | ||||||
|                 <div class="spinner-border spinner-border-sm" role="status"></div> |               [loading]="suggestionsLoading" | ||||||
|               } @else { |               [suggestions]="suggestions" | ||||||
|                 <i-bs width="1.2em" height="1.2em" name="stars"></i-bs> |               (getSuggestions)="getSuggestions()"> | ||||||
|               } |             </pngx-suggestions-dropdown> | ||||||
|               <span class="d-none d-lg-inline ps-1" i18n>Suggest</span> |  | ||||||
|             </button> |  | ||||||
|           </div> |           </div> | ||||||
| 
 | 
 | ||||||
|           <div class="btn-group pb-3 ms-2"> |           <div class="btn-group pb-3 ms-2"> | ||||||
|  | |||||||
| @ -1068,10 +1068,22 @@ describe('DocumentDetailComponent', () => { | |||||||
| 
 | 
 | ||||||
|   it('should get suggestions', () => { |   it('should get suggestions', () => { | ||||||
|     const suggestionsSpy = jest.spyOn(documentService, 'getSuggestions') |     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() |     initNormally() | ||||||
|     expect(suggestionsSpy).toHaveBeenCalled() |     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', () => { |   it('should show error if needed for get suggestions', () => { | ||||||
|  | |||||||
| @ -103,6 +103,7 @@ import { TextComponent } from '../common/input/text/text.component' | |||||||
| import { UrlComponent } from '../common/input/url/url.component' | import { UrlComponent } from '../common/input/url/url.component' | ||||||
| import { PageHeaderComponent } from '../common/page-header/page-header.component' | import { PageHeaderComponent } from '../common/page-header/page-header.component' | ||||||
| import { ShareLinksDialogComponent } from '../common/share-links-dialog/share-links-dialog.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 { DocumentHistoryComponent } from '../document-history/document-history.component' | ||||||
| import { DocumentNotesComponent } from '../document-notes/document-notes.component' | import { DocumentNotesComponent } from '../document-notes/document-notes.component' | ||||||
| import { ComponentWithPermissions } from '../with-permissions/with-permissions.component' | import { ComponentWithPermissions } from '../with-permissions/with-permissions.component' | ||||||
| @ -159,6 +160,7 @@ export enum ZoomSetting { | |||||||
|     NumberComponent, |     NumberComponent, | ||||||
|     MonetaryComponent, |     MonetaryComponent, | ||||||
|     UrlComponent, |     UrlComponent, | ||||||
|  |     SuggestionsDropdownComponent, | ||||||
|     CustomDatePipe, |     CustomDatePipe, | ||||||
|     FileSizePipe, |     FileSizePipe, | ||||||
|     IfPermissionsDirective, |     IfPermissionsDirective, | ||||||
|  | |||||||
| @ -2,12 +2,16 @@ export interface DocumentSuggestions { | |||||||
|   title?: string |   title?: string | ||||||
| 
 | 
 | ||||||
|   tags?: number[] |   tags?: number[] | ||||||
|  |   suggested_tags?: string[] | ||||||
| 
 | 
 | ||||||
|   correspondents?: number[] |   correspondents?: number[] | ||||||
|  |   suggested_correspondents?: string[] | ||||||
| 
 | 
 | ||||||
|   document_types?: number[] |   document_types?: number[] | ||||||
|  |   suggested_document_types?: string[] | ||||||
| 
 | 
 | ||||||
|   storage_paths?: number[] |   storage_paths?: number[] | ||||||
|  |   suggested_storage_paths?: string[] | ||||||
| 
 | 
 | ||||||
|   dates?: string[] // ISO-formatted date string e.g. 2022-11-03
 |   dates?: string[] // ISO-formatted date string e.g. 2022-11-03
 | ||||||
| } | } | ||||||
|  | |||||||
| @ -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") |             logging.debug(f"No match for: '{name}' in {attr} list") | ||||||
| 
 | 
 | ||||||
|     return results |     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] | ||||||
|  | |||||||
| @ -78,6 +78,7 @@ from rest_framework.viewsets import ViewSet | |||||||
| from documents import bulk_edit | from documents import bulk_edit | ||||||
| from documents import index | from documents import index | ||||||
| from documents.ai.llm_classifier import get_ai_document_classification | 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_correspondents_by_name | ||||||
| from documents.ai.matching import match_document_types_by_name | from documents.ai.matching import match_document_types_by_name | ||||||
| from documents.ai.matching import match_storage_paths_by_name | from documents.ai.matching import match_storage_paths_by_name | ||||||
| @ -778,32 +779,42 @@ class DocumentViewSet( | |||||||
|                 return Response(cached.suggestions) |                 return Response(cached.suggestions) | ||||||
| 
 | 
 | ||||||
|             llm_resp = get_ai_document_classification(doc) |             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 = { |             resp_data = { | ||||||
|                 "title": llm_resp.get("title"), |                 "title": llm_resp.get("title"), | ||||||
|                 "tags": [ |                 "tags": [t.id for t in matched_tags], | ||||||
|                     t.id |                 "suggested_tags": extract_unmatched_names( | ||||||
|                     for t in match_tags_by_name(llm_resp.get("tags", []), request.user) |                     llm_resp.get("tags", []), | ||||||
|                 ], |                     matched_tags, | ||||||
|                 "correspondents": [ |                 ), | ||||||
|                     c.id |                 "correspondents": [c.id for c in matched_correspondents], | ||||||
|                     for c in match_correspondents_by_name( |                 "suggested_correspondents": extract_unmatched_names( | ||||||
|                         llm_resp.get("correspondents", []), |                     llm_resp.get("correspondents", []), | ||||||
|                         request.user, |                     matched_correspondents, | ||||||
|                     ) |                 ), | ||||||
|                 ], |                 "document_types": [d.id for d in matched_types], | ||||||
|                 "document_types": [ |                 "suggested_document_types": extract_unmatched_names( | ||||||
|                     d.id |                     llm_resp.get("document_types", []), | ||||||
|                     for d in match_document_types_by_name( |                     matched_types, | ||||||
|                         llm_resp.get("document_types", []), |                 ), | ||||||
|                     ) |                 "storage_paths": [s.id for s in matched_paths], | ||||||
|                 ], |                 "suggested_storage_paths": extract_unmatched_names( | ||||||
|                 "storage_paths": [ |                     llm_resp.get("storage_paths", []), | ||||||
|                     s.id |                     matched_paths, | ||||||
|                     for s in match_storage_paths_by_name( |                 ), | ||||||
|                         llm_resp.get("storage_paths", []), |  | ||||||
|                         request.user, |  | ||||||
|                     ) |  | ||||||
|                 ], |  | ||||||
|                 "dates": llm_resp.get("dates", []), |                 "dates": llm_resp.get("dates", []), | ||||||
|             } |             } | ||||||
| 
 | 
 | ||||||
|  | |||||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user