-<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>
--- /dev/null
+<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>
--- /dev/null
+.suggestions-dropdown {
+ min-width: 250px;
+}
--- /dev/null
+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)
+ })
+})
--- /dev/null
+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
+ )
+ }
+}
<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">
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', () => {
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'
NumberComponent,
MonetaryComponent,
UrlComponent,
+ SuggestionsDropdownComponent,
CustomDatePipe,
FileSizePipe,
IfPermissionsDirective,
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
}
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]
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
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", []),
}