mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-08-12 05:43:18 +00:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
72a4676be0 | ||
|
|
6673144d23 | ||
|
|
994a84cf92 | ||
|
|
654ce5d8f3 | ||
|
|
5d5e9b6db4 |
@@ -948,11 +948,10 @@ for display in the web interface.
|
|||||||
|
|
||||||
!!! note
|
!!! note
|
||||||
|
|
||||||
The **remote OCR parser** (Azure AI) also honors this setting: when
|
The **remote OCR parser** (Azure AI) always produces a searchable
|
||||||
no archive is requested (`never`, or `auto` with a born-digital PDF),
|
PDF and stores it as the archive copy, regardless of this setting.
|
||||||
the remote engine is skipped entirely and locally-extracted text is
|
`ARCHIVE_FILE_GENERATION=never` has no effect when the remote
|
||||||
used instead, avoiding an unnecessary API call and a duplicate text
|
parser handles a document.
|
||||||
layer.
|
|
||||||
|
|
||||||
#### [`PAPERLESS_OCR_CLEAN=<mode>`](#PAPERLESS_OCR_CLEAN) {#PAPERLESS_OCR_CLEAN}
|
#### [`PAPERLESS_OCR_CLEAN=<mode>`](#PAPERLESS_OCR_CLEAN) {#PAPERLESS_OCR_CLEAN}
|
||||||
|
|
||||||
|
|||||||
@@ -187,11 +187,10 @@ PAPERLESS_ARCHIVE_FILE_GENERATION=auto
|
|||||||
|
|
||||||
### Remote OCR parser
|
### Remote OCR parser
|
||||||
|
|
||||||
If you use the **remote OCR parser** (Azure AI), `ARCHIVE_FILE_GENERATION` is
|
If you use the **remote OCR parser** (Azure AI), note that it always produces a
|
||||||
honored the same way as for the local engine: when no archive is requested
|
searchable PDF and stores it as the archive copy. `ARCHIVE_FILE_GENERATION=never`
|
||||||
(`never`, or `auto` with a born-digital PDF), the remote engine is skipped
|
has no effect for documents handled by the remote parser - the archive is produced
|
||||||
entirely and locally-extracted text is used instead, avoiding an unnecessary
|
unconditionally by the remote engine.
|
||||||
API call and a duplicate text layer.
|
|
||||||
|
|
||||||
## Search Index (Whoosh -> Tantivy)
|
## Search Index (Whoosh -> Tantivy)
|
||||||
|
|
||||||
|
|||||||
+3
-4
@@ -576,9 +576,7 @@ The following workflow action types are available:
|
|||||||
- Tags, correspondent, document type and storage path
|
- Tags, correspondent, document type and storage path
|
||||||
- Document owner
|
- Document owner
|
||||||
- View and / or edit permissions to users or groups
|
- View and / or edit permissions to users or groups
|
||||||
- Custom fields, optionally with a value. If no value is set, the field is only added to the
|
- Custom fields. Note that no value for the field will be set
|
||||||
document and any value it may already have is left untouched. If a value is set, it will
|
|
||||||
overwrite an existing value of that field on the document.
|
|
||||||
|
|
||||||
##### Removal {#workflow-action-removal}
|
##### Removal {#workflow-action-removal}
|
||||||
|
|
||||||
@@ -1086,7 +1084,8 @@ Paperless-ngx supports performing OCR on documents using remote services. At the
|
|||||||
[Microsoft's Azure "Document Intelligence" service](https://azure.microsoft.com/en-us/products/ai-services/ai-document-intelligence).
|
[Microsoft's Azure "Document Intelligence" service](https://azure.microsoft.com/en-us/products/ai-services/ai-document-intelligence).
|
||||||
This is of course a paid service (with a free tier) which requires an Azure account and subscription. Azure AI is not affiliated with
|
This is of course a paid service (with a free tier) which requires an Azure account and subscription. Azure AI is not affiliated with
|
||||||
Paperless-ngx in any way. When enabled, Paperless-ngx will automatically send appropriate documents to Azure for OCR processing, bypassing
|
Paperless-ngx in any way. When enabled, Paperless-ngx will automatically send appropriate documents to Azure for OCR processing, bypassing
|
||||||
the local OCR engine. See the [configuration](configuration.md#PAPERLESS_REMOTE_OCR_ENGINE) options for more details.
|
the local OCR engine. See the [configuration](configuration.md#PAPERLESS_REMOTE_OCR_ENGINE) options for more details. These
|
||||||
|
settings can be supplied as environment variables or via **Application Configuration**.
|
||||||
|
|
||||||
Additionally, when using a commercial service with this feature, consider both potential costs as well as any associated file size
|
Additionally, when using a commercial service with this feature, consider both potential costs as well as any associated file size
|
||||||
or page limitations (e.g. with a free tier).
|
or page limitations (e.g. with a free tier).
|
||||||
|
|||||||
+8
-8
@@ -599,7 +599,7 @@
|
|||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/manage/saved-views/saved-views.component.html</context>
|
<context context-type="sourcefile">src/app/components/manage/saved-views/saved-views.component.html</context>
|
||||||
<context context-type="linenumber">85,86</context>
|
<context context-type="linenumber">84,85</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="3768927257183755959" datatype="html">
|
<trans-unit id="3768927257183755959" datatype="html">
|
||||||
@@ -670,7 +670,7 @@
|
|||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/manage/saved-views/saved-views.component.html</context>
|
<context context-type="sourcefile">src/app/components/manage/saved-views/saved-views.component.html</context>
|
||||||
<context context-type="linenumber">86,87</context>
|
<context context-type="linenumber">85,86</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="5079885666748292382" datatype="html">
|
<trans-unit id="5079885666748292382" datatype="html">
|
||||||
@@ -9907,7 +9907,7 @@
|
|||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/manage/saved-views/saved-views.component.ts</context>
|
<context context-type="sourcefile">src/app/components/manage/saved-views/saved-views.component.ts</context>
|
||||||
<context context-type="linenumber">314</context>
|
<context context-type="linenumber">296</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="2620006875434695386" datatype="html">
|
<trans-unit id="2620006875434695386" datatype="html">
|
||||||
@@ -10234,7 +10234,7 @@
|
|||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/manage/saved-views/saved-views.component.ts</context>
|
<context context-type="sourcefile">src/app/components/manage/saved-views/saved-views.component.ts</context>
|
||||||
<context context-type="linenumber">308</context>
|
<context context-type="linenumber">290</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="3501895737484542570" datatype="html">
|
<trans-unit id="3501895737484542570" datatype="html">
|
||||||
@@ -10346,28 +10346,28 @@
|
|||||||
<source>Saved view "<x id="PH" equiv-text="savedView.name"/>" deleted.</source>
|
<source>Saved view "<x id="PH" equiv-text="savedView.name"/>" deleted.</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/manage/saved-views/saved-views.component.ts</context>
|
<context context-type="sourcefile">src/app/components/manage/saved-views/saved-views.component.ts</context>
|
||||||
<context context-type="linenumber">178</context>
|
<context context-type="linenumber">160</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="1660419335376265526" datatype="html">
|
<trans-unit id="1660419335376265526" datatype="html">
|
||||||
<source>Views saved successfully.</source>
|
<source>Views saved successfully.</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/manage/saved-views/saved-views.component.ts</context>
|
<context context-type="sourcefile">src/app/components/manage/saved-views/saved-views.component.ts</context>
|
||||||
<context context-type="linenumber">255</context>
|
<context context-type="linenumber">237</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="1699877326523238632" datatype="html">
|
<trans-unit id="1699877326523238632" datatype="html">
|
||||||
<source>Error while saving views.</source>
|
<source>Error while saving views.</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/manage/saved-views/saved-views.component.ts</context>
|
<context context-type="sourcefile">src/app/components/manage/saved-views/saved-views.component.ts</context>
|
||||||
<context context-type="linenumber">260</context>
|
<context context-type="linenumber">242</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="4919025779187821586" datatype="html">
|
<trans-unit id="4919025779187821586" datatype="html">
|
||||||
<source>Note: Sharing saved views does not share the underlying documents.</source>
|
<source>Note: Sharing saved views does not share the underlying documents.</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/manage/saved-views/saved-views.component.ts</context>
|
<context context-type="sourcefile">src/app/components/manage/saved-views/saved-views.component.ts</context>
|
||||||
<context context-type="linenumber">296</context>
|
<context context-type="linenumber">278</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="1229748338333965418" datatype="html">
|
<trans-unit id="1229748338333965418" datatype="html">
|
||||||
|
|||||||
@@ -14,8 +14,12 @@
|
|||||||
<a ngbNavLink>{{category}}</a>
|
<a ngbNavLink>{{category}}</a>
|
||||||
<ng-template ngbNavContent>
|
<ng-template ngbNavContent>
|
||||||
<div class="p-3">
|
<div class="p-3">
|
||||||
|
@for (section of getCategorySections(category); track section) {
|
||||||
|
@if (section) {
|
||||||
|
<h5 class="mt-4 mb-3">{{section}}</h5>
|
||||||
|
}
|
||||||
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-2">
|
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-2">
|
||||||
@for (option of getCategoryOptions(category); track option.key) {
|
@for (option of getCategoryOptions(category, section); track option.key) {
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<div class="card bg-light">
|
<div class="card bg-light">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
@@ -51,6 +55,7 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -8,7 +8,11 @@ import { NgbModule } from '@ng-bootstrap/ng-bootstrap'
|
|||||||
import { NgSelectModule } from '@ng-select/ng-select'
|
import { NgSelectModule } from '@ng-select/ng-select'
|
||||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||||
import { of, throwError } from 'rxjs'
|
import { of, throwError } from 'rxjs'
|
||||||
import { OutputTypeConfig } from 'src/app/data/paperless-config'
|
import {
|
||||||
|
ConfigCategory,
|
||||||
|
ConfigSection,
|
||||||
|
OutputTypeConfig,
|
||||||
|
} from 'src/app/data/paperless-config'
|
||||||
import { ConfigService } from 'src/app/services/config.service'
|
import { ConfigService } from 'src/app/services/config.service'
|
||||||
import { SettingsService } from 'src/app/services/settings.service'
|
import { SettingsService } from 'src/app/services/settings.service'
|
||||||
import { ToastService } from 'src/app/services/toast.service'
|
import { ToastService } from 'src/app/services/toast.service'
|
||||||
@@ -158,4 +162,23 @@ describe('ConfigComponent', () => {
|
|||||||
component.resetOption('barcodes_enabled')
|
component.resetOption('barcodes_enabled')
|
||||||
expect(component.configForm.get('barcodes_enabled').value).toBeNull()
|
expect(component.configForm.get('barcodes_enabled').value).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should group options into sections within a category, or not', () => {
|
||||||
|
const sections = component.getCategorySections(ConfigCategory.OCR)
|
||||||
|
expect(sections).toEqual([null, ConfigSection.RemoteOCR])
|
||||||
|
expect(
|
||||||
|
component
|
||||||
|
.getCategoryOptions(ConfigCategory.OCR)
|
||||||
|
.map((option) => option.key)
|
||||||
|
).toContain('output_type')
|
||||||
|
expect(
|
||||||
|
component
|
||||||
|
.getCategoryOptions(ConfigCategory.OCR, ConfigSection.RemoteOCR)
|
||||||
|
.map((option) => option.key)
|
||||||
|
).toEqual([
|
||||||
|
'remote_ocr_engine',
|
||||||
|
'remote_ocr_api_key',
|
||||||
|
'remote_ocr_endpoint',
|
||||||
|
])
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -74,8 +74,20 @@ export class ConfigComponent
|
|||||||
return Object.values(ConfigCategory)
|
return Object.values(ConfigCategory)
|
||||||
}
|
}
|
||||||
|
|
||||||
getCategoryOptions(category: string): ConfigOption[] {
|
getCategorySections(category: string): string[] {
|
||||||
return PaperlessConfigOptions.filter((o) => o.category === category)
|
return [
|
||||||
|
...new Set(
|
||||||
|
PaperlessConfigOptions.filter((o) => o.category === category).map(
|
||||||
|
(o) => o.section ?? null // null means no section
|
||||||
|
)
|
||||||
|
),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
getCategoryOptions(category: string, section: string = null): ConfigOption[] {
|
||||||
|
return PaperlessConfigOptions.filter(
|
||||||
|
(o) => o.category === category && (o.section ?? null) === section
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
initialConfig: PaperlessConfig
|
initialConfig: PaperlessConfig
|
||||||
|
|||||||
+4
-4
@@ -52,10 +52,10 @@ describe('CustomFieldsValuesComponent', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should set selectedFields and map values correctly', () => {
|
it('should set selectedFields and map values correctly', () => {
|
||||||
component.value = { 1: 'value1', 3: 0, 4: false }
|
component.value = { 1: 'value1' }
|
||||||
component.selectedFields = [1, 2, 3, 4]
|
component.selectedFields = [1, 2]
|
||||||
expect(component.selectedFields).toEqual([1, 2, 3, 4])
|
expect(component.selectedFields).toEqual([1, 2])
|
||||||
expect(component.value).toEqual({ 1: 'value1', 2: null, 3: 0, 4: false })
|
expect(component.value).toEqual({ 1: 'value1', 2: null })
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should return the correct custom field by id', () => {
|
it('should return the correct custom field by id', () => {
|
||||||
|
|||||||
+1
-1
@@ -77,7 +77,7 @@ export class CustomFieldsValuesComponent extends AbstractInputComponent<Object>
|
|||||||
this._selectedFields = newFields
|
this._selectedFields = newFields
|
||||||
// map the selected fields to an object with field_id as key and value as value
|
// map the selected fields to an object with field_id as key and value as value
|
||||||
this.value = newFields.reduce((acc, fieldId) => {
|
this.value = newFields.reduce((acc, fieldId) => {
|
||||||
acc[fieldId] = this.value?.[fieldId] ?? null
|
acc[fieldId] = this.value?.[fieldId] || null
|
||||||
return acc
|
return acc
|
||||||
}, {})
|
}, {})
|
||||||
this.onChange(this.value)
|
this.onChange(this.value)
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
</pngx-page-header>
|
</pngx-page-header>
|
||||||
<form [formGroup]="savedViewsForm" (ngSubmit)="save()">
|
<form [formGroup]="savedViewsForm" (ngSubmit)="save()">
|
||||||
<ul class="list-group mb-3" formGroupName="savedViews">
|
<ul class="list-group mb-3" formGroupName="savedViews">
|
||||||
@for (view of pagedSavedViews(); track view) {
|
@for (view of savedViews(); track view) {
|
||||||
<li class="list-group-item py-3">
|
<li class="list-group-item py-3">
|
||||||
<div [formGroupName]="view.id">
|
<div [formGroupName]="view.id">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
@@ -81,11 +81,6 @@
|
|||||||
}
|
}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<div class="d-flex align-items-center mb-3">
|
|
||||||
<button type="button" (click)="reset()" class="btn btn-outline-secondary mb-2" [disabled]="(isDirty$ | async) === false" i18n>Cancel</button>
|
<button type="button" (click)="reset()" class="btn btn-outline-secondary mb-2" [disabled]="(isDirty$ | async) === false" i18n>Cancel</button>
|
||||||
<button type="submit" class="btn btn-primary ms-2 mb-2" [disabled]="(isDirty$ | async) === false" i18n>Save</button>
|
<button type="submit" class="btn btn-primary ms-2 mb-2" [disabled]="(isDirty$ | async) === false" i18n>Save</button>
|
||||||
@if (savedViews()?.length > pageSize) {
|
|
||||||
<ngb-pagination class="ms-auto" [pageSize]="pageSize" [collectionSize]="savedViews().length" [page]="page()" [maxSize]="5" (pageChange)="page.set($event)" size="sm" aria-label="Pagination"></ngb-pagination>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { provideHttpClientTesting } from '@angular/common/http/testing'
|
|||||||
import { signal } from '@angular/core'
|
import { signal } from '@angular/core'
|
||||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||||
import { By } from '@angular/platform-browser'
|
|
||||||
import { NgbModal, NgbModule } from '@ng-bootstrap/ng-bootstrap'
|
import { NgbModal, NgbModule } from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||||
import { Subject, of, throwError } from 'rxjs'
|
import { Subject, of, throwError } from 'rxjs'
|
||||||
@@ -223,44 +222,6 @@ describe('SavedViewsComponent', () => {
|
|||||||
).toEqual(view.show_on_dashboard)
|
).toEqual(view.show_on_dashboard)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should page saved views, clamp the page if views are removed', () => {
|
|
||||||
const manyViews = Array.from({ length: 30 }, (_, i) => ({
|
|
||||||
id: i + 1,
|
|
||||||
name: `view${i + 1}`,
|
|
||||||
})) as SavedView[]
|
|
||||||
const listSpy = jest.spyOn(savedViewService, 'list').mockReturnValue(
|
|
||||||
of({
|
|
||||||
all: manyViews.map((v) => v.id),
|
|
||||||
count: manyViews.length,
|
|
||||||
results: manyViews.concat([]),
|
|
||||||
})
|
|
||||||
)
|
|
||||||
component.ngOnInit()
|
|
||||||
fixture.detectChanges()
|
|
||||||
expect(listSpy).toHaveBeenCalledWith(1, 100000, null, false, {
|
|
||||||
full_perms: true,
|
|
||||||
})
|
|
||||||
expect(component.pagedSavedViews()).toHaveLength(25)
|
|
||||||
expect(fixture.debugElement.query(By.css('ngb-pagination'))).not.toBeNull()
|
|
||||||
// all views have controls, not just the current page
|
|
||||||
expect(
|
|
||||||
Object.keys(component.savedViewsForm.get('savedViews').value)
|
|
||||||
).toHaveLength(30)
|
|
||||||
|
|
||||||
component.page.set(2)
|
|
||||||
expect(component.pagedSavedViews()).toHaveLength(5)
|
|
||||||
|
|
||||||
listSpy.mockReturnValue(
|
|
||||||
of({
|
|
||||||
all: manyViews.slice(0, 25).map((v) => v.id),
|
|
||||||
count: 25,
|
|
||||||
results: manyViews.slice(0, 25),
|
|
||||||
})
|
|
||||||
)
|
|
||||||
component.ngOnInit()
|
|
||||||
expect(component.page()).toEqual(1)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should support editing permissions', () => {
|
it('should support editing permissions', () => {
|
||||||
const confirmClicked = new Subject<any>()
|
const confirmClicked = new Subject<any>()
|
||||||
const modalRef = {
|
const modalRef = {
|
||||||
|
|||||||
@@ -1,19 +1,12 @@
|
|||||||
import { AsyncPipe } from '@angular/common'
|
import { AsyncPipe } from '@angular/common'
|
||||||
import {
|
import { Component, OnDestroy, OnInit, inject, signal } from '@angular/core'
|
||||||
Component,
|
|
||||||
OnDestroy,
|
|
||||||
OnInit,
|
|
||||||
computed,
|
|
||||||
inject,
|
|
||||||
signal,
|
|
||||||
} from '@angular/core'
|
|
||||||
import {
|
import {
|
||||||
FormControl,
|
FormControl,
|
||||||
FormGroup,
|
FormGroup,
|
||||||
FormsModule,
|
FormsModule,
|
||||||
ReactiveFormsModule,
|
ReactiveFormsModule,
|
||||||
} from '@angular/forms'
|
} from '@angular/forms'
|
||||||
import { NgbModal, NgbPaginationModule } from '@ng-bootstrap/ng-bootstrap'
|
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { dirtyCheck } from '@ngneat/dirty-check-forms'
|
import { dirtyCheck } from '@ngneat/dirty-check-forms'
|
||||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||||
import { BehaviorSubject, Observable, of, switchMap, takeUntil } from 'rxjs'
|
import { BehaviorSubject, Observable, of, switchMap, takeUntil } from 'rxjs'
|
||||||
@@ -49,7 +42,6 @@ import { LoadingComponentWithPermissions } from '../../loading-component/loading
|
|||||||
FormsModule,
|
FormsModule,
|
||||||
ReactiveFormsModule,
|
ReactiveFormsModule,
|
||||||
AsyncPipe,
|
AsyncPipe,
|
||||||
NgbPaginationModule,
|
|
||||||
NgxBootstrapIconsModule,
|
NgxBootstrapIconsModule,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
@@ -66,14 +58,6 @@ export class SavedViewsComponent
|
|||||||
DisplayMode = DisplayMode
|
DisplayMode = DisplayMode
|
||||||
|
|
||||||
readonly savedViews = signal<SavedView[]>(undefined)
|
readonly savedViews = signal<SavedView[]>(undefined)
|
||||||
readonly page = signal(1)
|
|
||||||
public readonly pageSize = 25
|
|
||||||
// All views are loaded at init, so paging is only for display
|
|
||||||
readonly pagedSavedViews = computed(() => {
|
|
||||||
const start = (this.page() - 1) * this.pageSize
|
|
||||||
return this.savedViews()?.slice(start, start + this.pageSize)
|
|
||||||
})
|
|
||||||
|
|
||||||
private savedViewsGroup = new FormGroup({})
|
private savedViewsGroup = new FormGroup({})
|
||||||
public savedViewsForm: FormGroup = new FormGroup({
|
public savedViewsForm: FormGroup = new FormGroup({
|
||||||
savedViews: this.savedViewsGroup,
|
savedViews: this.savedViewsGroup,
|
||||||
@@ -100,11 +84,9 @@ export class SavedViewsComponent
|
|||||||
private reloadViews(): void {
|
private reloadViews(): void {
|
||||||
this.loading.set(true)
|
this.loading.set(true)
|
||||||
this.savedViewService
|
this.savedViewService
|
||||||
.list(1, 100000, null, false, { full_perms: true })
|
.list(null, null, null, false, { full_perms: true })
|
||||||
.subscribe((r) => {
|
.subscribe((r) => {
|
||||||
this.savedViews.set(r.results)
|
this.savedViews.set(r.results)
|
||||||
const pageCount = Math.ceil(r.results.length / this.pageSize)
|
|
||||||
this.page.update((page) => Math.min(page, Math.max(1, pageCount)))
|
|
||||||
this.initialize()
|
this.initialize()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,6 +54,10 @@ export const ConfigCategory = {
|
|||||||
AI: $localize`AI Settings`,
|
AI: $localize`AI Settings`,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const ConfigSection = {
|
||||||
|
RemoteOCR: $localize`Remote OCR`,
|
||||||
|
}
|
||||||
|
|
||||||
export const LLMEmbeddingBackendConfig = {
|
export const LLMEmbeddingBackendConfig = {
|
||||||
OPENAI_LIKE: 'openai-like',
|
OPENAI_LIKE: 'openai-like',
|
||||||
HUGGINGFACE: 'huggingface',
|
HUGGINGFACE: 'huggingface',
|
||||||
@@ -65,6 +69,10 @@ export const LLMBackendConfig = {
|
|||||||
OLLAMA: 'ollama',
|
OLLAMA: 'ollama',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const RemoteOCREngineConfig = {
|
||||||
|
AZURE_AI: 'azureai',
|
||||||
|
}
|
||||||
|
|
||||||
export interface ConfigOption {
|
export interface ConfigOption {
|
||||||
key: string
|
key: string
|
||||||
title: string
|
title: string
|
||||||
@@ -72,6 +80,7 @@ export interface ConfigOption {
|
|||||||
choices?: Array<{ id: string; name: string }>
|
choices?: Array<{ id: string; name: string }>
|
||||||
config_key?: string
|
config_key?: string
|
||||||
category: string
|
category: string
|
||||||
|
section?: string
|
||||||
note?: string
|
note?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,6 +190,33 @@ export const PaperlessConfigOptions: ConfigOption[] = [
|
|||||||
config_key: 'PAPERLESS_OCR_USER_ARGS',
|
config_key: 'PAPERLESS_OCR_USER_ARGS',
|
||||||
category: ConfigCategory.OCR,
|
category: ConfigCategory.OCR,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'remote_ocr_engine',
|
||||||
|
title: $localize`Remote OCR Engine`,
|
||||||
|
type: ConfigOptionType.Select,
|
||||||
|
choices: mapToItems(RemoteOCREngineConfig),
|
||||||
|
config_key: 'PAPERLESS_REMOTE_OCR_ENGINE',
|
||||||
|
category: ConfigCategory.OCR,
|
||||||
|
section: ConfigSection.RemoteOCR,
|
||||||
|
note: $localize`Enabling remote OCR sends documents to a third-party service for processing. Consider the privacy implications as well as potential costs before enabling.`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'remote_ocr_api_key',
|
||||||
|
title: $localize`Remote OCR API Key`,
|
||||||
|
type: ConfigOptionType.Password,
|
||||||
|
config_key: 'PAPERLESS_REMOTE_OCR_API_KEY',
|
||||||
|
category: ConfigCategory.OCR,
|
||||||
|
section: ConfigSection.RemoteOCR,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'remote_ocr_endpoint',
|
||||||
|
title: $localize`Remote OCR Endpoint`,
|
||||||
|
type: ConfigOptionType.String,
|
||||||
|
config_key: 'PAPERLESS_REMOTE_OCR_ENDPOINT',
|
||||||
|
category: ConfigCategory.OCR,
|
||||||
|
section: ConfigSection.RemoteOCR,
|
||||||
|
note: $localize`Required when using the Azure AI engine.`,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'app_logo',
|
key: 'app_logo',
|
||||||
title: $localize`Application Logo`,
|
title: $localize`Application Logo`,
|
||||||
@@ -398,6 +434,9 @@ export interface PaperlessConfig extends ObjectWithId {
|
|||||||
barcode_enable_tag: boolean
|
barcode_enable_tag: boolean
|
||||||
barcode_tag_mapping: object
|
barcode_tag_mapping: object
|
||||||
barcode_tag_split: boolean
|
barcode_tag_split: boolean
|
||||||
|
remote_ocr_engine: string
|
||||||
|
remote_ocr_api_key: string
|
||||||
|
remote_ocr_endpoint: string
|
||||||
ai_enabled: boolean
|
ai_enabled: boolean
|
||||||
llm_embedding_backend: string
|
llm_embedding_backend: string
|
||||||
llm_embedding_model: string
|
llm_embedding_model: string
|
||||||
|
|||||||
@@ -3213,13 +3213,6 @@ class WorkflowActionSerializer(serializers.ModelSerializer[WorkflowAction]):
|
|||||||
{"assign_title": f'Invalid f-string detected: "{e.args[0]}"'},
|
{"assign_title": f'Invalid f-string detected: "{e.args[0]}"'},
|
||||||
)
|
)
|
||||||
|
|
||||||
if attrs.get("assign_custom_fields_values"):
|
|
||||||
# Empty strings treated as None to avoid unexpected behavior
|
|
||||||
attrs["assign_custom_fields_values"] = {
|
|
||||||
field_id: (None if value == "" else value)
|
|
||||||
for field_id, value in attrs["assign_custom_fields_values"].items()
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
"type" in attrs
|
"type" in attrs
|
||||||
and attrs["type"] == WorkflowAction.WorkflowActionType.EMAIL
|
and attrs["type"] == WorkflowAction.WorkflowActionType.EMAIL
|
||||||
|
|||||||
@@ -72,6 +72,9 @@ class TestApiAppConfig(DirectoriesMixin, APITestCase):
|
|||||||
"barcode_enable_tag": None,
|
"barcode_enable_tag": None,
|
||||||
"barcode_tag_mapping": None,
|
"barcode_tag_mapping": None,
|
||||||
"barcode_tag_split": None,
|
"barcode_tag_split": None,
|
||||||
|
"remote_ocr_engine": None,
|
||||||
|
"remote_ocr_api_key": None,
|
||||||
|
"remote_ocr_endpoint": None,
|
||||||
"ai_enabled": False,
|
"ai_enabled": False,
|
||||||
"llm_embedding_backend": None,
|
"llm_embedding_backend": None,
|
||||||
"llm_embedding_model": None,
|
"llm_embedding_model": None,
|
||||||
@@ -870,6 +873,49 @@ class TestApiAppConfig(DirectoriesMixin, APITestCase):
|
|||||||
config.refresh_from_db()
|
config.refresh_from_db()
|
||||||
self.assertEqual(config.llm_api_key, None)
|
self.assertEqual(config.llm_api_key, None)
|
||||||
|
|
||||||
|
def test_update_remote_ocr_api_key(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- Existing config with remote_ocr_api_key specified
|
||||||
|
WHEN:
|
||||||
|
- API to update remote_ocr_api_key is called with all *s
|
||||||
|
- API to update remote_ocr_api_key is called with empty string
|
||||||
|
THEN:
|
||||||
|
- remote_ocr_api_key is unchanged
|
||||||
|
- remote_ocr_api_key is set to None
|
||||||
|
"""
|
||||||
|
config = ApplicationConfiguration.objects.first()
|
||||||
|
assert config is not None
|
||||||
|
config.remote_ocr_api_key = "1234567890"
|
||||||
|
config.save()
|
||||||
|
|
||||||
|
# Test with all *
|
||||||
|
response = self.client.patch(
|
||||||
|
f"{self.ENDPOINT}1/",
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"remote_ocr_api_key": "*" * 32,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
config.refresh_from_db()
|
||||||
|
self.assertEqual(config.remote_ocr_api_key, "1234567890")
|
||||||
|
# Test with empty string
|
||||||
|
response = self.client.patch(
|
||||||
|
f"{self.ENDPOINT}1/",
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"remote_ocr_api_key": "",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
config.refresh_from_db()
|
||||||
|
self.assertEqual(config.remote_ocr_api_key, None)
|
||||||
|
|
||||||
def test_enable_ai_index_triggers_update(self) -> None:
|
def test_enable_ai_index_triggers_update(self) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
|
|||||||
@@ -422,11 +422,6 @@ class TestApiWorkflows(DirectoriesMixin, APITestCase):
|
|||||||
json.dumps(
|
json.dumps(
|
||||||
{
|
{
|
||||||
"assign_title": "",
|
"assign_title": "",
|
||||||
"assign_custom_fields": [self.cf1.id, self.cf2.id],
|
|
||||||
"assign_custom_fields_values": {
|
|
||||||
str(self.cf1.id): "",
|
|
||||||
str(self.cf2.id): 0,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
content_type="application/json",
|
content_type="application/json",
|
||||||
@@ -434,10 +429,6 @@ class TestApiWorkflows(DirectoriesMixin, APITestCase):
|
|||||||
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||||
action = WorkflowAction.objects.get(id=response.data["id"])
|
action = WorkflowAction.objects.get(id=response.data["id"])
|
||||||
self.assertIsNone(action.assign_title)
|
self.assertIsNone(action.assign_title)
|
||||||
self.assertEqual(
|
|
||||||
action.assign_custom_fields_values,
|
|
||||||
{str(self.cf1.id): None, str(self.cf2.id): 0},
|
|
||||||
)
|
|
||||||
|
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
self.ENDPOINT_TRIGGERS,
|
self.ENDPOINT_TRIGGERS,
|
||||||
|
|||||||
@@ -2000,55 +2000,6 @@ class TestWorkflows(
|
|||||||
r"Doc added in \w{3,}",
|
r"Doc added in \w{3,}",
|
||||||
) # Match any 3-letter month name
|
) # Match any 3-letter month name
|
||||||
|
|
||||||
def test_document_updated_workflow_existing_custom_field_empty_value(self) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- Existing workflow with UPDATED trigger and action that assigns a custom field
|
|
||||||
with an empty value
|
|
||||||
WHEN:
|
|
||||||
- Document is updated that already contains the field with a value
|
|
||||||
THEN:
|
|
||||||
- The existing value is left untouched, see GH #13627
|
|
||||||
"""
|
|
||||||
trigger = WorkflowTrigger.objects.create(
|
|
||||||
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
|
|
||||||
filter_has_document_type=self.dt,
|
|
||||||
)
|
|
||||||
action = WorkflowAction.objects.create()
|
|
||||||
action.assign_custom_fields.add(self.cf1)
|
|
||||||
action.assign_custom_fields_values = {self.cf1.pk: ""}
|
|
||||||
action.save()
|
|
||||||
w = Workflow.objects.create(
|
|
||||||
name="Workflow 1",
|
|
||||||
order=0,
|
|
||||||
)
|
|
||||||
w.triggers.add(trigger)
|
|
||||||
w.actions.add(action)
|
|
||||||
w.save()
|
|
||||||
|
|
||||||
doc = Document.objects.create(
|
|
||||||
title="sample test",
|
|
||||||
correspondent=self.c,
|
|
||||||
original_filename="sample.pdf",
|
|
||||||
)
|
|
||||||
CustomFieldInstance.objects.create(
|
|
||||||
document=doc,
|
|
||||||
field=self.cf1,
|
|
||||||
value_text="existing value",
|
|
||||||
)
|
|
||||||
|
|
||||||
superuser = User.objects.create_superuser("superuser")
|
|
||||||
self.client.force_authenticate(user=superuser)
|
|
||||||
|
|
||||||
self.client.patch(
|
|
||||||
f"/api/documents/{doc.id}/",
|
|
||||||
{"document_type": self.dt.id},
|
|
||||||
format="json",
|
|
||||||
)
|
|
||||||
|
|
||||||
doc.refresh_from_db()
|
|
||||||
self.assertEqual(doc.custom_fields.get(field=self.cf1).value, "existing value")
|
|
||||||
|
|
||||||
def test_document_updated_workflow_existing_custom_field(self) -> None:
|
def test_document_updated_workflow_existing_custom_field(self) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
|
|||||||
@@ -2267,7 +2267,7 @@ class ChatStreamingView(GenericAPIView[Any]):
|
|||||||
if not has_perms_owner_aware(request.user, "view_document", document):
|
if not has_perms_owner_aware(request.user, "view_document", document):
|
||||||
return HttpResponseForbidden("Insufficient permissions")
|
return HttpResponseForbidden("Insufficient permissions")
|
||||||
|
|
||||||
documents = Document.objects.filter(pk=document.pk)
|
documents = [document]
|
||||||
else:
|
else:
|
||||||
documents = Document.objects.filter(
|
documents = Document.objects.filter(
|
||||||
id__in=permitted_document_ids(request.user),
|
id__in=permitted_document_ids(request.user),
|
||||||
|
|||||||
@@ -105,8 +105,7 @@ def apply_assignment_to_document(
|
|||||||
field=field,
|
field=field,
|
||||||
document=document,
|
document=document,
|
||||||
).first()
|
).first()
|
||||||
# empty string is indistinguishable from no value in the UI
|
if instance and args[value_field_name] is not None:
|
||||||
if instance and args[value_field_name] not in (None, ""):
|
|
||||||
setattr(instance, value_field_name, args[value_field_name])
|
setattr(instance, value_field_name, args[value_field_name])
|
||||||
instance.save()
|
instance.save()
|
||||||
elif not instance:
|
elif not instance:
|
||||||
|
|||||||
@@ -185,6 +185,30 @@ class GeneralConfig(BaseConfig):
|
|||||||
self.app_logo = app_config.app_logo.url if app_config.app_logo else None
|
self.app_logo = app_config.app_logo.url if app_config.app_logo else None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclasses.dataclass
|
||||||
|
class RemoteOCRConfig(BaseConfig):
|
||||||
|
"""
|
||||||
|
Settings for the remote (cloud) OCR parser
|
||||||
|
"""
|
||||||
|
|
||||||
|
remote_ocr_engine: str | None = dataclasses.field(init=False)
|
||||||
|
remote_ocr_api_key: str | None = dataclasses.field(init=False)
|
||||||
|
remote_ocr_endpoint: str | None = dataclasses.field(init=False)
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
app_config = self._get_config_instance()
|
||||||
|
|
||||||
|
self.remote_ocr_engine = (
|
||||||
|
app_config.remote_ocr_engine or settings.REMOTE_OCR_ENGINE
|
||||||
|
)
|
||||||
|
self.remote_ocr_api_key = (
|
||||||
|
app_config.remote_ocr_api_key or settings.REMOTE_OCR_API_KEY
|
||||||
|
)
|
||||||
|
self.remote_ocr_endpoint = (
|
||||||
|
app_config.remote_ocr_endpoint or settings.REMOTE_OCR_ENDPOINT
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@dataclasses.dataclass
|
@dataclasses.dataclass
|
||||||
class AIConfig(BaseConfig):
|
class AIConfig(BaseConfig):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# Generated by Django 5.2.16 on 2026-08-10 14:37
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
from django.db import models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("paperless", "0013_applicationconfiguration_llm_request_timeout"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="applicationconfiguration",
|
||||||
|
name="remote_ocr_api_key",
|
||||||
|
field=models.CharField(
|
||||||
|
blank=True,
|
||||||
|
max_length=1024,
|
||||||
|
null=True,
|
||||||
|
verbose_name="Sets the remote OCR API key",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="applicationconfiguration",
|
||||||
|
name="remote_ocr_endpoint",
|
||||||
|
field=models.CharField(
|
||||||
|
blank=True,
|
||||||
|
max_length=256,
|
||||||
|
null=True,
|
||||||
|
verbose_name="Sets the remote OCR endpoint",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="applicationconfiguration",
|
||||||
|
name="remote_ocr_engine",
|
||||||
|
field=models.CharField(
|
||||||
|
blank=True,
|
||||||
|
choices=[("azureai", "Azure AI Document Intelligence")],
|
||||||
|
max_length=32,
|
||||||
|
null=True,
|
||||||
|
verbose_name="Sets the remote OCR engine",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -74,6 +74,14 @@ class ColorConvertChoices(models.TextChoices):
|
|||||||
CMYK = ("CMYK", _("CMYK"))
|
CMYK = ("CMYK", _("CMYK"))
|
||||||
|
|
||||||
|
|
||||||
|
class RemoteOCREngine(models.TextChoices):
|
||||||
|
"""
|
||||||
|
Matches to PAPERLESS_REMOTE_OCR_ENGINE
|
||||||
|
"""
|
||||||
|
|
||||||
|
AZURE_AI = ("azureai", _("Azure AI Document Intelligence"))
|
||||||
|
|
||||||
|
|
||||||
class LLMEmbeddingBackend(models.TextChoices):
|
class LLMEmbeddingBackend(models.TextChoices):
|
||||||
OPENAI_LIKE = ("openai-like", _("OpenAI-compatible"))
|
OPENAI_LIKE = ("openai-like", _("OpenAI-compatible"))
|
||||||
HUGGINGFACE = ("huggingface", _("Huggingface"))
|
HUGGINGFACE = ("huggingface", _("Huggingface"))
|
||||||
@@ -286,6 +294,35 @@ class ApplicationConfiguration(AbstractSingletonModel):
|
|||||||
null=True,
|
null=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
"""
|
||||||
|
Settings for the remote OCR parser
|
||||||
|
"""
|
||||||
|
|
||||||
|
# PAPERLESS_REMOTE_OCR_ENGINE
|
||||||
|
remote_ocr_engine = models.CharField(
|
||||||
|
verbose_name=_("Sets the remote OCR engine"),
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
max_length=32,
|
||||||
|
choices=RemoteOCREngine.choices,
|
||||||
|
)
|
||||||
|
|
||||||
|
# PAPERLESS_REMOTE_OCR_API_KEY
|
||||||
|
remote_ocr_api_key = models.CharField(
|
||||||
|
verbose_name=_("Sets the remote OCR API key"),
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
max_length=1024,
|
||||||
|
)
|
||||||
|
|
||||||
|
# PAPERLESS_REMOTE_OCR_ENDPOINT
|
||||||
|
remote_ocr_endpoint = models.CharField(
|
||||||
|
verbose_name=_("Sets the remote OCR endpoint"),
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
max_length=256,
|
||||||
|
)
|
||||||
|
|
||||||
"""
|
"""
|
||||||
AI related settings
|
AI related settings
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -3,9 +3,7 @@ Built-in remote-OCR document parser.
|
|||||||
|
|
||||||
Handles documents by sending them to a configured remote OCR engine
|
Handles documents by sending them to a configured remote OCR engine
|
||||||
(currently Azure AI Vision / Document Intelligence) and retrieving both
|
(currently Azure AI Vision / Document Intelligence) and retrieving both
|
||||||
the extracted text and a searchable PDF with an embedded text layer. For
|
the extracted text and a searchable PDF with an embedded text layer.
|
||||||
born-digital PDFs that need no archive copy, the remote call is skipped
|
|
||||||
entirely in favor of locally-extracted text (see ``RemoteDocumentParser.parse``).
|
|
||||||
|
|
||||||
When no engine is configured, ``score()`` returns ``None`` so the parser
|
When no engine is configured, ``score()`` returns ``None`` so the parser
|
||||||
is effectively invisible to the registry — the tesseract parser handles
|
is effectively invisible to the registry — the tesseract parser handles
|
||||||
@@ -24,8 +22,6 @@ from typing import Self
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
|
||||||
from documents.parsers import ParseError
|
from documents.parsers import ParseError
|
||||||
from paperless.parsers.utils import extract_pdf_text
|
|
||||||
from paperless.parsers.utils import post_process_text
|
|
||||||
from paperless.version import __full_version_str__
|
from paperless.version import __full_version_str__
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@@ -61,6 +57,18 @@ class RemoteEngineConfig:
|
|||||||
self.api_key = api_key
|
self.api_key = api_key
|
||||||
self.endpoint = endpoint
|
self.endpoint = endpoint
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_app_config(cls) -> Self:
|
||||||
|
"""Build the config from the app config, falling back to the env."""
|
||||||
|
from paperless.config import RemoteOCRConfig
|
||||||
|
|
||||||
|
app_config = RemoteOCRConfig()
|
||||||
|
return cls(
|
||||||
|
engine=app_config.remote_ocr_engine,
|
||||||
|
api_key=app_config.remote_ocr_api_key,
|
||||||
|
endpoint=app_config.remote_ocr_endpoint,
|
||||||
|
)
|
||||||
|
|
||||||
def engine_is_valid(self) -> bool:
|
def engine_is_valid(self) -> bool:
|
||||||
"""Return True when the engine is known and fully configured."""
|
"""Return True when the engine is known and fully configured."""
|
||||||
return (
|
return (
|
||||||
@@ -74,11 +82,8 @@ class RemoteDocumentParser:
|
|||||||
"""Parse documents via a remote OCR API (currently Azure AI Vision).
|
"""Parse documents via a remote OCR API (currently Azure AI Vision).
|
||||||
|
|
||||||
This parser sends documents to a remote engine that returns both
|
This parser sends documents to a remote engine that returns both
|
||||||
extracted text and a searchable PDF with an embedded text layer,
|
extracted text and a searchable PDF with an embedded text layer.
|
||||||
except when ``parse()`` is called with ``produce_archive=False`` for
|
It does not depend on Tesseract or ocrmypdf.
|
||||||
a PDF, in which case the remote call is skipped and only locally
|
|
||||||
extracted text is returned (no archive). It does not depend on
|
|
||||||
Tesseract or ocrmypdf.
|
|
||||||
|
|
||||||
Class attributes
|
Class attributes
|
||||||
----------------
|
----------------
|
||||||
@@ -145,11 +150,7 @@ class RemoteDocumentParser:
|
|||||||
20 when the remote engine is configured and the MIME type is
|
20 when the remote engine is configured and the MIME type is
|
||||||
supported, otherwise None.
|
supported, otherwise None.
|
||||||
"""
|
"""
|
||||||
config = RemoteEngineConfig(
|
config = RemoteEngineConfig.from_app_config()
|
||||||
engine=settings.REMOTE_OCR_ENGINE,
|
|
||||||
api_key=settings.REMOTE_OCR_API_KEY,
|
|
||||||
endpoint=settings.REMOTE_OCR_ENDPOINT,
|
|
||||||
)
|
|
||||||
if not config.engine_is_valid():
|
if not config.engine_is_valid():
|
||||||
return None
|
return None
|
||||||
if mime_type not in _SUPPORTED_MIME_TYPES:
|
if mime_type not in _SUPPORTED_MIME_TYPES:
|
||||||
@@ -167,11 +168,8 @@ class RemoteDocumentParser:
|
|||||||
Returns
|
Returns
|
||||||
-------
|
-------
|
||||||
bool
|
bool
|
||||||
Always True — the remote engine is capable of returning a PDF
|
Always True — the remote engine always returns a PDF with an
|
||||||
with an embedded text layer to serve as the archive copy.
|
embedded text layer that serves as the archive copy.
|
||||||
Whether it actually does so for a given document depends on
|
|
||||||
``produce_archive`` passed to :meth:`parse` (see there for when
|
|
||||||
the remote engine call, and thus archive generation, is skipped).
|
|
||||||
"""
|
"""
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -228,12 +226,6 @@ class RemoteDocumentParser:
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""Send the document to the remote engine and store results.
|
"""Send the document to the remote engine and store results.
|
||||||
|
|
||||||
When *produce_archive* is False for a PDF, the caller (via
|
|
||||||
``documents.consumer.should_produce_archive``) has already determined
|
|
||||||
that the document is born-digital and needs no archive — skip the
|
|
||||||
remote engine entirely rather than re-OCRing it and creating a
|
|
||||||
duplicate text layer.
|
|
||||||
|
|
||||||
Parameters
|
Parameters
|
||||||
----------
|
----------
|
||||||
document_path:
|
document_path:
|
||||||
@@ -241,14 +233,10 @@ class RemoteDocumentParser:
|
|||||||
mime_type:
|
mime_type:
|
||||||
Detected MIME type of the document.
|
Detected MIME type of the document.
|
||||||
produce_archive:
|
produce_archive:
|
||||||
Whether an archive copy is wanted. For PDFs, False skips the
|
Ignored — the remote engine always returns a searchable PDF,
|
||||||
remote engine and uses locally-extracted text instead.
|
which is stored as the archive copy regardless of this flag.
|
||||||
"""
|
"""
|
||||||
config = RemoteEngineConfig(
|
config = RemoteEngineConfig.from_app_config()
|
||||||
engine=settings.REMOTE_OCR_ENGINE,
|
|
||||||
api_key=settings.REMOTE_OCR_API_KEY,
|
|
||||||
endpoint=settings.REMOTE_OCR_ENDPOINT,
|
|
||||||
)
|
|
||||||
|
|
||||||
if not config.engine_is_valid():
|
if not config.engine_is_valid():
|
||||||
logger.warning(
|
logger.warning(
|
||||||
@@ -257,16 +245,6 @@ class RemoteDocumentParser:
|
|||||||
self._text = ""
|
self._text = ""
|
||||||
return
|
return
|
||||||
|
|
||||||
if not produce_archive and mime_type == "application/pdf":
|
|
||||||
logger.debug(
|
|
||||||
"Remote OCR: skipped — no archive requested, "
|
|
||||||
"using locally-extracted text",
|
|
||||||
)
|
|
||||||
self._text = (
|
|
||||||
post_process_text(extract_pdf_text(document_path, log=logger)) or ""
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
if config.engine == "azureai":
|
if config.engine == "azureai":
|
||||||
self._text = self._azure_ai_vision_parse(document_path, config)
|
self._text = self._azure_ai_vision_parse(document_path, config)
|
||||||
|
|
||||||
|
|||||||
@@ -219,6 +219,13 @@ class ApplicationConfigurationSerializer(
|
|||||||
allow_null=True,
|
allow_null=True,
|
||||||
max_length=1024,
|
max_length=1024,
|
||||||
)
|
)
|
||||||
|
remote_ocr_api_key = ObfuscatedPasswordField(
|
||||||
|
required=False,
|
||||||
|
allow_null=True,
|
||||||
|
max_length=1024,
|
||||||
|
)
|
||||||
|
|
||||||
|
OBFUSCATED_FIELDS = ("llm_api_key", "remote_ocr_api_key")
|
||||||
|
|
||||||
def run_validation(self, data):
|
def run_validation(self, data):
|
||||||
# Empty strings treated as None to avoid unexpected behavior
|
# Empty strings treated as None to avoid unexpected behavior
|
||||||
@@ -230,11 +237,13 @@ class ApplicationConfigurationSerializer(
|
|||||||
data["language"] = None
|
data["language"] = None
|
||||||
if "llm_output_language" in data and data["llm_output_language"] == "":
|
if "llm_output_language" in data and data["llm_output_language"] == "":
|
||||||
data["llm_output_language"] = None
|
data["llm_output_language"] = None
|
||||||
if "llm_api_key" in data and data["llm_api_key"] is not None:
|
for field in self.OBFUSCATED_FIELDS:
|
||||||
if data["llm_api_key"] == "":
|
if field in data and data[field] is not None:
|
||||||
data["llm_api_key"] = None
|
if data[field] == "":
|
||||||
elif len(data["llm_api_key"].replace("*", "")) == 0:
|
data[field] = None
|
||||||
del data["llm_api_key"]
|
# Not a real value, don't overwrite the stored one
|
||||||
|
elif len(data[field].replace("*", "")) == 0:
|
||||||
|
del data[field]
|
||||||
return super().run_validation(data)
|
return super().run_validation(data)
|
||||||
|
|
||||||
def update(self, instance, validated_data):
|
def update(self, instance, validated_data):
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ from unittest.mock import Mock
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from documents.parsers import ParseError
|
from documents.parsers import ParseError
|
||||||
|
from paperless.models import ApplicationConfiguration
|
||||||
from paperless.parsers import ParserContext
|
from paperless.parsers import ParserContext
|
||||||
from paperless.parsers import ParserProtocol
|
from paperless.parsers import ParserProtocol
|
||||||
from paperless.parsers.remote import RemoteDocumentParser
|
from paperless.parsers.remote import RemoteDocumentParser
|
||||||
@@ -33,6 +34,10 @@ if TYPE_CHECKING:
|
|||||||
from pytest_mock import MockerFixture
|
from pytest_mock import MockerFixture
|
||||||
|
|
||||||
|
|
||||||
|
# Remote ocr config from ApplicationConfiguration needs DB access
|
||||||
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Module-local fixtures
|
# Module-local fixtures
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -227,6 +232,18 @@ class TestRemoteParserScore:
|
|||||||
score = RemoteDocumentParser.score("application/pdf", "doc.pdf")
|
score = RemoteDocumentParser.score("application/pdf", "doc.pdf")
|
||||||
assert score is not None and score > 10
|
assert score is not None and score > 10
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("no_engine_settings")
|
||||||
|
def test_score_uses_app_config_when_env_unset(self) -> None:
|
||||||
|
"""The app config alone is enough to activate the parser."""
|
||||||
|
config = ApplicationConfiguration.objects.first()
|
||||||
|
assert config is not None
|
||||||
|
config.remote_ocr_engine = "azureai"
|
||||||
|
config.remote_ocr_api_key = "app-config-key"
|
||||||
|
config.remote_ocr_endpoint = "https://config.cognitiveservices.azure.com"
|
||||||
|
config.save()
|
||||||
|
|
||||||
|
assert RemoteDocumentParser.score("application/pdf", "doc.pdf") == 20
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Properties
|
# Properties
|
||||||
@@ -337,117 +354,6 @@ class TestRemoteParserParse:
|
|||||||
assert remote_parser.get_date() is None
|
assert remote_parser.get_date() is None
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# parse() — produce_archive=False skips the remote engine (PDFs only)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
class TestRemoteParserSkipsWhenNoArchiveWanted:
|
|
||||||
"""When the caller has already decided no archive is needed for a PDF
|
|
||||||
(documents.consumer.should_produce_archive), the remote engine call is
|
|
||||||
skipped entirely in favor of locally-extracted text.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def test_pdf_skips_azure_when_no_archive_requested(
|
|
||||||
self,
|
|
||||||
remote_parser: RemoteDocumentParser,
|
|
||||||
simple_digital_pdf_file: Path,
|
|
||||||
azure_client: Mock,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN: produce_archive=False for a PDF
|
|
||||||
WHEN: parse() is called
|
|
||||||
THEN: Azure is never invoked, no archive is produced, and text
|
|
||||||
comes from local pdftotext extraction
|
|
||||||
"""
|
|
||||||
remote_parser.parse(
|
|
||||||
simple_digital_pdf_file,
|
|
||||||
"application/pdf",
|
|
||||||
produce_archive=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
azure_client.begin_analyze_document.assert_not_called()
|
|
||||||
assert remote_parser.get_archive_path() is None
|
|
||||||
assert remote_parser.get_text() != ""
|
|
||||||
|
|
||||||
def test_pdf_no_archive_requested_text_matches_local_extraction(
|
|
||||||
self,
|
|
||||||
remote_parser: RemoteDocumentParser,
|
|
||||||
simple_digital_pdf_file: Path,
|
|
||||||
azure_client: Mock,
|
|
||||||
mocker: MockerFixture,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN: produce_archive=False for a PDF
|
|
||||||
WHEN: parse() is called
|
|
||||||
THEN: the returned text is exactly the locally-extracted text,
|
|
||||||
not anything from the (unused) Azure mock
|
|
||||||
"""
|
|
||||||
mocker.patch(
|
|
||||||
"paperless.parsers.remote.extract_pdf_text",
|
|
||||||
return_value="Local digital text.",
|
|
||||||
)
|
|
||||||
|
|
||||||
remote_parser.parse(
|
|
||||||
simple_digital_pdf_file,
|
|
||||||
"application/pdf",
|
|
||||||
produce_archive=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert remote_parser.get_text() == "Local digital text."
|
|
||||||
|
|
||||||
def test_pdf_no_archive_requested_closes_no_client(
|
|
||||||
self,
|
|
||||||
remote_parser: RemoteDocumentParser,
|
|
||||||
simple_digital_pdf_file: Path,
|
|
||||||
azure_client: Mock,
|
|
||||||
) -> None:
|
|
||||||
remote_parser.parse(
|
|
||||||
simple_digital_pdf_file,
|
|
||||||
"application/pdf",
|
|
||||||
produce_archive=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
azure_client.close.assert_not_called()
|
|
||||||
|
|
||||||
def test_non_pdf_still_calls_azure_when_no_archive_requested(
|
|
||||||
self,
|
|
||||||
remote_parser: RemoteDocumentParser,
|
|
||||||
simple_digital_pdf_file: Path,
|
|
||||||
azure_client: Mock,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
Images have no local-text fallback, so produce_archive=False does
|
|
||||||
not skip the remote engine for non-PDF MIME types.
|
|
||||||
"""
|
|
||||||
remote_parser.parse(
|
|
||||||
simple_digital_pdf_file,
|
|
||||||
"image/png",
|
|
||||||
produce_archive=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
azure_client.begin_analyze_document.assert_called_once()
|
|
||||||
assert remote_parser.get_text() == _DEFAULT_TEXT
|
|
||||||
|
|
||||||
@pytest.mark.usefixtures("no_engine_settings")
|
|
||||||
def test_unconfigured_engine_takes_precedence_over_skip(
|
|
||||||
self,
|
|
||||||
remote_parser: RemoteDocumentParser,
|
|
||||||
simple_digital_pdf_file: Path,
|
|
||||||
) -> None:
|
|
||||||
"""An unconfigured engine still short-circuits before the
|
|
||||||
produce_archive check, returning empty text as before.
|
|
||||||
"""
|
|
||||||
remote_parser.parse(
|
|
||||||
simple_digital_pdf_file,
|
|
||||||
"application/pdf",
|
|
||||||
produce_archive=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert remote_parser.get_text() == ""
|
|
||||||
assert remote_parser.get_archive_path() is None
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# parse() — Azure failure path
|
# parse() — Azure failure path
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -1277,6 +1277,8 @@ class TestParserFileTypes:
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
# Remote ocr config from ApplicationConfiguration needs DB access
|
||||||
|
@pytest.mark.django_db
|
||||||
class TestRasterisedDocumentParserRegistry:
|
class TestRasterisedDocumentParserRegistry:
|
||||||
def test_registered_in_defaults(self) -> None:
|
def test_registered_in_defaults(self) -> None:
|
||||||
from paperless.parsers.registry import ParserRegistry
|
from paperless.parsers.registry import ParserRegistry
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
"""Tests for RemoteOCRConfig precedence between app config and Django settings."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from django.test import override_settings
|
||||||
|
|
||||||
|
from paperless.config import RemoteOCRConfig
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def null_app_config(mocker) -> MagicMock:
|
||||||
|
"""Mock ApplicationConfiguration with all fields None → falls back to Django settings."""
|
||||||
|
return mocker.MagicMock(
|
||||||
|
remote_ocr_engine=None,
|
||||||
|
remote_ocr_api_key=None,
|
||||||
|
remote_ocr_endpoint=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def make_remote_ocr_config(mocker):
|
||||||
|
def _make(app_config, **django_settings_overrides):
|
||||||
|
mocker.patch(
|
||||||
|
"paperless.config.BaseConfig._get_config_instance",
|
||||||
|
return_value=app_config,
|
||||||
|
)
|
||||||
|
with override_settings(**django_settings_overrides):
|
||||||
|
return RemoteOCRConfig()
|
||||||
|
|
||||||
|
return _make
|
||||||
|
|
||||||
|
|
||||||
|
class TestRemoteOCRConfig:
|
||||||
|
def test_falls_back_to_settings(
|
||||||
|
self,
|
||||||
|
make_remote_ocr_config,
|
||||||
|
null_app_config,
|
||||||
|
) -> None:
|
||||||
|
cfg = make_remote_ocr_config(
|
||||||
|
null_app_config,
|
||||||
|
REMOTE_OCR_ENGINE="azureai",
|
||||||
|
REMOTE_OCR_API_KEY="env-key",
|
||||||
|
REMOTE_OCR_ENDPOINT="https://env.cognitiveservices.azure.com",
|
||||||
|
)
|
||||||
|
assert cfg.remote_ocr_engine == "azureai"
|
||||||
|
assert cfg.remote_ocr_api_key == "env-key"
|
||||||
|
assert cfg.remote_ocr_endpoint == "https://env.cognitiveservices.azure.com"
|
||||||
|
|
||||||
|
def test_app_config_takes_precedence(
|
||||||
|
self,
|
||||||
|
make_remote_ocr_config,
|
||||||
|
mocker,
|
||||||
|
) -> None:
|
||||||
|
app_config = mocker.MagicMock(
|
||||||
|
remote_ocr_engine="azureai",
|
||||||
|
remote_ocr_api_key="db-key",
|
||||||
|
remote_ocr_endpoint="https://db.cognitiveservices.azure.com",
|
||||||
|
)
|
||||||
|
cfg = make_remote_ocr_config(
|
||||||
|
app_config,
|
||||||
|
REMOTE_OCR_ENGINE=None,
|
||||||
|
REMOTE_OCR_API_KEY="env-key",
|
||||||
|
REMOTE_OCR_ENDPOINT="https://env.cognitiveservices.azure.com",
|
||||||
|
)
|
||||||
|
assert cfg.remote_ocr_engine == "azureai"
|
||||||
|
assert cfg.remote_ocr_api_key == "db-key"
|
||||||
|
assert cfg.remote_ocr_endpoint == "https://db.cognitiveservices.azure.com"
|
||||||
|
|
||||||
|
def test_unset_everywhere(
|
||||||
|
self,
|
||||||
|
make_remote_ocr_config,
|
||||||
|
null_app_config,
|
||||||
|
) -> None:
|
||||||
|
cfg = make_remote_ocr_config(
|
||||||
|
null_app_config,
|
||||||
|
REMOTE_OCR_ENGINE=None,
|
||||||
|
REMOTE_OCR_API_KEY=None,
|
||||||
|
REMOTE_OCR_ENDPOINT=None,
|
||||||
|
)
|
||||||
|
assert cfg.remote_ocr_engine is None
|
||||||
|
assert cfg.remote_ocr_api_key is None
|
||||||
|
assert cfg.remote_ocr_endpoint is None
|
||||||
@@ -2,8 +2,6 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
from django.db.models import QuerySet
|
|
||||||
|
|
||||||
from documents.models import Document
|
from documents.models import Document
|
||||||
from paperless.config import AIConfig
|
from paperless.config import AIConfig
|
||||||
from paperless_ai.client import AIClient
|
from paperless_ai.client import AIClient
|
||||||
@@ -84,21 +82,10 @@ def _build_document_reference(
|
|||||||
|
|
||||||
|
|
||||||
def _get_document_references(
|
def _get_document_references(
|
||||||
documents: QuerySet[Document],
|
documents: list[Document],
|
||||||
top_nodes: list,
|
top_nodes: list,
|
||||||
) -> list[dict[str, int | str]]:
|
) -> list[dict[str, int | str]]:
|
||||||
candidate_ids: set[int] = set()
|
allowed_documents = {doc.pk: doc for doc in documents}
|
||||||
for node in top_nodes:
|
|
||||||
try:
|
|
||||||
candidate_ids.add(int(node.metadata["document_id"]))
|
|
||||||
except (KeyError, TypeError, ValueError): # pragma: no cover
|
|
||||||
continue
|
|
||||||
|
|
||||||
if not candidate_ids:
|
|
||||||
return []
|
|
||||||
|
|
||||||
allowed_documents = {doc.pk: doc for doc in documents.filter(pk__in=candidate_ids)}
|
|
||||||
|
|
||||||
references: list[dict[str, int | str]] = []
|
references: list[dict[str, int | str]] = []
|
||||||
seen_document_ids: set[int] = set()
|
seen_document_ids: set[int] = set()
|
||||||
|
|
||||||
@@ -132,7 +119,7 @@ def _format_chat_metadata_trailer(references: list[dict[str, int | str]]) -> str
|
|||||||
|
|
||||||
def stream_chat_with_documents(
|
def stream_chat_with_documents(
|
||||||
query_str: str,
|
query_str: str,
|
||||||
documents: QuerySet[Document],
|
documents: list[Document],
|
||||||
output_language: str | None = None,
|
output_language: str | None = None,
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
@@ -148,10 +135,10 @@ def stream_chat_with_documents(
|
|||||||
|
|
||||||
def _stream_chat_with_documents(
|
def _stream_chat_with_documents(
|
||||||
query_str: str,
|
query_str: str,
|
||||||
documents: QuerySet[Document],
|
documents: list[Document],
|
||||||
output_language: str | None = None,
|
output_language: str | None = None,
|
||||||
):
|
):
|
||||||
if not documents.exists():
|
if not documents:
|
||||||
yield CHAT_NO_CONTENT_MESSAGE
|
yield CHAT_NO_CONTENT_MESSAGE
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -161,9 +148,7 @@ def _stream_chat_with_documents(
|
|||||||
from llama_index.core.retrievers import VectorIndexRetriever
|
from llama_index.core.retrievers import VectorIndexRetriever
|
||||||
|
|
||||||
config = AIConfig()
|
config = AIConfig()
|
||||||
filters = _document_id_filters(
|
filters = _document_id_filters(str(doc.pk) for doc in documents)
|
||||||
str(pk) for pk in documents.values_list("pk", flat=True)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Hold the shared read lock for the whole operation: the query engine
|
# Hold the shared read lock for the whole operation: the query engine
|
||||||
# retrieves from the vector store again during synthesis, so the connection
|
# retrieves from the vector store again during synthesis, so the connection
|
||||||
|
|||||||
@@ -3,12 +3,10 @@ from unittest.mock import MagicMock
|
|||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from django.db.models.signals import post_init
|
|
||||||
from llama_index.core import settings as llama_settings
|
from llama_index.core import settings as llama_settings
|
||||||
from llama_index.core.embeddings.mock_embed_model import MockEmbedding
|
from llama_index.core.embeddings.mock_embed_model import MockEmbedding
|
||||||
from llama_index.core.schema import TextNode
|
from llama_index.core.schema import TextNode
|
||||||
|
|
||||||
from documents.models import Document
|
|
||||||
from documents.tests.factories import DocumentFactory
|
from documents.tests.factories import DocumentFactory
|
||||||
from paperless_ai import chat
|
from paperless_ai import chat
|
||||||
from paperless_ai import indexing
|
from paperless_ai import indexing
|
||||||
@@ -38,6 +36,16 @@ def patch_embed_nodes():
|
|||||||
yield mock_embed_nodes
|
yield mock_embed_nodes
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_document():
|
||||||
|
doc = MagicMock()
|
||||||
|
doc.pk = 1
|
||||||
|
doc.title = "Test Document"
|
||||||
|
doc.filename = "test_file.pdf"
|
||||||
|
doc.content = "This is the document content."
|
||||||
|
return doc
|
||||||
|
|
||||||
|
|
||||||
def assert_chat_output(
|
def assert_chat_output(
|
||||||
output: list[str],
|
output: list[str],
|
||||||
*,
|
*,
|
||||||
@@ -53,13 +61,6 @@ def assert_chat_output(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _fake_documents_queryset(pks: list[int]) -> MagicMock:
|
|
||||||
qs = MagicMock()
|
|
||||||
qs.exists.return_value = bool(pks)
|
|
||||||
qs.values_list.return_value = pks
|
|
||||||
return qs
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
("output_language", "expected_language_line"),
|
("output_language", "expected_language_line"),
|
||||||
[
|
[
|
||||||
@@ -106,10 +107,9 @@ def test_build_refine_prompt(
|
|||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
def test_stream_chat_with_one_document_retrieval(
|
def test_stream_chat_with_one_document_retrieval(
|
||||||
|
mock_document,
|
||||||
patch_embed_nodes,
|
patch_embed_nodes,
|
||||||
) -> None:
|
) -> None:
|
||||||
document = DocumentFactory.create(title="Test Document", content="ignored")
|
|
||||||
documents = Document.objects.filter(pk=document.pk)
|
|
||||||
with (
|
with (
|
||||||
patch("paperless_ai.chat.AIClient") as mock_client_cls,
|
patch("paperless_ai.chat.AIClient") as mock_client_cls,
|
||||||
patch("paperless_ai.chat.load_or_build_index") as mock_load_index,
|
patch("paperless_ai.chat.load_or_build_index") as mock_load_index,
|
||||||
@@ -124,19 +124,22 @@ def test_stream_chat_with_one_document_retrieval(
|
|||||||
mock_client_cls.return_value = mock_client
|
mock_client_cls.return_value = mock_client
|
||||||
mock_client.llm = MagicMock()
|
mock_client.llm = MagicMock()
|
||||||
|
|
||||||
mock_index = MagicMock()
|
mock_node = TextNode(
|
||||||
mock_index.vector_store.get_nodes.return_value = [
|
|
||||||
TextNode(
|
|
||||||
text="This is node content.",
|
text="This is node content.",
|
||||||
metadata={"document_id": str(document.pk), "title": "Test Document"},
|
metadata={"document_id": str(mock_document.pk), "title": "Test Document"},
|
||||||
),
|
)
|
||||||
]
|
mock_index = MagicMock()
|
||||||
|
# Simulate get_nodes returning nodes (content exists)
|
||||||
|
mock_index.vector_store.get_nodes.return_value = [mock_node]
|
||||||
mock_load_index.return_value = mock_index
|
mock_load_index.return_value = mock_index
|
||||||
|
|
||||||
mock_retriever_instance = MagicMock()
|
mock_retriever_instance = MagicMock()
|
||||||
mock_retriever_instance.retrieve.return_value = [
|
mock_retriever_instance.retrieve.return_value = [
|
||||||
MagicMock(
|
MagicMock(
|
||||||
metadata={"document_id": str(document.pk), "title": "Test Document"},
|
metadata={
|
||||||
|
"document_id": str(mock_document.pk),
|
||||||
|
"title": "Test Document",
|
||||||
|
},
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -150,7 +153,7 @@ def test_stream_chat_with_one_document_retrieval(
|
|||||||
"llama_index.core.retrievers.VectorIndexRetriever",
|
"llama_index.core.retrievers.VectorIndexRetriever",
|
||||||
return_value=mock_retriever_instance,
|
return_value=mock_retriever_instance,
|
||||||
):
|
):
|
||||||
output = list(stream_chat_with_documents("What is this?", documents))
|
output = list(stream_chat_with_documents("What is this?", [mock_document]))
|
||||||
|
|
||||||
mock_query_engine.query.assert_called_once_with("What is this?")
|
mock_query_engine.query.assert_called_once_with("What is this?")
|
||||||
synthesizer_kwargs = mock_get_response_synthesizer.call_args.kwargs
|
synthesizer_kwargs = mock_get_response_synthesizer.call_args.kwargs
|
||||||
@@ -163,16 +166,13 @@ def test_stream_chat_with_one_document_retrieval(
|
|||||||
output,
|
output,
|
||||||
expected_chunks=["chunk1", "chunk2"],
|
expected_chunks=["chunk1", "chunk2"],
|
||||||
expected_references=[
|
expected_references=[
|
||||||
{"id": document.pk, "title": "Test Document"},
|
{"id": mock_document.pk, "title": "Test Document"},
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
def test_stream_chat_with_multiple_documents_retrieval(patch_embed_nodes) -> None:
|
def test_stream_chat_with_multiple_documents_retrieval(patch_embed_nodes) -> None:
|
||||||
doc1 = DocumentFactory.create(title="Document 1", content="ignored")
|
|
||||||
doc2 = DocumentFactory.create(title="Document 2", content="ignored")
|
|
||||||
documents = Document.objects.filter(pk__in=[doc1.pk, doc2.pk])
|
|
||||||
with (
|
with (
|
||||||
patch("paperless_ai.chat.AIClient") as mock_client_cls,
|
patch("paperless_ai.chat.AIClient") as mock_client_cls,
|
||||||
patch("paperless_ai.chat.load_or_build_index") as mock_load_index,
|
patch("paperless_ai.chat.load_or_build_index") as mock_load_index,
|
||||||
@@ -184,23 +184,23 @@ def test_stream_chat_with_multiple_documents_retrieval(patch_embed_nodes) -> Non
|
|||||||
mock_client_cls.return_value = mock_client
|
mock_client_cls.return_value = mock_client
|
||||||
mock_client.llm = MagicMock()
|
mock_client.llm = MagicMock()
|
||||||
|
|
||||||
mock_index = MagicMock()
|
mock_node1 = TextNode(
|
||||||
mock_index.vector_store.get_nodes.return_value = [
|
|
||||||
TextNode(
|
|
||||||
text="Content for doc 1.",
|
text="Content for doc 1.",
|
||||||
metadata={"document_id": str(doc1.pk), "title": "Document 1"},
|
metadata={"document_id": "1", "title": "Document 1"},
|
||||||
),
|
)
|
||||||
TextNode(
|
mock_node2 = TextNode(
|
||||||
text="Content for doc 2.",
|
text="Content for doc 2.",
|
||||||
metadata={"document_id": str(doc2.pk), "title": "Document 2"},
|
metadata={"document_id": "2", "title": "Document 2"},
|
||||||
),
|
)
|
||||||
]
|
mock_index = MagicMock()
|
||||||
|
# Simulate get_nodes returning nodes (content exists)
|
||||||
|
mock_index.vector_store.get_nodes.return_value = [mock_node1, mock_node2]
|
||||||
mock_load_index.return_value = mock_index
|
mock_load_index.return_value = mock_index
|
||||||
|
|
||||||
mock_retriever_instance = MagicMock()
|
mock_retriever_instance = MagicMock()
|
||||||
mock_retriever_instance.retrieve.return_value = [
|
mock_retriever_instance.retrieve.return_value = [
|
||||||
MagicMock(metadata={"document_id": str(doc1.pk), "title": "Document 1"}),
|
MagicMock(metadata={"document_id": "1", "title": "Document 1"}),
|
||||||
MagicMock(metadata={"document_id": str(doc2.pk), "title": "Document 2"}),
|
MagicMock(metadata={"document_id": "2", "title": "Document 2"}),
|
||||||
]
|
]
|
||||||
|
|
||||||
mock_response_stream = MagicMock()
|
mock_response_stream = MagicMock()
|
||||||
@@ -210,11 +210,14 @@ def test_stream_chat_with_multiple_documents_retrieval(patch_embed_nodes) -> Non
|
|||||||
mock_query_engine_cls.return_value = mock_query_engine
|
mock_query_engine_cls.return_value = mock_query_engine
|
||||||
mock_query_engine.query.return_value = mock_response_stream
|
mock_query_engine.query.return_value = mock_response_stream
|
||||||
|
|
||||||
|
doc1 = MagicMock(pk=1, title="Document 1", filename="doc1.pdf")
|
||||||
|
doc2 = MagicMock(pk=2, title="Document 2", filename="doc2.pdf")
|
||||||
|
|
||||||
with patch(
|
with patch(
|
||||||
"llama_index.core.retrievers.VectorIndexRetriever",
|
"llama_index.core.retrievers.VectorIndexRetriever",
|
||||||
return_value=mock_retriever_instance,
|
return_value=mock_retriever_instance,
|
||||||
):
|
):
|
||||||
output = list(stream_chat_with_documents("What's up?", documents))
|
output = list(stream_chat_with_documents("What's up?", [doc1, doc2]))
|
||||||
|
|
||||||
mock_query_engine.query.assert_called_once_with("What's up?")
|
mock_query_engine.query.assert_called_once_with("What's up?")
|
||||||
patch_embed_nodes.assert_not_called()
|
patch_embed_nodes.assert_not_called()
|
||||||
@@ -222,15 +225,15 @@ def test_stream_chat_with_multiple_documents_retrieval(patch_embed_nodes) -> Non
|
|||||||
output,
|
output,
|
||||||
expected_chunks=["chunk1", "chunk2"],
|
expected_chunks=["chunk1", "chunk2"],
|
||||||
expected_references=[
|
expected_references=[
|
||||||
{"id": doc1.pk, "title": "Document 1"},
|
{"id": 1, "title": "Document 1"},
|
||||||
{"id": doc2.pk, "title": "Document 2"},
|
{"id": 2, "title": "Document 2"},
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_stream_chat_empty_document_list() -> None:
|
def test_stream_chat_empty_document_list() -> None:
|
||||||
with patch("paperless_ai.chat.load_or_build_index") as mock_load_index:
|
with patch("paperless_ai.chat.load_or_build_index") as mock_load_index:
|
||||||
output = list(stream_chat_with_documents("Any info?", Document.objects.none()))
|
output = list(stream_chat_with_documents("Any info?", []))
|
||||||
mock_load_index.assert_not_called()
|
mock_load_index.assert_not_called()
|
||||||
assert output == ["Sorry, I couldn't find any content to answer your question."]
|
assert output == ["Sorry, I couldn't find any content to answer your question."]
|
||||||
|
|
||||||
@@ -250,9 +253,7 @@ def test_stream_chat_no_matching_nodes() -> None:
|
|||||||
mock_index.vector_store.get_nodes.return_value = []
|
mock_index.vector_store.get_nodes.return_value = []
|
||||||
mock_load_index.return_value = mock_index
|
mock_load_index.return_value = mock_index
|
||||||
|
|
||||||
output = list(
|
output = list(stream_chat_with_documents("Any info?", [MagicMock(pk=1)]))
|
||||||
stream_chat_with_documents("Any info?", _fake_documents_queryset([1])),
|
|
||||||
)
|
|
||||||
|
|
||||||
assert output == ["Sorry, I couldn't find any content to answer your question."]
|
assert output == ["Sorry, I couldn't find any content to answer your question."]
|
||||||
|
|
||||||
@@ -281,9 +282,7 @@ def test_stream_chat_unexpected_failure_returns_generic_error(caplog) -> None:
|
|||||||
)
|
)
|
||||||
mock_retriever_cls.return_value = mock_retriever
|
mock_retriever_cls.return_value = mock_retriever
|
||||||
|
|
||||||
output = list(
|
output = list(stream_chat_with_documents("Any info?", [MagicMock(pk=1)]))
|
||||||
stream_chat_with_documents("Any info?", _fake_documents_queryset([1])),
|
|
||||||
)
|
|
||||||
|
|
||||||
assert output == [CHAT_ERROR_MESSAGE]
|
assert output == [CHAT_ERROR_MESSAGE]
|
||||||
assert "Failed to stream document chat response" in caplog.text
|
assert "Failed to stream document chat response" in caplog.text
|
||||||
@@ -299,12 +298,7 @@ class TestStreamChatRetrieval:
|
|||||||
) -> None:
|
) -> None:
|
||||||
doc = DocumentFactory.create(content="hello world")
|
doc = DocumentFactory.create(content="hello world")
|
||||||
# Nothing indexed for this document yet.
|
# Nothing indexed for this document yet.
|
||||||
out = list(
|
out = list(chat.stream_chat_with_documents("question?", [doc]))
|
||||||
chat.stream_chat_with_documents(
|
|
||||||
"question?",
|
|
||||||
Document.objects.filter(pk=doc.pk),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
assert chat.CHAT_NO_CONTENT_MESSAGE in out
|
assert chat.CHAT_NO_CONTENT_MESSAGE in out
|
||||||
|
|
||||||
def test_chat_filter_contains_only_requested_document_ids(
|
def test_chat_filter_contains_only_requested_document_ids(
|
||||||
@@ -338,12 +332,7 @@ class TestStreamChatRetrieval:
|
|||||||
side_effect=capture_retriever,
|
side_effect=capture_retriever,
|
||||||
)
|
)
|
||||||
|
|
||||||
list(
|
list(chat.stream_chat_with_documents("question?", [included]))
|
||||||
chat.stream_chat_with_documents(
|
|
||||||
"question?",
|
|
||||||
Document.objects.filter(pk=included.pk),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
assert captured_filters, "VectorIndexRetriever was never constructed"
|
assert captured_filters, "VectorIndexRetriever was never constructed"
|
||||||
filt = captured_filters[0]
|
filt = captured_filters[0]
|
||||||
@@ -351,47 +340,3 @@ class TestStreamChatRetrieval:
|
|||||||
filter_values = filt.filters[0].value
|
filter_values = filt.filters[0].value
|
||||||
assert str(included.pk) in filter_values
|
assert str(included.pk) in filter_values
|
||||||
assert str(excluded.pk) not in filter_values
|
assert str(excluded.pk) not in filter_values
|
||||||
|
|
||||||
@pytest.mark.django_db
|
|
||||||
def test_get_document_references_only_queries_referenced_documents(
|
|
||||||
self,
|
|
||||||
django_assert_num_queries,
|
|
||||||
) -> None:
|
|
||||||
"""Building references must not hydrate every document the caller is
|
|
||||||
permitted to see -- only the (<= CHAT_RETRIEVER_TOP_K) documents that
|
|
||||||
the retriever actually returned nodes for.
|
|
||||||
"""
|
|
||||||
referenced = DocumentFactory.create(title="Referenced Document")
|
|
||||||
# Many more documents are "accessible" but never referenced by a node.
|
|
||||||
DocumentFactory.create_batch(200)
|
|
||||||
|
|
||||||
documents = Document.objects.all()
|
|
||||||
top_nodes = [
|
|
||||||
MagicMock(
|
|
||||||
metadata={
|
|
||||||
"document_id": str(referenced.pk),
|
|
||||||
"title": "Referenced Document",
|
|
||||||
},
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
||||||
hydrated_count = 0
|
|
||||||
|
|
||||||
def _count_hydration(sender, instance, **kwargs):
|
|
||||||
nonlocal hydrated_count
|
|
||||||
hydrated_count += 1
|
|
||||||
|
|
||||||
post_init.connect(_count_hydration, sender=Document)
|
|
||||||
try:
|
|
||||||
# One query: `documents.filter(pk__in=candidate_ids)` for the single
|
|
||||||
# referenced id. No query should scale with the 200 unreferenced documents.
|
|
||||||
with django_assert_num_queries(1):
|
|
||||||
references = chat._get_document_references(documents, top_nodes)
|
|
||||||
finally:
|
|
||||||
post_init.disconnect(_count_hydration, sender=Document)
|
|
||||||
|
|
||||||
# The bug this guards against: the old code hydrated all 201 accessible
|
|
||||||
# documents via `{doc.pk: doc for doc in documents}` before filtering by
|
|
||||||
# top_nodes. Only the referenced document should ever be constructed.
|
|
||||||
assert hydrated_count == 1
|
|
||||||
assert references == [{"id": referenced.pk, "title": "Referenced Document"}]
|
|
||||||
|
|||||||
Reference in New Issue
Block a user