mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2025-05-13 12:19:28 -05:00
Suggestions dropdown
This commit is contained in:
parent
89671eb303
commit
f9ffc97970
@ -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">
|
||||||
|
@ -1030,10 +1030,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
|
||||||
@ -745,32 +746,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