mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-01-03 12:09:46 +01:00
Changes before error encountered
Co-authored-by: dawnsystem <42047891+dawnsystem@users.noreply.github.com>
This commit is contained in:
parent
780decf543
commit
5695d41903
5 changed files with 1111 additions and 0 deletions
|
|
@ -0,0 +1,126 @@
|
|||
@if (hasSuggestions) {
|
||||
<div class="ai-suggestions-panel card shadow-sm mb-3" [@slideIn]>
|
||||
<div class="card-header bg-primary text-white d-flex align-items-center justify-content-between" role="button" (click)="toggleCollapse()">
|
||||
<div class="d-flex align-items-center">
|
||||
<i-bs name="magic" width="1.2em" height="1.2em" class="me-2"></i-bs>
|
||||
<strong i18n>AI Suggestions</strong>
|
||||
<span class="badge bg-light text-primary ms-2">{{ pendingSuggestions.length }}</span>
|
||||
</div>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
@if (appliedCount > 0) {
|
||||
<span class="badge bg-success" i18n>{{ appliedCount }} applied</span>
|
||||
}
|
||||
@if (rejectedCount > 0) {
|
||||
<span class="badge bg-danger" i18n>{{ rejectedCount }} rejected</span>
|
||||
}
|
||||
<i-bs [name]="isCollapsed ? 'chevron-down' : 'chevron-up'" width="1em" height="1em"></i-bs>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div [ngbCollapse]="isCollapsed" class="card-body">
|
||||
<div class="mb-3 pb-2 border-bottom">
|
||||
<p class="text-muted small mb-2" i18n>
|
||||
<i-bs name="info-circle" width="0.9em" height="0.9em" class="me-1"></i-bs>
|
||||
AI has analyzed this document and suggests the following metadata. Review and apply or reject each suggestion.
|
||||
</p>
|
||||
<div class="d-flex gap-2 flex-wrap">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-success"
|
||||
[disabled]="disabled || pendingSuggestions.length === 0"
|
||||
(click)="applyAll()"
|
||||
i18n>
|
||||
<i-bs name="check-all" width="1em" height="1em" class="me-1"></i-bs>
|
||||
Apply All
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-outline-danger"
|
||||
[disabled]="disabled || pendingSuggestions.length === 0"
|
||||
(click)="rejectAll()"
|
||||
i18n>
|
||||
<i-bs name="x-circle" width="1em" height="1em" class="me-1"></i-bs>
|
||||
Reject All
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="suggestions-container">
|
||||
@for (type of suggestionTypes; track type) {
|
||||
<div class="suggestion-group mb-3">
|
||||
<div class="suggestion-group-header d-flex align-items-center mb-2">
|
||||
<i-bs [name]="getTypeIcon(type)" width="1.1em" height="1.1em" class="me-2 text-primary"></i-bs>
|
||||
<strong class="text-secondary">{{ getTypeLabel(type) }}</strong>
|
||||
<span class="badge bg-secondary ms-2">{{ groupedSuggestions.get(type)?.length }}</span>
|
||||
</div>
|
||||
|
||||
<div class="suggestion-items">
|
||||
@for (suggestion of groupedSuggestions.get(type); track suggestion.id) {
|
||||
<div
|
||||
class="suggestion-item card mb-2"
|
||||
[@fadeInOut]
|
||||
[class.suggestion-applying]="suggestion.status === AISuggestionStatus.Applied"
|
||||
[class.suggestion-rejecting]="suggestion.status === AISuggestionStatus.Rejected">
|
||||
<div class="card-body p-2">
|
||||
<div class="d-flex align-items-start justify-content-between gap-2">
|
||||
<div class="flex-grow-1">
|
||||
<div class="suggestion-value fw-medium mb-1">
|
||||
@if (suggestion.type === AISuggestionType.CustomField && suggestion.field_name) {
|
||||
<span class="text-muted small me-1">{{ suggestion.field_name }}:</span>
|
||||
}
|
||||
{{ getLabel(suggestion) }}
|
||||
</div>
|
||||
<div class="d-flex align-items-center gap-2 flex-wrap">
|
||||
<span
|
||||
class="confidence-badge badge"
|
||||
[ngClass]="getConfidenceClass(suggestion.confidence)">
|
||||
<i-bs [name]="getConfidenceIcon(suggestion.confidence)" width="0.8em" height="0.8em" class="me-1"></i-bs>
|
||||
{{ getConfidenceLabel(suggestion.confidence) }}
|
||||
</span>
|
||||
@if (suggestion.created_at) {
|
||||
<span class="text-muted small">
|
||||
<i-bs name="clock" width="0.8em" height="0.8em" class="me-1"></i-bs>
|
||||
{{ suggestion.created_at | date:'short' }}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="suggestion-actions d-flex gap-1 flex-shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-success"
|
||||
[disabled]="disabled"
|
||||
(click)="applySuggestion(suggestion)"
|
||||
i18n-title
|
||||
title="Apply this suggestion">
|
||||
<i-bs name="check-lg" width="1em" height="1em"></i-bs>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-outline-danger"
|
||||
[disabled]="disabled"
|
||||
(click)="rejectSuggestion(suggestion)"
|
||||
i18n-title
|
||||
title="Reject this suggestion">
|
||||
<i-bs name="x-lg" width="1em" height="1em"></i-bs>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (pendingSuggestions.length === 0) {
|
||||
<div class="text-center text-muted py-3">
|
||||
<i-bs name="check-circle" width="2em" height="2em" class="mb-2"></i-bs>
|
||||
<p i18n>All suggestions have been processed</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
|
@ -0,0 +1,241 @@
|
|||
.ai-suggestions-panel {
|
||||
border: 2px solid var(--bs-primary);
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15) !important;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: background-color 0.2s ease;
|
||||
padding: 0.75rem 1rem;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--bs-primary) !important;
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
.badge {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.suggestions-container {
|
||||
max-height: 600px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
|
||||
// Custom scrollbar styles
|
||||
&::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: #888;
|
||||
border-radius: 4px;
|
||||
|
||||
&:hover {
|
||||
background: #555;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.suggestion-group {
|
||||
.suggestion-group-header {
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 1px solid #dee2e6;
|
||||
|
||||
strong {
|
||||
font-size: 0.95rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.badge {
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
}
|
||||
|
||||
.suggestion-items {
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.suggestion-item {
|
||||
border-left: 3px solid var(--bs-primary);
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
|
||||
&:hover {
|
||||
border-left-color: var(--bs-success);
|
||||
box-shadow: 0 0.25rem 0.5rem rgba(0, 0, 0, 0.1);
|
||||
transform: translateX(2px);
|
||||
}
|
||||
|
||||
&.suggestion-applying {
|
||||
animation: applyAnimation 0.5s ease;
|
||||
border-left-color: var(--bs-success);
|
||||
background-color: rgba(25, 135, 84, 0.1);
|
||||
}
|
||||
|
||||
&.suggestion-rejecting {
|
||||
animation: rejectAnimation 0.5s ease;
|
||||
border-left-color: var(--bs-danger);
|
||||
background-color: rgba(220, 53, 69, 0.1);
|
||||
}
|
||||
|
||||
.suggestion-value {
|
||||
color: #333;
|
||||
font-size: 0.95rem;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.confidence-badge {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
&.confidence-high {
|
||||
background-color: #28a745;
|
||||
color: white;
|
||||
}
|
||||
|
||||
&.confidence-medium {
|
||||
background-color: #ffc107;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
&.confidence-low {
|
||||
background-color: #dc3545;
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
|
||||
.suggestion-actions {
|
||||
.btn {
|
||||
min-width: 36px;
|
||||
padding: 0.25rem 0.5rem;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Animations
|
||||
@keyframes applyAnimation {
|
||||
0% {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
transform: translateX(20px);
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translateX(40px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes rejectAnimation {
|
||||
0% {
|
||||
opacity: 1;
|
||||
transform: translateX(0) rotate(0deg);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
transform: translateX(-20px) rotate(-5deg);
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translateX(-40px) rotate(-10deg);
|
||||
}
|
||||
}
|
||||
|
||||
// Responsive design
|
||||
@media (max-width: 768px) {
|
||||
.ai-suggestions-panel {
|
||||
.card-header {
|
||||
padding: 0.5rem 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.badge {
|
||||
font-size: 0.65rem;
|
||||
padding: 0.2rem 0.4rem;
|
||||
}
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
.suggestions-container {
|
||||
max-height: 400px;
|
||||
}
|
||||
|
||||
.suggestion-group {
|
||||
.suggestion-items {
|
||||
padding-left: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.suggestion-item {
|
||||
.d-flex {
|
||||
flex-direction: column;
|
||||
gap: 0.5rem !important;
|
||||
}
|
||||
|
||||
.suggestion-actions {
|
||||
width: 100%;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 576px) {
|
||||
.ai-suggestions-panel {
|
||||
.card-header {
|
||||
.d-flex {
|
||||
flex-direction: column;
|
||||
align-items: flex-start !important;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.suggestion-item {
|
||||
.suggestion-value {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.confidence-badge {
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,331 @@
|
|||
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
|
||||
import { provideHttpClientTesting } from '@angular/common/http/testing'
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||
import { provideAnimations } from '@angular/platform-browser/animations'
|
||||
import { NgbCollapseModule } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||
import { of } from 'rxjs'
|
||||
import {
|
||||
AISuggestion,
|
||||
AISuggestionStatus,
|
||||
AISuggestionType,
|
||||
} from 'src/app/data/ai-suggestion'
|
||||
import { Correspondent } from 'src/app/data/correspondent'
|
||||
import { DocumentType } from 'src/app/data/document-type'
|
||||
import { StoragePath } from 'src/app/data/storage-path'
|
||||
import { Tag } from 'src/app/data/tag'
|
||||
import { CorrespondentService } from 'src/app/services/rest/correspondent.service'
|
||||
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
|
||||
import { DocumentTypeService } from 'src/app/services/rest/document-type.service'
|
||||
import { StoragePathService } from 'src/app/services/rest/storage-path.service'
|
||||
import { TagService } from 'src/app/services/rest/tag.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import { AiSuggestionsPanelComponent } from './ai-suggestions-panel.component'
|
||||
|
||||
const mockTags: Tag[] = [
|
||||
{ id: 1, name: 'Invoice', colour: '#ff0000', text_colour: '#ffffff' },
|
||||
{ id: 2, name: 'Receipt', colour: '#00ff00', text_colour: '#000000' },
|
||||
]
|
||||
|
||||
const mockCorrespondents: Correspondent[] = [
|
||||
{ id: 1, name: 'Acme Corp' },
|
||||
{ id: 2, name: 'TechStart LLC' },
|
||||
]
|
||||
|
||||
const mockDocumentTypes: DocumentType[] = [
|
||||
{ id: 1, name: 'Invoice' },
|
||||
{ id: 2, name: 'Contract' },
|
||||
]
|
||||
|
||||
const mockStoragePaths: StoragePath[] = [
|
||||
{ id: 1, name: '/invoices', path: '/invoices' },
|
||||
{ id: 2, name: '/contracts', path: '/contracts' },
|
||||
]
|
||||
|
||||
const mockSuggestions: AISuggestion[] = [
|
||||
{
|
||||
id: '1',
|
||||
type: AISuggestionType.Tag,
|
||||
value: 1,
|
||||
confidence: 0.85,
|
||||
status: AISuggestionStatus.Pending,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
type: AISuggestionType.Correspondent,
|
||||
value: 1,
|
||||
confidence: 0.75,
|
||||
status: AISuggestionStatus.Pending,
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
type: AISuggestionType.DocumentType,
|
||||
value: 1,
|
||||
confidence: 0.90,
|
||||
status: AISuggestionStatus.Pending,
|
||||
},
|
||||
]
|
||||
|
||||
describe('AiSuggestionsPanelComponent', () => {
|
||||
let component: AiSuggestionsPanelComponent
|
||||
let fixture: ComponentFixture<AiSuggestionsPanelComponent>
|
||||
let tagService: TagService
|
||||
let correspondentService: CorrespondentService
|
||||
let documentTypeService: DocumentTypeService
|
||||
let storagePathService: StoragePathService
|
||||
let customFieldsService: CustomFieldsService
|
||||
let toastService: ToastService
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [
|
||||
AiSuggestionsPanelComponent,
|
||||
NgbCollapseModule,
|
||||
NgxBootstrapIconsModule.pick(allIcons),
|
||||
],
|
||||
providers: [
|
||||
provideHttpClient(withInterceptorsFromDi()),
|
||||
provideHttpClientTesting(),
|
||||
provideAnimations(),
|
||||
],
|
||||
}).compileComponents()
|
||||
|
||||
tagService = TestBed.inject(TagService)
|
||||
correspondentService = TestBed.inject(CorrespondentService)
|
||||
documentTypeService = TestBed.inject(DocumentTypeService)
|
||||
storagePathService = TestBed.inject(StoragePathService)
|
||||
customFieldsService = TestBed.inject(CustomFieldsService)
|
||||
toastService = TestBed.inject(ToastService)
|
||||
|
||||
jest.spyOn(tagService, 'listAll').mockReturnValue(
|
||||
of({
|
||||
all: mockTags.map((t) => t.id),
|
||||
count: mockTags.length,
|
||||
results: mockTags,
|
||||
})
|
||||
)
|
||||
|
||||
jest.spyOn(correspondentService, 'listAll').mockReturnValue(
|
||||
of({
|
||||
all: mockCorrespondents.map((c) => c.id),
|
||||
count: mockCorrespondents.length,
|
||||
results: mockCorrespondents,
|
||||
})
|
||||
)
|
||||
|
||||
jest.spyOn(documentTypeService, 'listAll').mockReturnValue(
|
||||
of({
|
||||
all: mockDocumentTypes.map((dt) => dt.id),
|
||||
count: mockDocumentTypes.length,
|
||||
results: mockDocumentTypes,
|
||||
})
|
||||
)
|
||||
|
||||
jest.spyOn(storagePathService, 'listAll').mockReturnValue(
|
||||
of({
|
||||
all: mockStoragePaths.map((sp) => sp.id),
|
||||
count: mockStoragePaths.length,
|
||||
results: mockStoragePaths,
|
||||
})
|
||||
)
|
||||
|
||||
jest.spyOn(customFieldsService, 'listAll').mockReturnValue(
|
||||
of({
|
||||
all: [],
|
||||
count: 0,
|
||||
results: [],
|
||||
})
|
||||
)
|
||||
|
||||
fixture = TestBed.createComponent(AiSuggestionsPanelComponent)
|
||||
component = fixture.componentInstance
|
||||
})
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should process suggestions on input change', () => {
|
||||
component.suggestions = mockSuggestions
|
||||
component.ngOnChanges({
|
||||
suggestions: {
|
||||
currentValue: mockSuggestions,
|
||||
previousValue: [],
|
||||
firstChange: true,
|
||||
isFirstChange: () => true,
|
||||
},
|
||||
})
|
||||
|
||||
expect(component.pendingSuggestions.length).toBe(3)
|
||||
expect(component.appliedCount).toBe(0)
|
||||
expect(component.rejectedCount).toBe(0)
|
||||
})
|
||||
|
||||
it('should group suggestions by type', () => {
|
||||
component.suggestions = mockSuggestions
|
||||
component.ngOnChanges({
|
||||
suggestions: {
|
||||
currentValue: mockSuggestions,
|
||||
previousValue: [],
|
||||
firstChange: true,
|
||||
isFirstChange: () => true,
|
||||
},
|
||||
})
|
||||
|
||||
expect(component.groupedSuggestions.size).toBe(3)
|
||||
expect(component.groupedSuggestions.get(AISuggestionType.Tag)?.length).toBe(
|
||||
1
|
||||
)
|
||||
expect(
|
||||
component.groupedSuggestions.get(AISuggestionType.Correspondent)?.length
|
||||
).toBe(1)
|
||||
expect(
|
||||
component.groupedSuggestions.get(AISuggestionType.DocumentType)?.length
|
||||
).toBe(1)
|
||||
})
|
||||
|
||||
it('should apply a suggestion', () => {
|
||||
component.suggestions = mockSuggestions
|
||||
component.ngOnChanges({
|
||||
suggestions: {
|
||||
currentValue: mockSuggestions,
|
||||
previousValue: [],
|
||||
firstChange: true,
|
||||
isFirstChange: () => true,
|
||||
},
|
||||
})
|
||||
|
||||
const toastSpy = jest.spyOn(toastService, 'showInfo')
|
||||
const applySpy = jest.spyOn(component.apply, 'emit')
|
||||
|
||||
const suggestion = component.pendingSuggestions[0]
|
||||
component.applySuggestion(suggestion)
|
||||
|
||||
expect(suggestion.status).toBe(AISuggestionStatus.Applied)
|
||||
expect(applySpy).toHaveBeenCalledWith(suggestion)
|
||||
expect(toastSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should reject a suggestion', () => {
|
||||
component.suggestions = mockSuggestions
|
||||
component.ngOnChanges({
|
||||
suggestions: {
|
||||
currentValue: mockSuggestions,
|
||||
previousValue: [],
|
||||
firstChange: true,
|
||||
isFirstChange: () => true,
|
||||
},
|
||||
})
|
||||
|
||||
const toastSpy = jest.spyOn(toastService, 'showInfo')
|
||||
const rejectSpy = jest.spyOn(component.reject, 'emit')
|
||||
|
||||
const suggestion = component.pendingSuggestions[0]
|
||||
component.rejectSuggestion(suggestion)
|
||||
|
||||
expect(suggestion.status).toBe(AISuggestionStatus.Rejected)
|
||||
expect(rejectSpy).toHaveBeenCalledWith(suggestion)
|
||||
expect(toastSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should apply all suggestions', () => {
|
||||
component.suggestions = mockSuggestions
|
||||
component.ngOnChanges({
|
||||
suggestions: {
|
||||
currentValue: mockSuggestions,
|
||||
previousValue: [],
|
||||
firstChange: true,
|
||||
isFirstChange: () => true,
|
||||
},
|
||||
})
|
||||
|
||||
const toastSpy = jest.spyOn(toastService, 'showInfo')
|
||||
const applySpy = jest.spyOn(component.apply, 'emit')
|
||||
|
||||
component.applyAll()
|
||||
|
||||
expect(applySpy).toHaveBeenCalledTimes(3)
|
||||
expect(toastSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should reject all suggestions', () => {
|
||||
component.suggestions = mockSuggestions
|
||||
component.ngOnChanges({
|
||||
suggestions: {
|
||||
currentValue: mockSuggestions,
|
||||
previousValue: [],
|
||||
firstChange: true,
|
||||
isFirstChange: () => true,
|
||||
},
|
||||
})
|
||||
|
||||
const toastSpy = jest.spyOn(toastService, 'showInfo')
|
||||
const rejectSpy = jest.spyOn(component.reject, 'emit')
|
||||
|
||||
component.rejectAll()
|
||||
|
||||
expect(rejectSpy).toHaveBeenCalledTimes(3)
|
||||
expect(toastSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should return correct confidence class', () => {
|
||||
expect(component.getConfidenceClass(0.9)).toBe('confidence-high')
|
||||
expect(component.getConfidenceClass(0.7)).toBe('confidence-medium')
|
||||
expect(component.getConfidenceClass(0.5)).toBe('confidence-low')
|
||||
})
|
||||
|
||||
it('should return correct confidence label', () => {
|
||||
expect(component.getConfidenceLabel(0.85)).toContain('85%')
|
||||
expect(component.getConfidenceLabel(0.65)).toContain('65%')
|
||||
expect(component.getConfidenceLabel(0.45)).toContain('45%')
|
||||
})
|
||||
|
||||
it('should toggle collapse', () => {
|
||||
expect(component.isCollapsed).toBe(false)
|
||||
component.toggleCollapse()
|
||||
expect(component.isCollapsed).toBe(true)
|
||||
component.toggleCollapse()
|
||||
expect(component.isCollapsed).toBe(false)
|
||||
})
|
||||
|
||||
it('should respect disabled state', () => {
|
||||
component.suggestions = mockSuggestions
|
||||
component.disabled = true
|
||||
component.ngOnChanges({
|
||||
suggestions: {
|
||||
currentValue: mockSuggestions,
|
||||
previousValue: [],
|
||||
firstChange: true,
|
||||
isFirstChange: () => true,
|
||||
},
|
||||
})
|
||||
|
||||
const applySpy = jest.spyOn(component.apply, 'emit')
|
||||
const suggestion = component.pendingSuggestions[0]
|
||||
component.applySuggestion(suggestion)
|
||||
|
||||
expect(applySpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not render panel when there are no suggestions', () => {
|
||||
component.suggestions = []
|
||||
fixture.detectChanges()
|
||||
|
||||
expect(component.hasSuggestions).toBe(false)
|
||||
})
|
||||
|
||||
it('should render panel when there are suggestions', () => {
|
||||
component.suggestions = mockSuggestions
|
||||
component.ngOnChanges({
|
||||
suggestions: {
|
||||
currentValue: mockSuggestions,
|
||||
previousValue: [],
|
||||
firstChange: true,
|
||||
isFirstChange: () => true,
|
||||
},
|
||||
})
|
||||
fixture.detectChanges()
|
||||
|
||||
expect(component.hasSuggestions).toBe(true)
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,381 @@
|
|||
import { CommonModule } from '@angular/common'
|
||||
import {
|
||||
trigger,
|
||||
state,
|
||||
style,
|
||||
transition,
|
||||
animate,
|
||||
} from '@angular/animations'
|
||||
import {
|
||||
Component,
|
||||
EventEmitter,
|
||||
Input,
|
||||
OnChanges,
|
||||
Output,
|
||||
SimpleChanges,
|
||||
inject,
|
||||
} from '@angular/core'
|
||||
import { NgbCollapseModule } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||
import {
|
||||
AISuggestion,
|
||||
AISuggestionStatus,
|
||||
AISuggestionType,
|
||||
} from 'src/app/data/ai-suggestion'
|
||||
import { Correspondent } from 'src/app/data/correspondent'
|
||||
import { CustomField } from 'src/app/data/custom-field'
|
||||
import { DocumentType } from 'src/app/data/document-type'
|
||||
import { StoragePath } from 'src/app/data/storage-path'
|
||||
import { Tag } from 'src/app/data/tag'
|
||||
import { CorrespondentService } from 'src/app/services/rest/correspondent.service'
|
||||
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
|
||||
import { DocumentTypeService } from 'src/app/services/rest/document-type.service'
|
||||
import { StoragePathService } from 'src/app/services/rest/storage-path.service'
|
||||
import { TagService } from 'src/app/services/rest/tag.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-ai-suggestions-panel',
|
||||
templateUrl: './ai-suggestions-panel.component.html',
|
||||
styleUrls: ['./ai-suggestions-panel.component.scss'],
|
||||
imports: [
|
||||
CommonModule,
|
||||
NgbCollapseModule,
|
||||
NgxBootstrapIconsModule,
|
||||
],
|
||||
animations: [
|
||||
trigger('slideIn', [
|
||||
transition(':enter', [
|
||||
style({ transform: 'translateY(-20px)', opacity: 0 }),
|
||||
animate('300ms ease-out', style({ transform: 'translateY(0)', opacity: 1 })),
|
||||
]),
|
||||
]),
|
||||
trigger('fadeInOut', [
|
||||
transition(':enter', [
|
||||
style({ opacity: 0, transform: 'scale(0.95)' }),
|
||||
animate('200ms ease-out', style({ opacity: 1, transform: 'scale(1)' })),
|
||||
]),
|
||||
transition(':leave', [
|
||||
animate('200ms ease-in', style({ opacity: 0, transform: 'scale(0.95)' })),
|
||||
]),
|
||||
]),
|
||||
],
|
||||
})
|
||||
export class AiSuggestionsPanelComponent implements OnChanges {
|
||||
private tagService = inject(TagService)
|
||||
private correspondentService = inject(CorrespondentService)
|
||||
private documentTypeService = inject(DocumentTypeService)
|
||||
private storagePathService = inject(StoragePathService)
|
||||
private customFieldsService = inject(CustomFieldsService)
|
||||
private toastService = inject(ToastService)
|
||||
|
||||
@Input()
|
||||
suggestions: AISuggestion[] = []
|
||||
|
||||
@Input()
|
||||
disabled: boolean = false
|
||||
|
||||
@Output()
|
||||
apply = new EventEmitter<AISuggestion>()
|
||||
|
||||
@Output()
|
||||
reject = new EventEmitter<AISuggestion>()
|
||||
|
||||
public isCollapsed = false
|
||||
public pendingSuggestions: AISuggestion[] = []
|
||||
public groupedSuggestions: Map<AISuggestionType, AISuggestion[]> = new Map()
|
||||
public appliedCount = 0
|
||||
public rejectedCount = 0
|
||||
|
||||
private tags: Tag[] = []
|
||||
private correspondents: Correspondent[] = []
|
||||
private documentTypes: DocumentType[] = []
|
||||
private storagePaths: StoragePath[] = []
|
||||
private customFields: CustomField[] = []
|
||||
|
||||
public AISuggestionType = AISuggestionType
|
||||
public AISuggestionStatus = AISuggestionStatus
|
||||
|
||||
ngOnChanges(changes: SimpleChanges): void {
|
||||
if (changes['suggestions']) {
|
||||
this.processSuggestions()
|
||||
this.loadMetadata()
|
||||
}
|
||||
}
|
||||
|
||||
private processSuggestions(): void {
|
||||
this.pendingSuggestions = this.suggestions.filter(
|
||||
(s) => s.status === AISuggestionStatus.Pending
|
||||
)
|
||||
this.appliedCount = this.suggestions.filter(
|
||||
(s) => s.status === AISuggestionStatus.Applied
|
||||
).length
|
||||
this.rejectedCount = this.suggestions.filter(
|
||||
(s) => s.status === AISuggestionStatus.Rejected
|
||||
).length
|
||||
|
||||
// Group suggestions by type
|
||||
this.groupedSuggestions.clear()
|
||||
this.pendingSuggestions.forEach((suggestion) => {
|
||||
const group = this.groupedSuggestions.get(suggestion.type) || []
|
||||
group.push(suggestion)
|
||||
this.groupedSuggestions.set(suggestion.type, group)
|
||||
})
|
||||
}
|
||||
|
||||
private loadMetadata(): void {
|
||||
// Load tags if needed
|
||||
const tagSuggestions = this.pendingSuggestions.filter(
|
||||
(s) => s.type === AISuggestionType.Tag
|
||||
)
|
||||
if (tagSuggestions.length > 0) {
|
||||
this.tagService.listAll().subscribe((tags) => {
|
||||
this.tags = tags.results
|
||||
this.updateSuggestionLabels()
|
||||
})
|
||||
}
|
||||
|
||||
// Load correspondents if needed
|
||||
const correspondentSuggestions = this.pendingSuggestions.filter(
|
||||
(s) => s.type === AISuggestionType.Correspondent
|
||||
)
|
||||
if (correspondentSuggestions.length > 0) {
|
||||
this.correspondentService.listAll().subscribe((correspondents) => {
|
||||
this.correspondents = correspondents.results
|
||||
this.updateSuggestionLabels()
|
||||
})
|
||||
}
|
||||
|
||||
// Load document types if needed
|
||||
const documentTypeSuggestions = this.pendingSuggestions.filter(
|
||||
(s) => s.type === AISuggestionType.DocumentType
|
||||
)
|
||||
if (documentTypeSuggestions.length > 0) {
|
||||
this.documentTypeService.listAll().subscribe((documentTypes) => {
|
||||
this.documentTypes = documentTypes.results
|
||||
this.updateSuggestionLabels()
|
||||
})
|
||||
}
|
||||
|
||||
// Load storage paths if needed
|
||||
const storagePathSuggestions = this.pendingSuggestions.filter(
|
||||
(s) => s.type === AISuggestionType.StoragePath
|
||||
)
|
||||
if (storagePathSuggestions.length > 0) {
|
||||
this.storagePathService.listAll().subscribe((storagePaths) => {
|
||||
this.storagePaths = storagePaths.results
|
||||
this.updateSuggestionLabels()
|
||||
})
|
||||
}
|
||||
|
||||
// Load custom fields if needed
|
||||
const customFieldSuggestions = this.pendingSuggestions.filter(
|
||||
(s) => s.type === AISuggestionType.CustomField
|
||||
)
|
||||
if (customFieldSuggestions.length > 0) {
|
||||
this.customFieldsService.listAll().subscribe((customFields) => {
|
||||
this.customFields = customFields.results
|
||||
this.updateSuggestionLabels()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private updateSuggestionLabels(): void {
|
||||
this.pendingSuggestions.forEach((suggestion) => {
|
||||
if (!suggestion.label) {
|
||||
suggestion.label = this.getLabel(suggestion)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
public getLabel(suggestion: AISuggestion): string {
|
||||
if (suggestion.label) {
|
||||
return suggestion.label
|
||||
}
|
||||
|
||||
switch (suggestion.type) {
|
||||
case AISuggestionType.Tag:
|
||||
const tag = this.tags.find((t) => t.id === suggestion.value)
|
||||
return tag ? tag.name : `Tag #${suggestion.value}`
|
||||
|
||||
case AISuggestionType.Correspondent:
|
||||
const correspondent = this.correspondents.find(
|
||||
(c) => c.id === suggestion.value
|
||||
)
|
||||
return correspondent
|
||||
? correspondent.name
|
||||
: `Correspondent #${suggestion.value}`
|
||||
|
||||
case AISuggestionType.DocumentType:
|
||||
const docType = this.documentTypes.find(
|
||||
(dt) => dt.id === suggestion.value
|
||||
)
|
||||
return docType ? docType.name : `Document Type #${suggestion.value}`
|
||||
|
||||
case AISuggestionType.StoragePath:
|
||||
const storagePath = this.storagePaths.find(
|
||||
(sp) => sp.id === suggestion.value
|
||||
)
|
||||
return storagePath ? storagePath.name : `Storage Path #${suggestion.value}`
|
||||
|
||||
case AISuggestionType.CustomField:
|
||||
return suggestion.field_name || 'Custom Field'
|
||||
|
||||
case AISuggestionType.Date:
|
||||
return new Date(suggestion.value).toLocaleDateString()
|
||||
|
||||
case AISuggestionType.Title:
|
||||
return suggestion.value
|
||||
|
||||
default:
|
||||
return String(suggestion.value)
|
||||
}
|
||||
}
|
||||
|
||||
public getTypeLabel(type: AISuggestionType): string {
|
||||
switch (type) {
|
||||
case AISuggestionType.Tag:
|
||||
return $localize`Tags`
|
||||
case AISuggestionType.Correspondent:
|
||||
return $localize`Correspondent`
|
||||
case AISuggestionType.DocumentType:
|
||||
return $localize`Document Type`
|
||||
case AISuggestionType.StoragePath:
|
||||
return $localize`Storage Path`
|
||||
case AISuggestionType.CustomField:
|
||||
return $localize`Custom Field`
|
||||
case AISuggestionType.Date:
|
||||
return $localize`Date`
|
||||
case AISuggestionType.Title:
|
||||
return $localize`Title`
|
||||
default:
|
||||
return String(type)
|
||||
}
|
||||
}
|
||||
|
||||
public getTypeIcon(type: AISuggestionType): string {
|
||||
switch (type) {
|
||||
case AISuggestionType.Tag:
|
||||
return 'tag'
|
||||
case AISuggestionType.Correspondent:
|
||||
return 'person'
|
||||
case AISuggestionType.DocumentType:
|
||||
return 'file-earmark-text'
|
||||
case AISuggestionType.StoragePath:
|
||||
return 'folder'
|
||||
case AISuggestionType.CustomField:
|
||||
return 'input-cursor-text'
|
||||
case AISuggestionType.Date:
|
||||
return 'calendar'
|
||||
case AISuggestionType.Title:
|
||||
return 'pencil'
|
||||
default:
|
||||
return 'lightbulb'
|
||||
}
|
||||
}
|
||||
|
||||
public getConfidenceClass(confidence: number): string {
|
||||
if (confidence >= 0.8) {
|
||||
return 'confidence-high'
|
||||
} else if (confidence >= 0.6) {
|
||||
return 'confidence-medium'
|
||||
} else {
|
||||
return 'confidence-low'
|
||||
}
|
||||
}
|
||||
|
||||
public getConfidenceLabel(confidence: number): string {
|
||||
const percentage = Math.round(confidence * 100)
|
||||
if (confidence >= 0.8) {
|
||||
return $localize`High (${percentage}%)`
|
||||
} else if (confidence >= 0.6) {
|
||||
return $localize`Medium (${percentage}%)`
|
||||
} else {
|
||||
return $localize`Low (${percentage}%)`
|
||||
}
|
||||
}
|
||||
|
||||
public getConfidenceIcon(confidence: number): string {
|
||||
if (confidence >= 0.8) {
|
||||
return 'check-circle-fill'
|
||||
} else if (confidence >= 0.6) {
|
||||
return 'exclamation-circle'
|
||||
} else {
|
||||
return 'question-circle'
|
||||
}
|
||||
}
|
||||
|
||||
public applySuggestion(suggestion: AISuggestion): void {
|
||||
if (this.disabled) {
|
||||
return
|
||||
}
|
||||
|
||||
suggestion.status = AISuggestionStatus.Applied
|
||||
this.apply.emit(suggestion)
|
||||
this.processSuggestions()
|
||||
|
||||
this.toastService.showInfo(
|
||||
$localize`Applied AI suggestion: ${this.getLabel(suggestion)}`
|
||||
)
|
||||
}
|
||||
|
||||
public rejectSuggestion(suggestion: AISuggestion): void {
|
||||
if (this.disabled) {
|
||||
return
|
||||
}
|
||||
|
||||
suggestion.status = AISuggestionStatus.Rejected
|
||||
this.reject.emit(suggestion)
|
||||
this.processSuggestions()
|
||||
|
||||
this.toastService.showInfo(
|
||||
$localize`Rejected AI suggestion: ${this.getLabel(suggestion)}`
|
||||
)
|
||||
}
|
||||
|
||||
public applyAll(): void {
|
||||
if (this.disabled) {
|
||||
return
|
||||
}
|
||||
|
||||
const count = this.pendingSuggestions.length
|
||||
this.pendingSuggestions.forEach((suggestion) => {
|
||||
suggestion.status = AISuggestionStatus.Applied
|
||||
this.apply.emit(suggestion)
|
||||
})
|
||||
this.processSuggestions()
|
||||
|
||||
this.toastService.showInfo(
|
||||
$localize`Applied ${count} AI suggestions`
|
||||
)
|
||||
}
|
||||
|
||||
public rejectAll(): void {
|
||||
if (this.disabled) {
|
||||
return
|
||||
}
|
||||
|
||||
const count = this.pendingSuggestions.length
|
||||
this.pendingSuggestions.forEach((suggestion) => {
|
||||
suggestion.status = AISuggestionStatus.Rejected
|
||||
this.reject.emit(suggestion)
|
||||
})
|
||||
this.processSuggestions()
|
||||
|
||||
this.toastService.showInfo(
|
||||
$localize`Rejected ${count} AI suggestions`
|
||||
)
|
||||
}
|
||||
|
||||
public toggleCollapse(): void {
|
||||
this.isCollapsed = !this.isCollapsed
|
||||
}
|
||||
|
||||
public get hasSuggestions(): boolean {
|
||||
return this.pendingSuggestions.length > 0
|
||||
}
|
||||
|
||||
public get suggestionTypes(): AISuggestionType[] {
|
||||
return Array.from(this.groupedSuggestions.keys())
|
||||
}
|
||||
}
|
||||
32
src-ui/src/app/data/ai-suggestion.ts
Normal file
32
src-ui/src/app/data/ai-suggestion.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
export enum AISuggestionType {
|
||||
Tag = 'tag',
|
||||
Correspondent = 'correspondent',
|
||||
DocumentType = 'document_type',
|
||||
StoragePath = 'storage_path',
|
||||
CustomField = 'custom_field',
|
||||
Date = 'date',
|
||||
Title = 'title',
|
||||
}
|
||||
|
||||
export enum AISuggestionStatus {
|
||||
Pending = 'pending',
|
||||
Applied = 'applied',
|
||||
Rejected = 'rejected',
|
||||
}
|
||||
|
||||
export interface AISuggestion {
|
||||
id: string
|
||||
type: AISuggestionType
|
||||
value: any
|
||||
confidence: number
|
||||
status: AISuggestionStatus
|
||||
label?: string
|
||||
field_name?: string // For custom fields
|
||||
created_at?: Date
|
||||
}
|
||||
|
||||
export interface AIDocumentSuggestions {
|
||||
document_id: number
|
||||
suggestions: AISuggestion[]
|
||||
generated_at: Date
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue