mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-08-11 05:13:18 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
28995c6fe0 | ||
|
|
855669ddf9 | ||
|
|
50914f784a | ||
|
|
f4b88cae63 | ||
|
|
2516944c18 |
@@ -948,10 +948,11 @@ for display in the web interface.
|
|||||||
|
|
||||||
!!! note
|
!!! note
|
||||||
|
|
||||||
The **remote OCR parser** (Azure AI) always produces a searchable
|
The **remote OCR parser** (Azure AI) also honors this setting: when
|
||||||
PDF and stores it as the archive copy, regardless of this setting.
|
no archive is requested (`never`, or `auto` with a born-digital PDF),
|
||||||
`ARCHIVE_FILE_GENERATION=never` has no effect when the remote
|
the remote engine is skipped entirely and locally-extracted text is
|
||||||
parser handles a document.
|
used instead, avoiding an unnecessary API call and a duplicate text
|
||||||
|
layer.
|
||||||
|
|
||||||
#### [`PAPERLESS_OCR_CLEAN=<mode>`](#PAPERLESS_OCR_CLEAN) {#PAPERLESS_OCR_CLEAN}
|
#### [`PAPERLESS_OCR_CLEAN=<mode>`](#PAPERLESS_OCR_CLEAN) {#PAPERLESS_OCR_CLEAN}
|
||||||
|
|
||||||
|
|||||||
@@ -187,10 +187,11 @@ PAPERLESS_ARCHIVE_FILE_GENERATION=auto
|
|||||||
|
|
||||||
### Remote OCR parser
|
### Remote OCR parser
|
||||||
|
|
||||||
If you use the **remote OCR parser** (Azure AI), note that it always produces a
|
If you use the **remote OCR parser** (Azure AI), `ARCHIVE_FILE_GENERATION` is
|
||||||
searchable PDF and stores it as the archive copy. `ARCHIVE_FILE_GENERATION=never`
|
honored the same way as for the local engine: when no archive is requested
|
||||||
has no effect for documents handled by the remote parser - the archive is produced
|
(`never`, or `auto` with a born-digital PDF), the remote engine is skipped
|
||||||
unconditionally by the remote engine.
|
entirely and locally-extracted text is used instead, avoiding an unnecessary
|
||||||
|
API call and a duplicate text layer.
|
||||||
|
|
||||||
## Search Index (Whoosh -> Tantivy)
|
## Search Index (Whoosh -> Tantivy)
|
||||||
|
|
||||||
|
|||||||
+3
-1
@@ -576,7 +576,9 @@ 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. Note that no value for the field will be set
|
- Custom fields, optionally with a value. If no value is set, the field is only added to the
|
||||||
|
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}
|
||||||
|
|
||||||
|
|||||||
+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' }
|
component.value = { 1: 'value1', 3: 0, 4: false }
|
||||||
component.selectedFields = [1, 2]
|
component.selectedFields = [1, 2, 3, 4]
|
||||||
expect(component.selectedFields).toEqual([1, 2])
|
expect(component.selectedFields).toEqual([1, 2, 3, 4])
|
||||||
expect(component.value).toEqual({ 1: 'value1', 2: null })
|
expect(component.value).toEqual({ 1: 'value1', 2: null, 3: 0, 4: false })
|
||||||
})
|
})
|
||||||
|
|
||||||
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)
|
||||||
|
|||||||
@@ -3213,6 +3213,13 @@ 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
|
||||||
|
|||||||
@@ -422,6 +422,11 @@ 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",
|
||||||
@@ -429,6 +434,10 @@ 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,6 +2000,55 @@ 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:
|
||||||
|
|||||||
@@ -105,7 +105,8 @@ def apply_assignment_to_document(
|
|||||||
field=field,
|
field=field,
|
||||||
document=document,
|
document=document,
|
||||||
).first()
|
).first()
|
||||||
if instance and args[value_field_name] is not None:
|
# empty string is indistinguishable from no value in the UI
|
||||||
|
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:
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ 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.
|
the extracted text and a searchable PDF with an embedded text layer. For
|
||||||
|
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
|
||||||
@@ -22,6 +24,8 @@ 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:
|
||||||
@@ -70,8 +74,11 @@ 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,
|
||||||
It does not depend on Tesseract or ocrmypdf.
|
except when ``parse()`` is called with ``produce_archive=False`` for
|
||||||
|
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
|
||||||
----------------
|
----------------
|
||||||
@@ -160,8 +167,11 @@ class RemoteDocumentParser:
|
|||||||
Returns
|
Returns
|
||||||
-------
|
-------
|
||||||
bool
|
bool
|
||||||
Always True — the remote engine always returns a PDF with an
|
Always True — the remote engine is capable of returning a PDF
|
||||||
embedded text layer that serves as the archive copy.
|
with an embedded text layer to serve 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
|
||||||
|
|
||||||
@@ -218,6 +228,12 @@ 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:
|
||||||
@@ -225,8 +241,8 @@ class RemoteDocumentParser:
|
|||||||
mime_type:
|
mime_type:
|
||||||
Detected MIME type of the document.
|
Detected MIME type of the document.
|
||||||
produce_archive:
|
produce_archive:
|
||||||
Ignored — the remote engine always returns a searchable PDF,
|
Whether an archive copy is wanted. For PDFs, False skips the
|
||||||
which is stored as the archive copy regardless of this flag.
|
remote engine and uses locally-extracted text instead.
|
||||||
"""
|
"""
|
||||||
config = RemoteEngineConfig(
|
config = RemoteEngineConfig(
|
||||||
engine=settings.REMOTE_OCR_ENGINE,
|
engine=settings.REMOTE_OCR_ENGINE,
|
||||||
@@ -241,6 +257,16 @@ 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)
|
||||||
|
|
||||||
|
|||||||
@@ -337,6 +337,117 @@ 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
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user