mirror of
				https://github.com/paperless-ngx/paperless-ngx.git
				synced 2025-10-31 02:27:10 -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> | ||||
|       <i-bs name="ui-radios"></i-bs> | ||||
|       <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 }"> | ||||
|           <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"> | ||||
|  | ||||
| @ -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', () => { | ||||
|  | ||||
| @ -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, | ||||
|  | ||||
| @ -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
 | ||||
| } | ||||
|  | ||||
| @ -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] | ||||
|  | ||||
| @ -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", []), | ||||
|             } | ||||
| 
 | ||||
|  | ||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user