Compare commits

..
Author SHA1 Message Date
stumpylog 1f8cf4cd6e Fix: consolidate and extend unicode NFC normalization for filenames and matching
Consolidates the scattered unicodedata.normalize("NFC", ...) calls introduced
by my earlier filename/path normalization work into a single
documents.utils.normalize_unicode() helper, and closes several gaps where
NFD-normalized filenames could still slip through unnormalized:

- Document.get_public_filename() now normalizes, fixing exported filenames
  built from an NFD title/correspondent name (the default, non-format export
  path was not covered by the earlier fix).
- DocumentViewSet.update_version() now normalizes the uploaded filename,
  matching PostDocumentView's existing behavior.
- ConsumerPlugin normalizes self.filename once at consumption time, covering
  the title fallback and Document.original_filename for every document
  source (consume folder, mail, API, barcode splits).
- Workflow trigger and mail rule filename/path matching (documents/matching.py,
  paperless_mail/mail.py) now normalize the document/attachment side before
  comparing, so an NFD filename matches an NFC-typed filter pattern instead of
  silently failing to match.
- WorkflowTriggerSerializer and MailRuleSerializer normalize filter_filename/
  filter_path and the attachment include/exclude patterns once at write time,
  so the read side isn't re-normalizing an already-canonical value on every
  match.
2026-09-08 16:00:16 -07:00
32 changed files with 826 additions and 1400 deletions
+1 -2
View File
@@ -2,7 +2,6 @@ from __future__ import annotations
import logging import logging
import tempfile import tempfile
import uuid
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from typing import Literal from typing import Literal
@@ -380,7 +379,7 @@ def delete(doc_ids: list[int]) -> Literal["OK"]:
) )
delete_ids = list({*doc_ids, *version_ids}) delete_ids = list({*doc_ids, *version_ids})
Document.objects.filter(id__in=delete_ids).delete(transaction_id=uuid.uuid4()) Document.objects.filter(id__in=delete_ids).delete()
from documents.search import get_backend from documents.search import get_backend
+4 -1
View File
@@ -52,6 +52,7 @@ from documents.templating.workflows import parse_w_workflow_placeholders
from documents.utils import compute_checksum from documents.utils import compute_checksum
from documents.utils import copy_basic_file_stats from documents.utils import copy_basic_file_stats
from documents.utils import copy_file_with_basic_stats from documents.utils import copy_file_with_basic_stats
from documents.utils import normalize_unicode
from documents.utils import run_subprocess from documents.utils import run_subprocess
from paperless.config import OcrConfig from paperless.config import OcrConfig
from paperless.config import RemoteOCRConfig from paperless.config import RemoteOCRConfig
@@ -201,7 +202,9 @@ class ConsumerPluginMixin:
self.renew_logging_group() self.renew_logging_group()
self.filename = self.metadata.filename or self.input_doc.original_file.name self.filename = normalize_unicode(
self.metadata.filename or self.input_doc.original_file.name,
)
def _send_progress( def _send_progress(
self, self,
@@ -156,15 +156,6 @@ class FileStabilityTracker:
logger.debug(f"File disappeared during stability check: {path}") logger.debug(f"File disappeared during stability check: {path}")
continue continue
# Stable, but empty: some scanners create a zero byte placeholder
# and only write the page some time later. Consuming it now can
# only fail so drop it and let the writer's next event
# (or the periodic rescan) bring it back once it has content
if not tracked.last_size:
to_remove.append(path)
logger.debug("Ignoring stable but empty file: %s", path)
continue
# File is stable, we can return it # File is stable, we can return it
to_yield.append(path) to_yield.append(path)
logger.info(f"File is stable: {path}") logger.info(f"File is stable: {path}")
+10 -6
View File
@@ -21,6 +21,7 @@ from documents.models import Workflow
from documents.models import WorkflowTrigger from documents.models import WorkflowTrigger
from documents.permissions import permitted_object_ids from documents.permissions import permitted_object_ids
from documents.regex import safe_regex_search from documents.regex import safe_regex_search
from documents.utils import normalize_unicode
if TYPE_CHECKING: if TYPE_CHECKING:
from django.db.models import QuerySet from django.db.models import QuerySet
@@ -311,11 +312,12 @@ def consumable_document_matches_workflow(
trigger_matched = False trigger_matched = False
# Document filename vs trigger filename # Document filename vs trigger filename
document_filename = normalize_unicode(document.original_file.name)
if ( if (
trigger.filter_filename is not None trigger.filter_filename is not None
and len(trigger.filter_filename) > 0 and len(trigger.filter_filename) > 0
and not fnmatch( and not fnmatch(
document.original_file.name.lower(), document_filename.lower(),
trigger.filter_filename.lower(), trigger.filter_filename.lower(),
) )
): ):
@@ -328,10 +330,12 @@ def consumable_document_matches_workflow(
# Document path vs trigger path # Document path vs trigger path
# Use the original_path if set, else us the original_file # Use the original_path if set, else us the original_file
match_against = ( match_against = normalize_unicode(
document.original_path str(
if document.original_path is not None document.original_path
else document.original_file if document.original_path is not None
else document.original_file,
),
) )
if ( if (
@@ -536,7 +540,7 @@ def existing_document_matches_workflow(
and len(trigger.filter_filename) > 0 and len(trigger.filter_filename) > 0
and document.original_filename is not None and document.original_filename is not None
and not fnmatch( and not fnmatch(
document.original_filename.lower(), normalize_unicode(document.original_filename).lower(),
trigger.filter_filename.lower(), trigger.filter_filename.lower(),
) )
): ):
+4 -11
View File
@@ -1,5 +1,4 @@
import datetime import datetime
import uuid
from pathlib import Path from pathlib import Path
from typing import Final from typing import Final
@@ -28,6 +27,7 @@ from django_softdelete.models import SoftDeleteModel
from documents.data_models import DocumentSource from documents.data_models import DocumentSource
from documents.parsers import get_default_file_extension from documents.parsers import get_default_file_extension
from documents.utils import normalize_unicode
class ModelWithOwner(models.Model): class ModelWithOwner(models.Model):
@@ -468,7 +468,7 @@ class Document(SoftDeleteModel, ModelWithOwner): # type: ignore[django-manager-
context_document = ( context_document = (
self.root_document if self.root_document_id is not None else self self.root_document if self.root_document_id is not None else self
) )
result = str(context_document) result = normalize_unicode(str(context_document))
if counter: if counter:
result += f"_{counter:02}" result += f"_{counter:02}"
@@ -515,20 +515,13 @@ class Document(SoftDeleteModel, ModelWithOwner): # type: ignore[django-manager-
def delete( def delete(
self, self,
*args, *args,
transaction_id=None,
**kwargs, **kwargs,
): ):
# Versions must share the root's transaction ID so they are restored # If deleting a root document, move all its versions to trash as well.
# together by django-softdelete.
if transaction_id is None:
transaction_id = uuid.uuid4()
if self.root_document_id is None: if self.root_document_id is None:
Document.objects.filter(root_document=self).delete( Document.objects.filter(root_document=self).delete()
transaction_id=transaction_id,
)
return super().delete( return super().delete(
*args, *args,
transaction_id=transaction_id,
**kwargs, **kwargs,
) )
+8
View File
@@ -87,6 +87,7 @@ from documents.regex import validate_regex_pattern
from documents.templating.filepath import validate_filepath_template_and_render from documents.templating.filepath import validate_filepath_template_and_render
from documents.templating.utils import convert_format_str_to_template_format from documents.templating.utils import convert_format_str_to_template_format
from documents.templating.workflows import validate_workflow_template from documents.templating.workflows import validate_workflow_template
from documents.utils import normalize_unicode
from documents.validators import uri_validator from documents.validators import uri_validator
from documents.validators import url_validator from documents.validators import url_validator
from documents.versioning import sort_versions_newest_first from documents.versioning import sort_versions_newest_first
@@ -3120,6 +3121,13 @@ class WorkflowTriggerSerializer(serializers.ModelSerializer[WorkflowTrigger]):
): ):
attrs["filter_path"] = None attrs["filter_path"] = None
# Normalize once at write time, since these are matched against many
# documents but edited rarely
if attrs.get("filter_filename") is not None:
attrs["filter_filename"] = normalize_unicode(attrs["filter_filename"])
if attrs.get("filter_path") is not None:
attrs["filter_path"] = normalize_unicode(attrs["filter_path"])
if ( if (
"filter_custom_field_query" in attrs "filter_custom_field_query" in attrs
and attrs["filter_custom_field_query"] is not None and attrs["filter_custom_field_query"] is not None
+11 -13
View File
@@ -1,7 +1,6 @@
import logging import logging
import os import os
import re import re
import unicodedata
from collections.abc import Iterable from collections.abc import Iterable
from pathlib import PurePath from pathlib import PurePath
@@ -26,6 +25,7 @@ from documents.templating.environment import _template_environment
from documents.templating.filters import format_datetime from documents.templating.filters import format_datetime
from documents.templating.filters import get_cf_value from documents.templating.filters import get_cf_value
from documents.templating.filters import localize_date from documents.templating.filters import localize_date
from documents.utils import normalize_unicode
logger = logging.getLogger("paperless.templating") logger = logging.getLogger("paperless.templating")
@@ -42,7 +42,7 @@ class FilePathTemplate(Template):
3. Removing extra spaces before and after forward slashes 3. Removing extra spaces before and after forward slashes
4. Preserving spaces in other parts of the path 4. Preserving spaces in other parts of the path
""" """
value = unicodedata.normalize("NFC", value) value = normalize_unicode(value)
value = value.replace("\n", "").replace("\r", "") value = value.replace("\n", "").replace("\r", "")
value = re.sub(r"\s*/\s*", "/", value) value = re.sub(r"\s*/\s*", "/", value)
@@ -184,17 +184,17 @@ def get_basic_metadata_context(
""" """
return { return {
"title": pathvalidate.sanitize_filename( "title": pathvalidate.sanitize_filename(
unicodedata.normalize("NFC", document.title), normalize_unicode(document.title),
replacement_text="-", replacement_text="-",
), ),
"correspondent": pathvalidate.sanitize_filename( "correspondent": pathvalidate.sanitize_filename(
unicodedata.normalize("NFC", document.correspondent.name), normalize_unicode(document.correspondent.name),
replacement_text="-", replacement_text="-",
) )
if document.correspondent if document.correspondent
else no_value_default, else no_value_default,
"document_type": pathvalidate.sanitize_filename( "document_type": pathvalidate.sanitize_filename(
unicodedata.normalize("NFC", document.document_type.name), normalize_unicode(document.document_type.name),
replacement_text="-", replacement_text="-",
) )
if document.document_type if document.document_type
@@ -205,8 +205,7 @@ def get_basic_metadata_context(
"owner_username": document.owner.username "owner_username": document.owner.username
if document.owner if document.owner
else no_value_default, else no_value_default,
"original_name": unicodedata.normalize( "original_name": normalize_unicode(
"NFC",
PurePath(document.original_filename).with_suffix("").name, PurePath(document.original_filename).with_suffix("").name,
) )
if document.original_filename if document.original_filename
@@ -275,12 +274,12 @@ def get_tags_context(tags: Iterable[Tag]) -> dict[str, str | list[str]]:
return { return {
"tag_list": pathvalidate.sanitize_filename( "tag_list": pathvalidate.sanitize_filename(
",".join( ",".join(
sorted(unicodedata.normalize("NFC", tag.name) for tag in tags), sorted(normalize_unicode(tag.name) for tag in tags),
), ),
replacement_text="-", replacement_text="-",
), ),
# Assumed to be ordered, but a template could loop through to find what they want # Assumed to be ordered, but a template could loop through to find what they want
"tag_name_list": [unicodedata.normalize("NFC", x.name) for x in tags], "tag_name_list": [normalize_unicode(x.name) for x in tags],
} }
@@ -307,7 +306,7 @@ def get_custom_fields_context(
CustomField.FieldDataType.LONG_TEXT, CustomField.FieldDataType.LONG_TEXT,
}: }:
value = pathvalidate.sanitize_filename( value = pathvalidate.sanitize_filename(
unicodedata.normalize("NFC", field_instance.value), normalize_unicode(field_instance.value),
replacement_text="-", replacement_text="-",
) )
elif ( elif (
@@ -316,8 +315,7 @@ def get_custom_fields_context(
): ):
options = field_instance.field.extra_data["select_options"] options = field_instance.field.extra_data["select_options"]
value = pathvalidate.sanitize_filename( value = pathvalidate.sanitize_filename(
unicodedata.normalize( normalize_unicode(
"NFC",
next( next(
option["label"] option["label"]
for option in options for option in options
@@ -330,7 +328,7 @@ def get_custom_fields_context(
value = field_instance.value value = field_instance.value
field_data["custom_fields"][ field_data["custom_fields"][
pathvalidate.sanitize_filename( pathvalidate.sanitize_filename(
unicodedata.normalize("NFC", field_instance.field.name), normalize_unicode(field_instance.field.name),
replacement_text="-", replacement_text="-",
) )
] = { ] = {
-62
View File
@@ -207,65 +207,3 @@ class TestTrashAPI(DirectoriesMixin, APITestCase):
) )
self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST) self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn("have not yet been deleted", resp.data["documents"][0]) self.assertIn("have not yet been deleted", resp.data["documents"][0])
def _make_versioned_document(self) -> tuple[Document, list[Document]]:
root = Document.objects.create(
title="root",
content="root-content",
checksum="root",
mime_type="application/pdf",
)
versions = [
Document.objects.create(
title=f"v{index}",
content=f"v{index}-content",
checksum=f"v{index}",
mime_type="application/pdf",
root_document=root,
version_index=index,
)
for index in range(1, 3)
]
return root, versions
def test_api_trash_restore_document_restores_its_versions(self) -> None:
"""
GIVEN:
- Existing document with two versions
WHEN:
- API request to delete the document
- API request to restore it from the trash
THEN:
- Only the document itself is listed in the trash
- A version cannot be restored without its root
- The document is restored together with all of its versions
"""
root, versions = self._make_versioned_document()
self.client.force_login(user=self.user)
self.client.delete(f"/api/documents/{root.pk}/")
self.assertEqual(Document.deleted_objects.count(), 3)
resp = self.client.get("/api/trash/")
self.assertEqual(resp.status_code, status.HTTP_200_OK)
self.assertEqual(resp.data["count"], 1)
self.assertEqual(resp.data["results"][0]["id"], root.pk)
# A version cannot be restored while its root remains in the trash.
resp = self.client.post(
"/api/trash/",
{"action": "restore", "documents": [versions[0].pk]},
)
self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn("Restore the root document", resp.data["documents"][0])
resp = self.client.post(
"/api/trash/",
{"action": "restore", "documents": [root.pk]},
)
self.assertEqual(resp.status_code, status.HTTP_200_OK)
self.assertEqual(Document.deleted_objects.count(), 0)
self.assertCountEqual(
Document.objects.filter(root_document=root).values_list("id", flat=True),
[version.pk for version in versions],
)
@@ -0,0 +1,69 @@
import unicodedata
from typing import TYPE_CHECKING
from unittest import mock
import celery.result
import pytest
from django.core.files.uploadedfile import SimpleUploadedFile
from documents.models import Document
if TYPE_CHECKING:
from documents.data_models import ConsumableDocument
@pytest.fixture()
def consume_file_mock():
with mock.patch("documents.tasks.consume_file.apply_async") as m:
m.return_value = celery.result.AsyncResult(id="test-task-id")
yield m
@pytest.fixture()
def directories(tmp_path, settings, _media_settings):
scratch = tmp_path / "scratch"
scratch.mkdir()
settings.SCRATCH_DIR = scratch
return scratch
@pytest.mark.django_db
class TestUpdateVersionNFCNormalization:
def test_nfd_filename_normalized_to_nfc(
self,
admin_client,
consume_file_mock: mock.MagicMock,
directories,
):
"""Uploaded new-version file with NFD filename must have its temp name stored as NFC."""
document = Document.objects.create(
title="Test",
content="content",
checksum="checksum",
mime_type="application/pdf",
)
nfd = unicodedata.normalize("NFD", "Rechnung März.pdf")
nfc = unicodedata.normalize("NFC", "Rechnung März.pdf")
assert nfd != nfc
uploaded = SimpleUploadedFile(
nfd,
b"%PDF-1.4 test",
content_type="application/pdf",
)
response = admin_client.post(
f"/api/documents/{document.pk}/update_version/",
{"document": uploaded},
)
assert response.status_code == 200
task_kwargs = consume_file_mock.call_args.kwargs["kwargs"]
input_doc: ConsumableDocument = task_kwargs["input_doc"]
assert input_doc.original_file.name == nfc, (
f"Expected NFC filename {nfc!r}, got {input_doc.original_file.name!r}"
)
assert unicodedata.is_normalized("NFC", input_doc.original_file.name)
-5
View File
@@ -392,11 +392,6 @@ class TestBulkEdit(DirectoriesMixin, TestCase):
self.assertFalse(Document.objects.filter(id=self.doc1.id).exists()) self.assertFalse(Document.objects.filter(id=self.doc1.id).exists())
self.assertFalse(Document.objects.filter(id=version.id).exists()) self.assertFalse(Document.objects.filter(id=version.id).exists())
Document.deleted_objects.get(id=self.doc1.id).restore(strict=False)
self.assertTrue(Document.objects.filter(id=self.doc1.id).exists())
self.assertTrue(Document.objects.filter(id=version.id).exists())
def test_delete_version_document_keeps_root(self) -> None: def test_delete_version_document_keeps_root(self) -> None:
version = Document.objects.create( version = Document.objects.create(
checksum="A-v1", checksum="A-v1",
+1 -5
View File
@@ -110,7 +110,7 @@ class TestDocument(TestCase):
checksum="checksum", checksum="checksum",
mime_type="application/pdf", mime_type="application/pdf",
) )
version = Document.objects.create( Document.objects.create(
root_document=root, root_document=root,
correspondent=root.correspondent, correspondent=root.correspondent,
title="Version", title="Version",
@@ -124,10 +124,6 @@ class TestDocument(TestCase):
self.assertEqual(Document.objects.count(), 0) self.assertEqual(Document.objects.count(), 0)
self.assertEqual(Document.deleted_objects.count(), 2) self.assertEqual(Document.deleted_objects.count(), 2)
root.restore(strict=False)
self.assertTrue(Document.objects.filter(pk=version.pk).exists())
def test_file_name(self) -> None: def test_file_name(self) -> None:
doc = Document( doc = Document(
mime_type="application/pdf", mime_type="application/pdf",
@@ -0,0 +1,48 @@
import unicodedata
from datetime import date
import pytest
from documents.models import Correspondent
from documents.models import Document
@pytest.mark.django_db
class TestGetPublicFilenameNfc:
def test_normalizes_nfd_title_to_nfc(self) -> None:
nfd_title = unicodedata.normalize("NFD", "Gehaltserhöhung")
assert not unicodedata.is_normalized("NFC", nfd_title)
doc = Document(
mime_type="application/pdf",
title=nfd_title,
created=date(2025, 10, 17),
)
result = doc.get_public_filename()
assert unicodedata.is_normalized("NFC", result)
assert (
result
== "2025-10-17 "
+ unicodedata.normalize(
"NFC",
nfd_title,
)
+ ".pdf"
)
def test_normalizes_nfd_correspondent_name_to_nfc(self) -> None:
nfd_name = unicodedata.normalize("NFD", "Müller GmbH")
correspondent = Correspondent.objects.create(name=nfd_name)
doc = Document.objects.create(
mime_type="application/pdf",
title="Rechnung",
created=date(2025, 10, 17),
correspondent=correspondent,
)
result = doc.get_public_filename()
assert unicodedata.is_normalized("NFC", result)
@@ -136,23 +136,6 @@ def wait_for_mock_call(
return False return False
def sleep_past_stability(
owner: FileStabilityTracker | ConsumerThread,
*,
windows: float = 1.5,
) -> None:
"""
Block until a tracked file's stability window has certainly elapsed.
Args:
owner: The tracker, or the consumer thread running one, whose
configured stability delay sets the wait.
windows: How many stability windows to wait, giving slop for a slow
or loaded test runner.
"""
sleep(owner.stability_delay * windows)
class TestTrackedFile: class TestTrackedFile:
"""Tests for the TrackedFile dataclass.""" """Tests for the TrackedFile dataclass."""
@@ -278,56 +261,6 @@ class TestFileStabilityTracker:
assert len(stable) == 0 assert len(stable) == 0
assert stability_tracker.pending_count == 1 assert stability_tracker.pending_count == 1
def test_get_stable_files_skips_empty_file(
self,
stability_tracker: FileStabilityTracker,
tmp_path: Path,
) -> None:
"""
GIVEN:
- A zero byte file, tracked and past its stability delay
WHEN:
- Stable files are collected
THEN:
- The file is not yielded for consumption
- The file is dropped from tracking rather than held, so an
abandoned placeholder does not keep the watch loop awake
"""
empty = tmp_path / "scan.pdf"
empty.write_bytes(b"")
stability_tracker.track(empty, Change.added)
sleep_past_stability(stability_tracker)
stable = list(stability_tracker.get_stable_files())
assert stable == []
assert stability_tracker.pending_count == 0
def test_empty_file_is_yielded_once_content_arrives(
self,
stability_tracker: FileStabilityTracker,
tmp_path: Path,
) -> None:
"""
GIVEN:
- A zero byte file which was dropped from tracking while empty
WHEN:
- The writer fills the file and a new event re-tracks it
THEN:
- The file is yielded for consumption once it is stable
"""
target = tmp_path / "scan.pdf"
target.write_bytes(b"")
stability_tracker.track(target, Change.added)
sleep_past_stability(stability_tracker)
assert list(stability_tracker.get_stable_files()) == []
target.write_bytes(b"%PDF-1.4 content")
stability_tracker.track(target, Change.modified)
sleep_past_stability(stability_tracker)
assert list(stability_tracker.get_stable_files()) == [target]
def test_get_stable_files_deleted_during_check(self, temp_file: Path) -> None: def test_get_stable_files_deleted_during_check(self, temp_file: Path) -> None:
"""Test deleted file is not returned during stability check.""" """Test deleted file is not returned during stability check."""
tracker = FileStabilityTracker(stability_delay=0.1) tracker = FileStabilityTracker(stability_delay=0.1)
@@ -946,51 +879,6 @@ class TestCommandWatch:
mock_consume_file_delay.apply_async.assert_called() mock_consume_file_delay.apply_async.assert_called()
def test_scanner_placeholder_is_not_consumed_while_empty(
self,
consumption_dir: Path,
sample_pdf: Path,
mock_consume_file_delay: MagicMock,
start_consumer: Callable[..., ConsumerThread],
) -> None:
"""
GIVEN:
- A scanner which creates a zero byte placeholder and only writes
the page some time later (GH discussion #13969)
WHEN:
- The placeholder sits untouched well past the stability delay
- The scanner then writes the real content
THEN:
- The empty placeholder is never queued, as it could only fail
with "Unsupported mime type inode/x-empty"
- The file is queued exactly once, when the content lands
"""
thread = start_consumer(stability_delay=0.2)
target = consumption_dir / "scan.pdf"
target.write_bytes(b"") # the scanner's placeholder
# Well past the stability delay: the old behaviour queued it here.
sleep_past_stability(thread, windows=5)
if thread.exception:
raise thread.exception
assert mock_consume_file_delay.apply_async.call_count == 0
shutil.copy(sample_pdf, target) # the scanner finishes the page
assert wait_for_mock_call(
mock_consume_file_delay.apply_async,
timeout_s=5.0,
)
if thread.exception:
raise thread.exception
assert mock_consume_file_delay.apply_async.call_count == 1
queued_doc = mock_consume_file_delay.apply_async.call_args.kwargs["kwargs"][
"input_doc"
]
assert queued_doc.original_file.name == "scan.pdf"
def test_ignores_macos_files( def test_ignores_macos_files(
self, self,
consumption_dir: Path, consumption_dir: Path,
+80
View File
@@ -0,0 +1,80 @@
import unicodedata
import pytest
from documents.data_models import ConsumableDocument
from documents.data_models import DocumentSource
from documents.matching import consumable_document_matches_workflow
from documents.matching import existing_document_matches_workflow
from documents.models import Document
from documents.models import Workflow
from documents.models import WorkflowTrigger
@pytest.mark.django_db
class TestMatchingNfcNormalization:
def test_consumable_document_filename_nfd_matches_nfc_pattern(
self,
tmp_path,
) -> None:
"""
GIVEN:
- A file on disk whose name is NFD-normalized
- A workflow trigger filename filter typed as NFC
WHEN:
- The consumable document is checked against the trigger
THEN:
- It matches, because both sides are normalized before comparing
"""
nfd_name = unicodedata.normalize("NFD", "Gehaltserhöhung.pdf")
nfc_pattern = unicodedata.normalize("NFC", "*Gehaltserhöhung*")
assert nfd_name != unicodedata.normalize("NFC", nfd_name)
file_path = tmp_path / nfd_name
file_path.write_bytes(b"%PDF-1.4 test")
document = ConsumableDocument(
source=DocumentSource.ConsumeFolder,
original_file=file_path,
)
trigger = WorkflowTrigger(
type=WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
filter_filename=nfc_pattern,
sources=[],
)
matched, reason = consumable_document_matches_workflow(document, trigger)
assert matched, reason
def test_existing_document_filename_nfd_matches_nfc_pattern(self) -> None:
"""
GIVEN:
- A Document whose original_filename is NFD-normalized (e.g. from
before normalization was applied at consumption time)
- A workflow trigger filename filter typed as NFC
WHEN:
- The document is checked against the trigger
THEN:
- It matches, because both sides are normalized before comparing
"""
nfd_name = unicodedata.normalize("NFD", "Gehaltserhöhung.pdf")
nfc_pattern = unicodedata.normalize("NFC", "*Gehaltserhöhung*")
document = Document.objects.create(
title="Test",
content="content",
checksum="checksum",
mime_type="application/pdf",
original_filename=nfd_name,
)
workflow = Workflow.objects.create(name="Test workflow", order=0)
trigger = WorkflowTrigger.objects.create(
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,
filter_filename=nfc_pattern,
)
workflow.triggers.add(trigger)
matched, reason = existing_document_matches_workflow(document, trigger)
assert matched, reason
@@ -0,0 +1,6 @@
from documents.utils import normalize_unicode
class TestNormalizeUnicode:
def test_none_passes_through(self) -> None:
assert normalize_unicode(None) is None
-33
View File
@@ -32,7 +32,6 @@ from documents.signals.handlers import update_llm_suggestions_cache
from documents.tests.utils import DirectoriesMixin from documents.tests.utils import DirectoriesMixin
from documents.tests.utils import read_streaming_response from documents.tests.utils import read_streaming_response
from paperless.models import ApplicationConfiguration from paperless.models import ApplicationConfiguration
from paperless_ai.exceptions import LLMProviderError
from paperless_ai.exceptions import LLMTimeoutError from paperless_ai.exceptions import LLMTimeoutError
@@ -738,38 +737,6 @@ class TestAISuggestions(DirectoriesMixin, TestCase):
get_llm_suggestion_cache(self.document.pk, backend="openai-like"), get_llm_suggestion_cache(self.document.pk, backend="openai-like"),
) )
@patch("documents.views.get_ai_document_classification")
@override_settings(
AI_ENABLED=True,
LLM_BACKEND="openai-like",
)
def test_ai_suggestions_with_llm_provider_error(
self,
mock_get_ai_classification,
) -> None:
mock_get_ai_classification.side_effect = LLMProviderError(
"confidential provider response",
)
self.client.force_login(user=self.user)
response = self.client.get(
f"/api/documents/{self.document.pk}/ai_suggestions/",
)
self.assertEqual(response.status_code, status.HTTP_502_BAD_GATEWAY)
self.assertEqual(
response.json(),
{
"ai": [
"AI backend rejected the request. Check logs for details.",
],
},
)
self.assertNotIn("confidential provider response", response.content.decode())
self.assertIsNone(
get_llm_suggestion_cache(self.document.pk, backend="openai-like"),
)
@patch("documents.views.get_ai_document_classification") @patch("documents.views.get_ai_document_classification")
@override_settings( @override_settings(
AI_ENABLED=True, AI_ENABLED=True,
+20
View File
@@ -1,6 +1,7 @@
import hashlib import hashlib
import logging import logging
import shutil import shutil
import unicodedata
from collections.abc import Callable from collections.abc import Callable
from collections.abc import Iterable from collections.abc import Iterable
from collections.abc import Iterator from collections.abc import Iterator
@@ -31,6 +32,25 @@ def identity(iterable: Iterable[_T]) -> Iterable[_T]:
return iterable return iterable
def normalize_unicode(value: str | None) -> str | None:
"""
Normalize a string to Unicode NFC form, or return None unchanged.
This is the single normalization pass for any user- or filesystem-supplied
text that ends up in a filename, path, or is compared/matched against one
(titles, correspondent/tag/type names, uploaded filenames, workflow and
mail rule filename/path filters). Composed (NFC) and decomposed (NFD)
forms of the same visible text are different byte sequences, which breaks
exact comparisons and filesystem lookups even though the text looks
identical. Always normalize through this function rather than calling
unicodedata.normalize() directly, so every call site agrees on the same
form.
"""
if value is None:
return None
return unicodedata.normalize("NFC", value)
class QuerySetStream(Generic[_M]): class QuerySetStream(Generic[_M]):
"""Stream a QuerySet via .iterator(chunk_size=...) instead of """Stream a QuerySet via .iterator(chunk_size=...) instead of
materializing it (plus any prefetch caches) all at once, while still materializing it (plus any prefetch caches) all at once, while still
+5 -31
View File
@@ -231,6 +231,7 @@ from documents.tasks import sanity_check
from documents.tasks import train_classifier from documents.tasks import train_classifier
from documents.tasks import update_document_parent_tags from documents.tasks import update_document_parent_tags
from documents.utils import get_boolean from documents.utils import get_boolean
from documents.utils import normalize_unicode
from documents.versioning import VersionResolutionError from documents.versioning import VersionResolutionError
from documents.versioning import annotate_effective_content from documents.versioning import annotate_effective_content
from documents.versioning import get_latest_version_for_root from documents.versioning import get_latest_version_for_root
@@ -252,7 +253,6 @@ from paperless.views import StandardPagination
from paperless_ai.ai_classifier import get_ai_document_classification from paperless_ai.ai_classifier import get_ai_document_classification
from paperless_ai.ai_classifier import get_llm_output_language from paperless_ai.ai_classifier import get_llm_output_language
from paperless_ai.chat import stream_chat_with_documents from paperless_ai.chat import stream_chat_with_documents
from paperless_ai.exceptions import LLMProviderError
from paperless_ai.exceptions import LLMTimeoutError from paperless_ai.exceptions import LLMTimeoutError
from paperless_ai.matching import extract_unmatched_names from paperless_ai.matching import extract_unmatched_names
from paperless_ai.matching import match_correspondents_by_name from paperless_ai.matching import match_correspondents_by_name
@@ -1604,22 +1604,6 @@ class DocumentViewSet(
{"ai": [_("AI backend request timed out.")]}, {"ai": [_("AI backend request timed out.")]},
status=status.HTTP_503_SERVICE_UNAVAILABLE, status=status.HTTP_503_SERVICE_UNAVAILABLE,
) )
except LLMProviderError:
logger.exception(
"AI backend rejected the request for document %s",
doc.pk,
)
return Response(
{
"ai": [
_(
"AI backend rejected the request. "
"Check logs for details.",
),
],
},
status=status.HTTP_502_BAD_GATEWAY,
)
set_llm_suggestions_cache( set_llm_suggestions_cache(
doc.pk, doc.pk,
llm_suggestions, llm_suggestions,
@@ -2085,6 +2069,7 @@ class DocumentViewSet(
try: try:
doc_name, doc_data = serializer.validated_data.get("document") doc_name, doc_data = serializer.validated_data.get("document")
doc_name = normalize_unicode(doc_name)
version_label = serializer.validated_data.get("version_label") version_label = serializer.validated_data.get("version_label")
t = int(mktime(datetime.now().timetuple())) t = int(mktime(datetime.now().timetuple()))
@@ -3351,7 +3336,7 @@ class PostDocumentView(GenericAPIView[Any]):
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
doc_name, doc_data = serializer.validated_data.get("document") doc_name, doc_data = serializer.validated_data.get("document")
doc_name = normalize("NFC", doc_name) doc_name = normalize_unicode(doc_name)
correspondent_id = serializer.validated_data.get("correspondent") correspondent_id = serializer.validated_data.get("correspondent")
document_type_id = serializer.validated_data.get("document_type") document_type_id = serializer.validated_data.get("document_type")
storage_path_id = serializer.validated_data.get("storage_path") storage_path_id = serializer.validated_data.get("storage_path")
@@ -5454,10 +5439,7 @@ class TrashView(ListModelMixin, PassUserMixin):
model = Document model = Document
# A version is listed separately only when its root is not in the trash. queryset = Document.deleted_objects.all()
queryset = Document.deleted_objects.exclude(
root_document_id__in=Document.deleted_objects.values("id"),
)
def get(self, request: Request, format: str | None = None) -> Response: def get(self, request: Request, format: str | None = None) -> Response:
self.serializer_class = DocumentSerializer self.serializer_class = DocumentSerializer
@@ -5488,15 +5470,7 @@ class TrashView(ListModelMixin, PassUserMixin):
return HttpResponseForbidden("Insufficient permissions") return HttpResponseForbidden("Insufficient permissions")
action = serializer.validated_data.get("action") action = serializer.validated_data.get("action")
if action == "restore": if action == "restore":
restored = list(self.get_queryset().filter(id__in=doc_ids)) restored = list(Document.deleted_objects.filter(id__in=doc_ids))
if len(restored) != len(doc_ids):
raise ValidationError(
{
"documents": [
"Restore the root document instead of one of its versions.",
],
},
)
for doc in restored: for doc in restored:
doc.restore(strict=False) doc.restore(strict=False)
if restored: if restored:
File diff suppressed because it is too large Load Diff
+47 -97
View File
@@ -4,24 +4,21 @@ from django.conf import settings
from django.contrib.auth.models import User from django.contrib.auth.models import User
from documents.models import Document from documents.models import Document
from documents.permissions import permitted_object_ids from documents.permissions import get_objects_for_user_owner_aware
from documents.permissions import restrict_queryset_to_visible
from documents.permissions import user_is_unrestricted
from paperless.config import AIConfig from paperless.config import AIConfig
from paperless_ai.base_model import ClassificationSuggestions from paperless_ai.base_model import ClassificationSuggestions
from paperless_ai.base_model import TaxonomyChoiceDict from paperless_ai.base_model import TaxonomyChoiceDict
from paperless_ai.base_model import classification_suggestions_to_model from paperless_ai.base_model import classification_suggestions_to_model
from paperless_ai.client import AIClient from paperless_ai.client import AIClient
from paperless_ai.db import db_connection_released from paperless_ai.db import db_connection_released
from paperless_ai.indexing import _node_document_ids
from paperless_ai.indexing import retrieve_similar_nodes from paperless_ai.indexing import retrieve_similar_nodes
from paperless_ai.indexing import truncate_content from paperless_ai.indexing import truncate_content
from paperless_ai.prompts.context import ClassificationPromptContext from paperless_ai.prompts.context import ClassificationPromptContext
from paperless_ai.prompts.context import LocalizationPromptContext from paperless_ai.prompts.context import LocalizationPromptContext
from paperless_ai.prompts.context import RagContextPromptContext from paperless_ai.prompts.context import RagContextPromptContext
from paperless_ai.prompts.render import render_prompt from paperless_ai.prompts.render import render_prompt
from paperless_ai.taxonomy import SimilarDocument
from paperless_ai.taxonomy import TaxonomyCandidates from paperless_ai.taxonomy import TaxonomyCandidates
from paperless_ai.taxonomy import _node_document_weights
from paperless_ai.taxonomy import build_taxonomy_candidates from paperless_ai.taxonomy import build_taxonomy_candidates
from paperless_ai.taxonomy import empty_taxonomy_candidates from paperless_ai.taxonomy import empty_taxonomy_candidates
from paperless_ai.taxonomy import format_taxonomy_for_prompt from paperless_ai.taxonomy import format_taxonomy_for_prompt
@@ -40,48 +37,6 @@ logger = logging.getLogger("paperless_ai.rag_classifier")
TAXONOMY_CANDIDATE_TOP_K = 15 TAXONOMY_CANDIDATE_TOP_K = 15
def _fulltext_similar_documents(
document: Document,
user: User | None,
top_k: int,
) -> list[SimilarDocument]:
"""Rank-based fallback when no embedding backend is configured. Uses
Tantivy's "More Like This" (term-overlap similarity) instead of vector
similarity - cruder, but far better than no candidates at all.
more_like_this_ids returns only a ranked ID list, no scores, so weight is
synthesized from rank (descending from top_k) rather than claiming a
similarity magnitude that doesn't exist. An unrestricted user (none, or an
active superuser - see user_is_unrestricted) is normalized to ``None``
before calling, since the backend's permission filter has no superuser
short-circuit of its own. Results are re-checked with
restrict_queryset_to_visible() since Tantivy's indexed permission fields
lag the DB via async reindexing.
"""
from documents.search import get_backend
unrestricted = user_is_unrestricted(user)
search_user = None if unrestricted else user
backend = get_backend()
similar_ids = backend.more_like_this_ids(
document.pk,
user=search_user,
limit=top_k,
)
if not unrestricted:
allowed_ids = set(
restrict_queryset_to_visible(
Document.objects.filter(pk__in=similar_ids),
user,
"view_document",
).values_list("pk", flat=True),
)
similar_ids = [doc_id for doc_id in similar_ids if doc_id in allowed_ids]
return [
SimilarDocument(document_id=doc_id, weight=float(top_k - rank))
for rank, doc_id in enumerate(similar_ids)
]
def get_language_name(language_code: str) -> str: def get_language_name(language_code: str) -> str:
normalized_language_code = language_code.lower() normalized_language_code = language_code.lower()
for code, name in settings.LANGUAGES: for code, name in settings.LANGUAGES:
@@ -181,52 +136,43 @@ def get_taxonomy_context(
user: User | None = None, user: User | None = None,
max_docs: int = 5, max_docs: int = 5,
) -> tuple[TaxonomyCandidates, str]: ) -> tuple[TaxonomyCandidates, str]:
"""One retrieval feeds both taxonomy candidates and RAG text context. Uses """One retrieval feeds both taxonomy candidates and RAG text context.
vector similarity when an embedding backend is configured, otherwise On any retrieval failure, degrades to empty candidates/context rather than
falls back to Tantivy full-text "More Like This" similarity - see propagating the exception - a vector-store outage should not block
_fulltext_similar_documents. On any retrieval failure, degrades to empty classification, only its RAG-assisted enrichment.
candidates/context rather than propagating the exception - neither a
vector-store outage nor a search-index issue should block classification,
only its context-assisted enrichment.
""" """
ai_config = AIConfig()
try: try:
if ai_config.llm_embedding_backend: # None means "no restriction" to retrieve_similar_nodes. A superuser
# None means "no restriction" to retrieve_similar_nodes. An # (like no user at all) can see every document, so skip materializing
# unrestricted user (no user at all, or an active superuser -- see # every visible pk into a Python list and passing it through as an IN
# user_is_unrestricted) can see every document, so skip # filter: for a large library that is a wasted quadratic scan in the
# materializing every visible pk into a Python list and passing it # vector store at best, and past ~32,763 documents a hard
# through as an IN filter: for a large library that is a wasted # sqlite3.OperationalError (SQLite's bound-parameter limit) at worst.
# quadratic scan in the vector store at best, and past ~32,763 # get_objects_for_user_owner_aware() would return every Document for a
# documents a hard sqlite3.OperationalError (SQLite's # superuser anyway (guardian's own with_superuser shortcut), so this
# bound-parameter limit) at worst. # changes nothing about which documents are considered -- only how we
# permitted_object_ids() has its own superuser shortcut that would # get there.
# return every Document's id anyway, so this changes nothing about visible_document_ids = (
# which documents are considered -- only how we get there. None
visible_document_ids = ( if user is None or user.is_superuser
None else list(
if user_is_unrestricted(user) get_objects_for_user_owner_aware(
else list(permitted_object_ids(user, Document, "view_document")) user,
) "view_document",
nodes = retrieve_similar_nodes( Document,
document, ).values_list("pk", flat=True),
top_k=TAXONOMY_CANDIDATE_TOP_K,
document_ids=visible_document_ids,
)
similar_documents = _node_document_weights(nodes)
else:
# See _fulltext_similar_documents: it applies its own permission
# filter via `user`, so no visible-document-id list is needed here.
similar_documents = _fulltext_similar_documents(
document,
user,
top_k=TAXONOMY_CANDIDATE_TOP_K,
) )
)
nodes = retrieve_similar_nodes(
document,
top_k=TAXONOMY_CANDIDATE_TOP_K,
document_ids=visible_document_ids,
)
candidates = build_taxonomy_candidates(similar_documents, user) candidates = build_taxonomy_candidates(nodes, user)
# similar_documents is already ordered by descending weight; don't lose it. # ``nodes`` are already ordered by descending vector similarity; don't lose it.
similar_document_ids = [s["document_id"] for s in similar_documents] similar_document_ids = list(dict.fromkeys(_node_document_ids(nodes)))
similar_documents_by_id = Document.objects.in_bulk(similar_document_ids) similar_documents_by_id = Document.objects.in_bulk(similar_document_ids)
similar_docs = [ similar_docs = [
similar_documents_by_id[document_id] similar_documents_by_id[document_id]
@@ -240,8 +186,8 @@ def get_taxonomy_context(
context_blocks.append(f"TITLE: {title}\n{text}") context_blocks.append(f"TITLE: {title}\n{text}")
except Exception: except Exception:
logger.exception( logger.exception(
"Failed to retrieve similar-document context for document %s; " "Failed to retrieve RAG neighbours for document %s; continuing "
"continuing without taxonomy candidates or similar-document context.", "without taxonomy candidates or similar-document context.",
document.pk, document.pk,
) )
return empty_taxonomy_candidates(), "" return empty_taxonomy_candidates(), ""
@@ -295,13 +241,17 @@ def get_ai_document_classification(
) -> ClassificationSuggestions: ) -> ClassificationSuggestions:
ai_config = AIConfig() ai_config = AIConfig()
candidates, context = get_taxonomy_context(document, user) if ai_config.llm_embedding_backend:
prompt = build_prompt_with_rag( candidates, context = get_taxonomy_context(document, user)
document, prompt = build_prompt_with_rag(
ai_config, document,
candidates=candidates, ai_config,
context=context, candidates=candidates,
) context=context,
)
else:
candidates = empty_taxonomy_candidates()
prompt = build_prompt_without_rag(document, ai_config, candidates=candidates)
client = AIClient() client = AIClient()
# Hand the pooled DB connection back while the (slow) LLM query runs so it # Hand the pooled DB connection back while the (slow) LLM query runs so it
+3 -19
View File
@@ -22,7 +22,6 @@ from paperless.network import validate_outbound_http_url
from paperless_ai.base_model import ClassificationSuggestions from paperless_ai.base_model import ClassificationSuggestions
from paperless_ai.base_model import DocumentClassifierSchema from paperless_ai.base_model import DocumentClassifierSchema
from paperless_ai.base_model import model_to_classification_suggestions from paperless_ai.base_model import model_to_classification_suggestions
from paperless_ai.exceptions import LLMProviderError
from paperless_ai.exceptions import LLMTimeoutError from paperless_ai.exceptions import LLMTimeoutError
logger = logging.getLogger("paperless_ai.client") logger = logging.getLogger("paperless_ai.client")
@@ -133,7 +132,7 @@ class AIClient:
from llama_index.core.llms import ChatMessage from llama_index.core.llms import ChatMessage
if self.settings.llm_backend == LLMBackend.OLLAMA: if self.settings.llm_backend == LLMBackend.OLLAMA:
with self._normalize_errors(): with self._normalize_timeouts():
result = self.llm.chat( result = self.llm.chat(
[ChatMessage(role="user", content=prompt)], [ChatMessage(role="user", content=prompt)],
format=DocumentClassifierSchema.model_json_schema(), format=DocumentClassifierSchema.model_json_schema(),
@@ -154,7 +153,7 @@ class AIClient:
content=f"{prompt}\n\n" content=f"{prompt}\n\n"
f"Answer by calling the {tool.metadata.name} tool. Do not write the answer as text.", f"Answer by calling the {tool.metadata.name} tool. Do not write the answer as text.",
) )
with self._normalize_errors(): with self._normalize_timeouts():
result = self.llm.chat_with_tools( result = self.llm.chat_with_tools(
tools=[tool], tools=[tool],
user_msg=user_msg, user_msg=user_msg,
@@ -174,7 +173,7 @@ class AIClient:
) )
@contextmanager @contextmanager
def _normalize_errors(self) -> Iterator[None]: def _normalize_timeouts(self) -> Iterator[None]:
try: try:
yield yield
except httpx.TimeoutException as exc: except httpx.TimeoutException as exc:
@@ -182,23 +181,8 @@ class AIClient:
except Exception as exc: except Exception as exc:
if self._is_openai_timeout(exc): if self._is_openai_timeout(exc):
raise LLMTimeoutError from exc raise LLMTimeoutError from exc
if self._is_provider_error(exc):
raise LLMProviderError from exc
raise raise
def _is_provider_error(self, exc: Exception) -> bool:
if self.settings.llm_backend == LLMBackend.OLLAMA:
from ollama import ResponseError
return isinstance(exc, ResponseError)
if self.settings.llm_backend == LLMBackend.OPENAI_LIKE:
from openai import APIStatusError
return isinstance(exc, APIStatusError)
return False
def _is_openai_timeout(self, exc: Exception) -> bool: def _is_openai_timeout(self, exc: Exception) -> bool:
if self.settings.llm_backend != LLMBackend.OPENAI_LIKE: if self.settings.llm_backend != LLMBackend.OPENAI_LIKE:
return False return False
-4
View File
@@ -1,6 +1,2 @@
class LLMTimeoutError(Exception): class LLMTimeoutError(Exception):
pass pass
class LLMProviderError(Exception):
"""The LLM backend rejected the request."""
+17
View File
@@ -721,3 +721,20 @@ def retrieve_similar_nodes(
continue continue
filtered.append(node) filtered.append(node)
return filtered return filtered
def _node_document_ids(nodes: list["NodeWithScore"]) -> list[int]:
document_ids: list[int] = []
for node in nodes:
document_id = node.metadata.get("document_id")
if document_id is None: # pragma: no cover
# See the matching guard in retrieve_similar_nodes() above.
continue
try:
document_ids.append(int(document_id))
except ValueError: # pragma: no cover
logger.warning(
"Skipping LLM index result with invalid document_id %r.",
document_id,
)
return document_ids
+14 -31
View File
@@ -31,11 +31,6 @@ class TaxonomyCandidate(TypedDict):
weight: float weight: float
class SimilarDocument(TypedDict):
document_id: int
weight: float
class TaxonomyCandidates(TypedDict): class TaxonomyCandidates(TypedDict):
tags: list[TaxonomyCandidate] tags: list[TaxonomyCandidate]
document_types: list[TaxonomyCandidate] document_types: list[TaxonomyCandidate]
@@ -54,10 +49,10 @@ def empty_taxonomy_candidates() -> TaxonomyCandidates:
) )
def _node_document_weights(nodes: list["NodeWithScore"]) -> list[SimilarDocument]: def _node_document_weights(nodes: list["NodeWithScore"]) -> dict[int, float]:
"""Sum each node's similarity score into its document_id (a document can """document_id -> that node's similarity score, summed if a document_id
appear via multiple chunks/nodes) and return one SimilarDocument per appears more than once across the retrieved nodes (e.g. multiple chunks
distinct document_id.""" of the same source document)."""
weights: dict[int, float] = defaultdict(float) weights: dict[int, float] = defaultdict(float)
for node in nodes: for node in nodes:
document_id = node.metadata.get("document_id") document_id = node.metadata.get("document_id")
@@ -70,14 +65,7 @@ def _node_document_weights(nodes: list["NodeWithScore"]) -> list[SimilarDocument
weights[int(document_id)] += float(node.score or 0.0) weights[int(document_id)] += float(node.score or 0.0)
except (TypeError, ValueError): # pragma: no cover except (TypeError, ValueError): # pragma: no cover
continue continue
return sorted( return weights
(
SimilarDocument(document_id=document_id, weight=weight)
for document_id, weight in weights.items()
),
key=lambda similar: similar["weight"],
reverse=True,
)
def _visible_ranked_candidates( def _visible_ranked_candidates(
@@ -113,25 +101,20 @@ def _visible_ranked_candidates(
def build_taxonomy_candidates( def build_taxonomy_candidates(
similar_documents: list[SimilarDocument], nodes: list["NodeWithScore"],
user: User | None, user: User | None,
) -> TaxonomyCandidates: ) -> TaxonomyCandidates:
"""Resolve each similar document's id to a live Document, read its """Resolve each neighbour node's document_id to a live Document, read its
*current* tags/type/correspondent/storage_path via the ORM (never any *current* tags/type/correspondent/storage_path via the ORM (never the
possibly-stale names an adapter's source might have cached), weight each possibly-stale names cached in vector-index node metadata), weight each
distinct taxonomy object by aggregate similarity weight, permission-filter distinct taxonomy object by aggregate neighbour similarity, permission-filter
against what ``user`` can see, and return each category ranked by weight against what ``user`` can see, and return each category ranked by weight
and capped. ``similar_documents`` may come from either the vector-RAG and capped.
adapter or the full-text fallback adapter - both produce this same shape.
""" """
if not similar_documents:
return empty_taxonomy_candidates()
# Both adapters guarantee at most one SimilarDocument per document_id, so document_weights = _node_document_weights(nodes)
# this never silently drops a duplicate's weight. if not document_weights:
document_weights: dict[int, float] = { return empty_taxonomy_candidates()
s["document_id"]: s["weight"] for s in similar_documents
}
# Only .tags.all() needs prefetching (a reverse M2M, one extra query for # Only .tags.all() needs prefetching (a reverse M2M, one extra query for
# the whole batch). document_type/correspondent/storage_path are read # the whole batch). document_type/correspondent/storage_path are read
+22 -306
View File
@@ -1,5 +1,4 @@
import datetime import datetime
from collections.abc import Generator
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import MagicMock from unittest.mock import MagicMock
from unittest.mock import patch from unittest.mock import patch
@@ -7,24 +6,18 @@ from unittest.mock import patch
import pytest import pytest
import pytest_mock import pytest_mock
from django.test import override_settings from django.test import override_settings
from guardian.shortcuts import assign_perm
from guardian.shortcuts import remove_perm
from documents.models import Document from documents.models import Document
from documents.search import TantivyBackend
from documents.tests.factories import DocumentFactory from documents.tests.factories import DocumentFactory
from documents.tests.factories import TagFactory from documents.tests.factories import TagFactory
from documents.tests.factories import UserFactory from documents.tests.factories import UserFactory
from paperless.config import AIConfig from paperless.config import AIConfig
from paperless_ai.ai_classifier import TAXONOMY_CANDIDATE_TOP_K
from paperless_ai.ai_classifier import _fulltext_similar_documents
from paperless_ai.ai_classifier import build_localization_prompt from paperless_ai.ai_classifier import build_localization_prompt
from paperless_ai.ai_classifier import build_prompt_with_rag from paperless_ai.ai_classifier import build_prompt_with_rag
from paperless_ai.ai_classifier import build_prompt_without_rag from paperless_ai.ai_classifier import build_prompt_without_rag
from paperless_ai.ai_classifier import get_ai_document_classification from paperless_ai.ai_classifier import get_ai_document_classification
from paperless_ai.ai_classifier import get_language_name from paperless_ai.ai_classifier import get_language_name
from paperless_ai.ai_classifier import get_taxonomy_context from paperless_ai.ai_classifier import get_taxonomy_context
from paperless_ai.taxonomy import SimilarDocument
from paperless_ai.taxonomy import TaxonomyCandidate from paperless_ai.taxonomy import TaxonomyCandidate
from paperless_ai.taxonomy import TaxonomyCandidates from paperless_ai.taxonomy import TaxonomyCandidates
@@ -227,10 +220,12 @@ def test_use_rag_if_configured(
@pytest.mark.django_db @pytest.mark.django_db
@patch("paperless_ai.client.AIClient.run_llm_query") @patch("paperless_ai.client.AIClient.run_llm_query")
@patch("paperless_ai.ai_classifier.build_prompt_with_rag") @patch("paperless_ai.ai_classifier.build_prompt_without_rag")
@patch("paperless_ai.ai_classifier.AIConfig")
@override_settings(LLM_BACKEND="ollama", LLM_MODEL="some_model") @override_settings(LLM_BACKEND="ollama", LLM_MODEL="some_model")
def test_use_rag_prompt_even_without_embedding_backend( def test_use_without_rag_if_not_configured(
mock_build_prompt_with_rag, mock_ai_config,
mock_build_prompt_without_rag,
mock_run_llm_query, mock_run_llm_query,
mock_document, mock_document,
): ):
@@ -240,13 +235,13 @@ def test_use_rag_prompt_even_without_embedding_backend(
WHEN: WHEN:
- get_ai_document_classification() is called - get_ai_document_classification() is called
THEN: THEN:
- The RAG-context prompt builder is still used (fed by the full-text - The non-RAG prompt builder is used
fallback's context/candidates instead of the vector store's)
""" """
mock_build_prompt_with_rag.return_value = "Prompt with RAG" mock_ai_config.return_value.llm_embedding_backend = None
mock_build_prompt_without_rag.return_value = "Prompt without RAG"
mock_run_llm_query.return_value = NESTED_SUGGESTIONS mock_run_llm_query.return_value = NESTED_SUGGESTIONS
get_ai_document_classification(mock_document) get_ai_document_classification(mock_document)
mock_build_prompt_with_rag.assert_called_once() mock_build_prompt_without_rag.assert_called_once()
@pytest.mark.django_db @pytest.mark.django_db
@@ -325,7 +320,6 @@ def test_build_localization_prompt_preserves_unicode_characters():
@pytest.mark.django_db @pytest.mark.django_db
@override_settings(LLM_EMBEDDING_BACKEND="huggingface")
def test_get_taxonomy_context_assembles_rag_text_and_candidates(): def test_get_taxonomy_context_assembles_rag_text_and_candidates():
""" """
GIVEN: GIVEN:
@@ -360,7 +354,6 @@ def test_get_taxonomy_context_assembles_rag_text_and_candidates():
@pytest.mark.django_db @pytest.mark.django_db
@override_settings(LLM_EMBEDDING_BACKEND="huggingface")
def test_get_taxonomy_context_preserves_similarity_order_and_distinct_documents(): def test_get_taxonomy_context_preserves_similarity_order_and_distinct_documents():
""" """
GIVEN: GIVEN:
@@ -431,7 +424,6 @@ def test_get_taxonomy_context_preserves_similarity_order_and_distinct_documents(
@pytest.mark.django_db @pytest.mark.django_db
@override_settings(LLM_EMBEDDING_BACKEND="huggingface")
def test_get_taxonomy_context_no_similar_docs(): def test_get_taxonomy_context_no_similar_docs():
""" """
GIVEN: GIVEN:
@@ -455,67 +447,6 @@ def test_get_taxonomy_context_no_similar_docs():
} }
@pytest.mark.django_db
def test_get_taxonomy_context_uses_fulltext_fallback_when_no_embedding_backend(
mocker: pytest_mock.MockerFixture,
) -> None:
"""
GIVEN:
- No LLM embedding backend is configured (the default test settings)
WHEN:
- get_taxonomy_context() is called
THEN:
- _fulltext_similar_documents() is called with the document, the user
and TAXONOMY_CANDIDATE_TOP_K
- retrieve_similar_nodes() (the vector path) is never called
"""
document = DocumentFactory.create(content="Some content")
mock_fulltext = mocker.patch(
"paperless_ai.ai_classifier._fulltext_similar_documents",
return_value=[],
)
mock_retrieve = mocker.patch("paperless_ai.ai_classifier.retrieve_similar_nodes")
get_taxonomy_context(document, user=None)
mock_fulltext.assert_called_once_with(
document,
None,
top_k=TAXONOMY_CANDIDATE_TOP_K,
)
mock_retrieve.assert_not_called()
@pytest.mark.django_db
@override_settings(LLM_EMBEDDING_BACKEND="huggingface")
def test_get_taxonomy_context_uses_vector_path_when_embedding_backend_configured(
mocker: pytest_mock.MockerFixture,
) -> None:
"""
GIVEN:
- An LLM embedding backend is configured
WHEN:
- get_taxonomy_context() is called
THEN:
- retrieve_similar_nodes() (the vector path) is called
- _fulltext_similar_documents() (the no-embedding-backend fallback)
is never called
"""
document = DocumentFactory.create(content="Some content")
mock_retrieve = mocker.patch(
"paperless_ai.ai_classifier.retrieve_similar_nodes",
return_value=[],
)
mock_fulltext = mocker.patch(
"paperless_ai.ai_classifier._fulltext_similar_documents",
)
get_taxonomy_context(document, user=None)
mock_retrieve.assert_called_once()
mock_fulltext.assert_not_called()
class TestGetTaxonomyContextVisibility: class TestGetTaxonomyContextVisibility:
"""get_taxonomy_context must not materialize every visible document id """get_taxonomy_context must not materialize every visible document id
for a user who can already see the whole library: a superuser (like no for a user who can already see the whole library: a superuser (like no
@@ -528,7 +459,6 @@ class TestGetTaxonomyContextVisibility:
""" """
@pytest.mark.django_db @pytest.mark.django_db
@override_settings(LLM_EMBEDDING_BACKEND="huggingface")
def test_skips_permission_lookup_for_superuser( def test_skips_permission_lookup_for_superuser(
self, self,
mocker: pytest_mock.MockerFixture, mocker: pytest_mock.MockerFixture,
@@ -547,18 +477,17 @@ class TestGetTaxonomyContextVisibility:
"paperless_ai.ai_classifier.retrieve_similar_nodes", "paperless_ai.ai_classifier.retrieve_similar_nodes",
return_value=[], return_value=[],
) )
mock_permitted = mocker.patch( mock_get_objects = mocker.patch(
"paperless_ai.ai_classifier.permitted_object_ids", "paperless_ai.ai_classifier.get_objects_for_user_owner_aware",
) )
user = UserFactory.create(is_superuser=True) user = UserFactory.create(is_superuser=True)
get_taxonomy_context(document, user) get_taxonomy_context(document, user)
mock_permitted.assert_not_called() mock_get_objects.assert_not_called()
assert mock_retrieve.call_args.kwargs["document_ids"] is None assert mock_retrieve.call_args.kwargs["document_ids"] is None
@pytest.mark.django_db @pytest.mark.django_db
@override_settings(LLM_EMBEDDING_BACKEND="huggingface")
def test_skips_permission_lookup_when_no_user( def test_skips_permission_lookup_when_no_user(
self, self,
mocker: pytest_mock.MockerFixture, mocker: pytest_mock.MockerFixture,
@@ -577,17 +506,16 @@ class TestGetTaxonomyContextVisibility:
"paperless_ai.ai_classifier.retrieve_similar_nodes", "paperless_ai.ai_classifier.retrieve_similar_nodes",
return_value=[], return_value=[],
) )
mock_permitted = mocker.patch( mock_get_objects = mocker.patch(
"paperless_ai.ai_classifier.permitted_object_ids", "paperless_ai.ai_classifier.get_objects_for_user_owner_aware",
) )
get_taxonomy_context(document, None) get_taxonomy_context(document, None)
mock_permitted.assert_not_called() mock_get_objects.assert_not_called()
assert mock_retrieve.call_args.kwargs["document_ids"] is None assert mock_retrieve.call_args.kwargs["document_ids"] is None
@pytest.mark.django_db @pytest.mark.django_db
@override_settings(LLM_EMBEDDING_BACKEND="huggingface")
def test_restricts_to_visible_documents_for_non_superuser( def test_restricts_to_visible_documents_for_non_superuser(
self, self,
mocker: pytest_mock.MockerFixture, mocker: pytest_mock.MockerFixture,
@@ -598,7 +526,7 @@ class TestGetTaxonomyContextVisibility:
WHEN: WHEN:
- get_taxonomy_context() is called - get_taxonomy_context() is called
THEN: THEN:
- The user's permitted document ids are looked up and passed to - The user's visible document ids are looked up and passed to
retrieve_similar_nodes() as a restriction retrieve_similar_nodes() as a restriction
""" """
document = DocumentFactory.create(content="Some content") document = DocumentFactory.create(content="Some content")
@@ -606,232 +534,21 @@ class TestGetTaxonomyContextVisibility:
"paperless_ai.ai_classifier.retrieve_similar_nodes", "paperless_ai.ai_classifier.retrieve_similar_nodes",
return_value=[], return_value=[],
) )
mock_permitted = mocker.patch( mock_queryset = mocker.MagicMock()
"paperless_ai.ai_classifier.permitted_object_ids", mock_queryset.values_list.return_value = [1, 2, 3]
return_value=[1, 2, 3], mock_get_objects = mocker.patch(
"paperless_ai.ai_classifier.get_objects_for_user_owner_aware",
return_value=mock_queryset,
) )
user = UserFactory.create(is_superuser=False) user = UserFactory.create(is_superuser=False)
get_taxonomy_context(document, user) get_taxonomy_context(document, user)
mock_permitted.assert_called_once_with(user, Document, "view_document") mock_get_objects.assert_called_once_with(user, "view_document", Document)
assert mock_retrieve.call_args.kwargs["document_ids"] == [1, 2, 3] assert mock_retrieve.call_args.kwargs["document_ids"] == [1, 2, 3]
@pytest.mark.django_db @pytest.mark.django_db
class TestFulltextSimilarDocuments:
"""_fulltext_similar_documents is the no-embedding-backend fallback: it
asks the Tantivy full-text index for "More Like This" neighbours instead
of the vector store, and synthesizes a rank-based weight since Tantivy's
more_like_this_ids returns only an ordered id list, no scores.
"""
@pytest.fixture
def fulltext_backend(
self,
mocker: pytest_mock.MockerFixture,
) -> Generator[TantivyBackend, None, None]:
"""An in-memory Tantivy backend, wired up as the module-level
singleton _fulltext_similar_documents resolves via get_backend()."""
backend = TantivyBackend(path=None)
backend.open()
mocker.patch("documents.search.get_backend", return_value=backend)
try:
yield backend
finally:
backend.close()
def test_ranks_by_rank_based_weight_descending(
self,
fulltext_backend: TantivyBackend,
) -> None:
"""
GIVEN:
- A source document and two similar documents indexed in Tantivy
WHEN:
- _fulltext_similar_documents() is called
THEN:
- Each result's weight reflects its rank (first result weighted
higher than the second), not a raw similarity score
"""
source = DocumentFactory.create(content="quarterly financial report details")
first = DocumentFactory.create(content="quarterly financial report details")
second = DocumentFactory.create(content="financial report")
for doc in (source, first, second):
fulltext_backend.add_or_update(doc)
result = _fulltext_similar_documents(source, user=None, top_k=5)
assert len(result) == 2
weight_by_id = {s["document_id"]: s["weight"] for s in result}
assert weight_by_id[first.pk] > weight_by_id[second.pk]
def test_excludes_source_document(
self,
fulltext_backend: TantivyBackend,
) -> None:
"""
GIVEN:
- A source document indexed in Tantivy with no other documents
WHEN:
- _fulltext_similar_documents() is called
THEN:
- An empty list is returned - the source document is never its
own similar document
"""
source = DocumentFactory.create(content="unique unrelated content")
fulltext_backend.add_or_update(source)
result = _fulltext_similar_documents(source, user=None, top_k=5)
assert result == []
def test_empty_index_returns_empty_list(
self,
fulltext_backend: TantivyBackend,
) -> None:
"""
GIVEN:
- A document that has never been indexed (fresh/empty Tantivy index)
WHEN:
- _fulltext_similar_documents() is called
THEN:
- An empty list is returned rather than raising
"""
source = DocumentFactory.create(content="never indexed")
result = _fulltext_similar_documents(source, user=None, top_k=5)
assert result == []
def test_respects_top_k_limit(
self,
fulltext_backend: TantivyBackend,
) -> None:
"""
GIVEN:
- A source document and four similar documents indexed
WHEN:
- _fulltext_similar_documents() is called with top_k=2
THEN:
- At most 2 results are returned
"""
source = DocumentFactory.create(content="shared overlapping keyword text")
fulltext_backend.add_or_update(source)
for _ in range(4):
fulltext_backend.add_or_update(
DocumentFactory.create(content="shared overlapping keyword text"),
)
result = _fulltext_similar_documents(source, user=None, top_k=2)
assert len(result) == 2
def test_result_shape_is_similar_document(
self,
fulltext_backend: TantivyBackend,
) -> None:
"""
GIVEN:
- A source document and one similar document indexed
WHEN:
- _fulltext_similar_documents() is called
THEN:
- Each result is a SimilarDocument (document_id + weight only)
"""
source = DocumentFactory.create(content="shared content phrase")
other = DocumentFactory.create(content="shared content phrase")
fulltext_backend.add_or_update(source)
fulltext_backend.add_or_update(other)
result = _fulltext_similar_documents(source, user=None, top_k=5)
# rank 0 (the only/best result) with top_k=5 -> weight = top_k - rank = 5.0,
# per the "first result gets top_k, the last gets 1" formula.
assert result == [SimilarDocument(document_id=other.pk, weight=5.0)]
def test_superuser_sees_other_users_documents(
self,
fulltext_backend: TantivyBackend,
) -> None:
"""
GIVEN:
- A source document owned by one user and a similar document
owned by a different user, with no sharing between them
WHEN:
- _fulltext_similar_documents() is called with a superuser
THEN:
- The other user's document is still returned as a similar
document - a superuser must not be narrowed by the backend's
owner-based permission filter
"""
owner = UserFactory.create()
other_owner = UserFactory.create()
superuser = UserFactory.create(is_superuser=True)
source = DocumentFactory.create(
content="shared content phrase",
owner=owner,
)
other = DocumentFactory.create(
content="shared content phrase",
owner=other_owner,
)
fulltext_backend.add_or_update(source)
fulltext_backend.add_or_update(other)
result = _fulltext_similar_documents(source, user=superuser, top_k=5)
assert [s["document_id"] for s in result] == [other.pk]
def test_excludes_stale_permitted_document_for_regular_user(
self,
fulltext_backend: TantivyBackend,
) -> None:
"""
GIVEN:
- A regular (non-superuser) user
- A similar document the user is permitted to view, and another
similar document indexed while the user still had view
permission but which has since had that permission revoked in
the database, i.e. the Tantivy index has stale permission data
WHEN:
- _fulltext_similar_documents() is called with that user
THEN:
- Only the still-permitted document is returned - the DB
re-check via restrict_queryset_to_visible() must catch the
document Tantivy's stale index still thinks is visible
"""
owner = UserFactory.create()
viewer = UserFactory.create(is_superuser=False)
source = DocumentFactory.create(
content="shared content phrase",
owner=owner,
)
permitted = DocumentFactory.create(
content="shared content phrase",
owner=owner,
)
now_private = DocumentFactory.create(
content="shared content phrase",
owner=owner,
)
assign_perm("view_document", viewer, permitted)
assign_perm("view_document", viewer, now_private)
fulltext_backend.add_or_update(source)
fulltext_backend.add_or_update(permitted)
fulltext_backend.add_or_update(now_private)
# Revoke access after indexing, without reindexing: the index still
# carries viewer as a permitted viewer for `now_private`.
remove_perm("view_document", viewer, now_private)
result = _fulltext_similar_documents(source, user=viewer, top_k=5)
assert [s["document_id"] for s in result] == [permitted.pk]
@pytest.mark.django_db
@override_settings(LLM_EMBEDDING_BACKEND="huggingface")
@patch("paperless_ai.ai_classifier.retrieve_similar_nodes") @patch("paperless_ai.ai_classifier.retrieve_similar_nodes")
def test_get_taxonomy_context_retrieval_failure_degrades_to_no_hints(mock_retrieve): def test_get_taxonomy_context_retrieval_failure_degrades_to_no_hints(mock_retrieve):
""" """
@@ -858,7 +575,6 @@ def test_get_taxonomy_context_retrieval_failure_degrades_to_no_hints(mock_retrie
@pytest.mark.django_db @pytest.mark.django_db
@override_settings(LLM_EMBEDDING_BACKEND="huggingface")
@patch("paperless_ai.ai_classifier.build_taxonomy_candidates") @patch("paperless_ai.ai_classifier.build_taxonomy_candidates")
@patch("paperless_ai.ai_classifier.retrieve_similar_nodes") @patch("paperless_ai.ai_classifier.retrieve_similar_nodes")
def test_get_taxonomy_context_candidate_building_failure_degrades_to_no_hints( def test_get_taxonomy_context_candidate_building_failure_degrades_to_no_hints(
+5 -3
View File
@@ -1188,7 +1188,9 @@ class TestRetrieveSimilarNodesAgainstRealIndex:
nodes = indexing.retrieve_similar_nodes(a, document_ids=[b.id]) nodes = indexing.retrieve_similar_nodes(a, document_ids=[b.id])
assert all(int(node.metadata["document_id"]) == b.id for node in nodes) assert all(
document_id == b.id for document_id in indexing._node_document_ids(nodes)
)
def test_excludes_self( def test_excludes_self(
self, self,
@@ -1210,7 +1212,7 @@ class TestRetrieveSimilarNodesAgainstRealIndex:
nodes = indexing.retrieve_similar_nodes(a, top_k=5) nodes = indexing.retrieve_similar_nodes(a, top_k=5)
assert {int(node.metadata["document_id"]) for node in nodes} == {b.id} assert set(indexing._node_document_ids(nodes)) == {b.id}
def test_excludes_self_with_multiple_chunks( def test_excludes_self_with_multiple_chunks(
self, self,
@@ -1233,4 +1235,4 @@ class TestRetrieveSimilarNodesAgainstRealIndex:
nodes = indexing.retrieve_similar_nodes(a, top_k=3) nodes = indexing.retrieve_similar_nodes(a, top_k=3)
assert {int(node.metadata["document_id"]) for node in nodes} == {b.id} assert set(indexing._node_document_ids(nodes)) == {b.id}
-48
View File
@@ -4,7 +4,6 @@ from unittest.mock import MagicMock
from unittest.mock import patch from unittest.mock import patch
import httpx import httpx
import ollama
import openai import openai
import pytest import pytest
from llama_index.core.llms.llm import ToolSelection from llama_index.core.llms.llm import ToolSelection
@@ -12,7 +11,6 @@ from llama_index.core.llms.llm import ToolSelection
from paperless_ai.client import LLM_SYSTEM_PROMPT from paperless_ai.client import LLM_SYSTEM_PROMPT
from paperless_ai.client import PLACEHOLDER_API_KEY from paperless_ai.client import PLACEHOLDER_API_KEY
from paperless_ai.client import AIClient from paperless_ai.client import AIClient
from paperless_ai.exceptions import LLMProviderError
from paperless_ai.exceptions import LLMTimeoutError from paperless_ai.exceptions import LLMTimeoutError
@@ -216,52 +214,6 @@ def test_run_llm_query_openai_timeout_raises_local_error(
client.run_llm_query("test_prompt") client.run_llm_query("test_prompt")
def test_run_llm_query_openai_status_error_raises_provider_error(
mock_ai_config,
mock_openai_llm,
):
mock_ai_config.llm_backend = "openai-like"
mock_ai_config.llm_model = "test_model"
mock_ai_config.llm_endpoint = "http://test-url"
request = httpx.Request("POST", "http://test-url/v1/chat/completions")
body = {"error": {"message": "Thinking mode does not support this tool_choice"}}
mock_openai_llm.return_value.chat_with_tools.side_effect = openai.BadRequestError(
"Error code: 400",
response=httpx.Response(400, request=request, json=body),
body=body,
)
client = AIClient()
with pytest.raises(LLMProviderError) as exc_info:
client.run_llm_query("test_prompt")
assert str(exc_info.value) == ""
assert isinstance(exc_info.value.__cause__, openai.BadRequestError)
def test_run_llm_query_ollama_response_error_raises_provider_error(
mock_ai_config,
mock_ollama_llm,
):
mock_ai_config.llm_backend = "ollama"
mock_ai_config.llm_model = "test_model"
mock_ai_config.llm_endpoint = "http://test-url"
response_error = ollama.ResponseError(
"confidential provider response",
status_code=400,
)
mock_ollama_llm.return_value.chat.side_effect = response_error
client = AIClient()
with pytest.raises(LLMProviderError) as exc_info:
client.run_llm_query("test_prompt")
assert str(exc_info.value) == ""
assert exc_info.value.__cause__ is response_error
def test_run_llm_query_httpx_timeout_raises_local_error( def test_run_llm_query_httpx_timeout_raises_local_error(
mock_ai_config, mock_ai_config,
mock_ollama_llm, mock_ollama_llm,
+31 -33
View File
@@ -1,4 +1,5 @@
import json import json
from types import SimpleNamespace
import pytest import pytest
import pytest_mock import pytest_mock
@@ -9,14 +10,14 @@ from documents.tests.factories import DocumentTypeFactory
from documents.tests.factories import StoragePathFactory from documents.tests.factories import StoragePathFactory
from documents.tests.factories import TagFactory from documents.tests.factories import TagFactory
from documents.tests.factories import UserFactory from documents.tests.factories import UserFactory
from paperless_ai.taxonomy import SimilarDocument
from paperless_ai.taxonomy import TaxonomyCandidates from paperless_ai.taxonomy import TaxonomyCandidates
from paperless_ai.taxonomy import build_taxonomy_candidates from paperless_ai.taxonomy import build_taxonomy_candidates
from paperless_ai.taxonomy import format_taxonomy_for_prompt from paperless_ai.taxonomy import format_taxonomy_for_prompt
def make_similar(document_id: int, weight: float) -> SimilarDocument: def make_node(document_id: int, score: float) -> SimpleNamespace:
return SimilarDocument(document_id=document_id, weight=weight) """A stand-in for NodeWithScore: only ``.metadata``/``.score`` are read."""
return SimpleNamespace(metadata={"document_id": str(document_id)}, score=score)
@pytest.mark.django_db @pytest.mark.django_db
@@ -52,9 +53,9 @@ class TestBuildTaxonomyCandidates:
doc_a.tags.add(tag) doc_a.tags.add(tag)
doc_b = DocumentFactory.create() doc_b = DocumentFactory.create()
doc_b.tags.add(tag) doc_b.tags.add(tag)
similar_documents = [make_similar(doc_a.pk, 0.9), make_similar(doc_b.pk, 0.4)] nodes = [make_node(doc_a.pk, 0.9), make_node(doc_b.pk, 0.4)]
result = build_taxonomy_candidates(similar_documents, user=None) result = build_taxonomy_candidates(nodes, user=None)
assert len(result["tags"]) == 1 assert len(result["tags"]) == 1
assert result["tags"][0]["id"] == tag.pk assert result["tags"][0]["id"] == tag.pk
@@ -79,9 +80,9 @@ class TestBuildTaxonomyCandidates:
document.tags.add(tag) document.tags.add(tag)
tag.name = "New Name" tag.name = "New Name"
tag.save() tag.save()
similar_documents = [make_similar(document.pk, 0.5)] nodes = [make_node(document.pk, 0.5)]
result = build_taxonomy_candidates(similar_documents, user=None) result = build_taxonomy_candidates(nodes, user=None)
assert result["tags"][0]["name"] == "New Name" assert result["tags"][0]["name"] == "New Name"
@@ -101,9 +102,9 @@ class TestBuildTaxonomyCandidates:
document = DocumentFactory.create() document = DocumentFactory.create()
document.tags.add(tag) document.tags.add(tag)
tag.delete() tag.delete()
similar_documents = [make_similar(document.pk, 0.5)] nodes = [make_node(document.pk, 0.5)]
result = build_taxonomy_candidates(similar_documents, user=None) result = build_taxonomy_candidates(nodes, user=None)
assert result["tags"] == [] assert result["tags"] == []
@@ -122,12 +123,9 @@ class TestBuildTaxonomyCandidates:
strong_doc.tags.add(strong_tag) strong_doc.tags.add(strong_tag)
weak_doc = DocumentFactory.create() weak_doc = DocumentFactory.create()
weak_doc.tags.add(weak_tag) weak_doc.tags.add(weak_tag)
similar_documents = [ nodes = [make_node(strong_doc.pk, 0.9), make_node(weak_doc.pk, 0.1)]
make_similar(strong_doc.pk, 0.9),
make_similar(weak_doc.pk, 0.1),
]
result = build_taxonomy_candidates(similar_documents, user=None) result = build_taxonomy_candidates(nodes, user=None)
assert [c["name"] for c in result["tags"]] == ["Strong", "Weak"] assert [c["name"] for c in result["tags"]] == ["Strong", "Weak"]
@@ -143,9 +141,9 @@ class TestBuildTaxonomyCandidates:
document = DocumentFactory.create() document = DocumentFactory.create()
for i in range(15): for i in range(15):
document.tags.add(TagFactory.create(name=f"Tag{i}")) document.tags.add(TagFactory.create(name=f"Tag{i}"))
similar_documents = [make_similar(document.pk, 0.5)] nodes = [make_node(document.pk, 0.5)]
result = build_taxonomy_candidates(similar_documents, user=None) result = build_taxonomy_candidates(nodes, user=None)
assert len(result["tags"]) == 10 assert len(result["tags"]) == 10
@@ -159,12 +157,12 @@ class TestBuildTaxonomyCandidates:
- Only 5 correspondents are returned - Only 5 correspondents are returned
""" """
correspondents = CorrespondentFactory.create_batch(7) correspondents = CorrespondentFactory.create_batch(7)
similar_documents = [ nodes = [
make_similar(DocumentFactory.create(correspondent=c).pk, 0.5) make_node(DocumentFactory.create(correspondent=c).pk, 0.5)
for c in correspondents for c in correspondents
] ]
result = build_taxonomy_candidates(similar_documents, user=None) result = build_taxonomy_candidates(nodes, user=None)
assert len(result["correspondents"]) == 5 assert len(result["correspondents"]) == 5
@@ -179,9 +177,9 @@ class TestBuildTaxonomyCandidates:
""" """
document_type = DocumentTypeFactory.create(name="Invoice") document_type = DocumentTypeFactory.create(name="Invoice")
document = DocumentFactory.create(document_type=document_type) document = DocumentFactory.create(document_type=document_type)
similar_documents = [make_similar(document.pk, 0.5)] nodes = [make_node(document.pk, 0.5)]
result = build_taxonomy_candidates(similar_documents, user=None) result = build_taxonomy_candidates(nodes, user=None)
assert len(result["document_types"]) == 1 assert len(result["document_types"]) == 1
assert result["document_types"][0]["id"] == document_type.pk assert result["document_types"][0]["id"] == document_type.pk
@@ -197,12 +195,12 @@ class TestBuildTaxonomyCandidates:
- Only 5 document_types are returned - Only 5 document_types are returned
""" """
document_types = DocumentTypeFactory.create_batch(7) document_types = DocumentTypeFactory.create_batch(7)
similar_documents = [ nodes = [
make_similar(DocumentFactory.create(document_type=dt).pk, 0.5) make_node(DocumentFactory.create(document_type=dt).pk, 0.5)
for dt in document_types for dt in document_types
] ]
result = build_taxonomy_candidates(similar_documents, user=None) result = build_taxonomy_candidates(nodes, user=None)
assert len(result["document_types"]) == 5 assert len(result["document_types"]) == 5
@@ -217,9 +215,9 @@ class TestBuildTaxonomyCandidates:
""" """
storage_path = StoragePathFactory.create(name="Invoices") storage_path = StoragePathFactory.create(name="Invoices")
document = DocumentFactory.create(storage_path=storage_path) document = DocumentFactory.create(storage_path=storage_path)
similar_documents = [make_similar(document.pk, 0.5)] nodes = [make_node(document.pk, 0.5)]
result = build_taxonomy_candidates(similar_documents, user=None) result = build_taxonomy_candidates(nodes, user=None)
assert len(result["storage_paths"]) == 1 assert len(result["storage_paths"]) == 1
assert result["storage_paths"][0]["id"] == storage_path.pk assert result["storage_paths"][0]["id"] == storage_path.pk
@@ -235,12 +233,12 @@ class TestBuildTaxonomyCandidates:
- Only 5 storage_paths are returned - Only 5 storage_paths are returned
""" """
storage_paths = StoragePathFactory.create_batch(7) storage_paths = StoragePathFactory.create_batch(7)
similar_documents = [ nodes = [
make_similar(DocumentFactory.create(storage_path=sp).pk, 0.5) make_node(DocumentFactory.create(storage_path=sp).pk, 0.5)
for sp in storage_paths for sp in storage_paths
] ]
result = build_taxonomy_candidates(similar_documents, user=None) result = build_taxonomy_candidates(nodes, user=None)
assert len(result["storage_paths"]) == 5 assert len(result["storage_paths"]) == 5
@@ -260,14 +258,14 @@ class TestBuildTaxonomyCandidates:
tag = TagFactory.create(name="Restricted") tag = TagFactory.create(name="Restricted")
document = DocumentFactory.create() document = DocumentFactory.create()
document.tags.add(tag) document.tags.add(tag)
similar_documents = [make_similar(document.pk, 0.5)] nodes = [make_node(document.pk, 0.5)]
user = UserFactory.create() user = UserFactory.create()
mocker.patch( mocker.patch(
"documents.permissions.permitted_object_ids", "documents.permissions.permitted_object_ids",
return_value=[], # user cannot see this tag return_value=[], # user cannot see this tag
) )
result = build_taxonomy_candidates(similar_documents, user=user) result = build_taxonomy_candidates(nodes, user=user)
assert result["tags"] == [] assert result["tags"] == []
@@ -297,10 +295,10 @@ class TestBuildTaxonomyCandidates:
tag.save() tag.save()
document = DocumentFactory.create() document = DocumentFactory.create()
document.tags.add(tag) document.tags.add(tag)
similar_documents = [make_similar(document.pk, 0.5)] nodes = [make_node(document.pk, 0.5)]
spy = mocker.patch("documents.permissions.permitted_object_ids") spy = mocker.patch("documents.permissions.permitted_object_ids")
result = build_taxonomy_candidates(similar_documents, user=None) result = build_taxonomy_candidates(nodes, user=None)
assert result["tags"][0]["name"] == "Owned" assert result["tags"][0]["name"] == "Owned"
spy.assert_not_called() spy.assert_not_called()
+9 -7
View File
@@ -6,7 +6,6 @@ import socket
import ssl import ssl
import tempfile import tempfile
import traceback import traceback
import unicodedata
from datetime import date from datetime import date
from datetime import timedelta from datetime import timedelta
from fnmatch import fnmatch from fnmatch import fnmatch
@@ -45,6 +44,7 @@ from documents.models import Correspondent
from documents.models import PaperlessTask from documents.models import PaperlessTask
from documents.parsers import is_mime_type_supported from documents.parsers import is_mime_type_supported
from documents.tasks import consume_file from documents.tasks import consume_file
from documents.utils import normalize_unicode
from paperless.network import is_public_ip from paperless.network import is_public_ip
from paperless.network import resolve_hostname_ips from paperless.network import resolve_hostname_ips
from paperless_mail.models import MailAccount from paperless_mail.models import MailAccount
@@ -617,10 +617,10 @@ class MailAccountHandler(LoggingMixin):
rule: MailRule, rule: MailRule,
) -> str | None: ) -> str | None:
if rule.assign_title_from == MailRule.TitleSource.FROM_SUBJECT: if rule.assign_title_from == MailRule.TitleSource.FROM_SUBJECT:
return unicodedata.normalize("NFC", message.subject) return normalize_unicode(message.subject)
elif rule.assign_title_from == MailRule.TitleSource.FROM_FILENAME: elif rule.assign_title_from == MailRule.TitleSource.FROM_FILENAME:
return unicodedata.normalize("NFC", Path(att.filename).stem) return normalize_unicode(Path(att.filename).stem)
elif rule.assign_title_from == MailRule.TitleSource.NONE: elif rule.assign_title_from == MailRule.TitleSource.NONE:
return None return None
@@ -1004,6 +1004,8 @@ class MailAccountHandler(LoggingMixin):
consume_tasks = [] consume_tasks = []
for att in message.attachments: for att in message.attachments:
attachment_filename = normalize_unicode(att.filename)
if ( if (
att.content_disposition != "attachment" att.content_disposition != "attachment"
and rule.attachment_type and rule.attachment_type
@@ -1018,7 +1020,7 @@ class MailAccountHandler(LoggingMixin):
if not self.filename_inclusion_matches( if not self.filename_inclusion_matches(
rule.filter_attachment_filename_include, rule.filter_attachment_filename_include,
att.filename, attachment_filename,
): ):
# Force the filename and pattern to the lowercase # Force the filename and pattern to the lowercase
# as this is system dependent otherwise # as this is system dependent otherwise
@@ -1030,7 +1032,7 @@ class MailAccountHandler(LoggingMixin):
continue continue
elif self.filename_exclusion_matches( elif self.filename_exclusion_matches(
rule.filter_attachment_filename_exclude, rule.filter_attachment_filename_exclude,
att.filename, attachment_filename,
): ):
self.log.debug( self.log.debug(
f"Rule {rule}: " f"Rule {rule}: "
@@ -1064,7 +1066,7 @@ class MailAccountHandler(LoggingMixin):
) )
attachment_name = pathvalidate.sanitize_filename( attachment_name = pathvalidate.sanitize_filename(
unicodedata.normalize("NFC", att.filename), attachment_filename,
) )
if attachment_name: if attachment_name:
temp_filename = temp_dir / attachment_name temp_filename = temp_dir / attachment_name
@@ -1175,7 +1177,7 @@ class MailAccountHandler(LoggingMixin):
doc_overrides = DocumentMetadataOverrides( doc_overrides = DocumentMetadataOverrides(
title=message.subject, title=message.subject,
filename=pathvalidate.sanitize_filename( filename=pathvalidate.sanitize_filename(
unicodedata.normalize("NFC", f"{message.subject}.eml"), normalize_unicode(f"{message.subject}.eml"),
), ),
correspondent_id=correspondent.id if correspondent else None, correspondent_id=correspondent.id if correspondent else None,
document_type_id=doc_type.id if doc_type else None, document_type_id=doc_type.id if doc_type else None,
+7
View File
@@ -8,6 +8,7 @@ from documents.serialisers import CorrespondentField
from documents.serialisers import DocumentTypeField from documents.serialisers import DocumentTypeField
from documents.serialisers import OwnedObjectSerializer from documents.serialisers import OwnedObjectSerializer
from documents.serialisers import TagsField from documents.serialisers import TagsField
from documents.utils import normalize_unicode
from paperless_mail.models import MailAccount from paperless_mail.models import MailAccount
from paperless_mail.models import MailRule from paperless_mail.models import MailRule
from paperless_mail.models import ProcessedMail from paperless_mail.models import ProcessedMail
@@ -161,6 +162,12 @@ class MailRuleSerializer(OwnedObjectSerializer):
raise serializers.ValidationError("Maximum mail age is unreasonably large.") raise serializers.ValidationError("Maximum mail age is unreasonably large.")
return value return value
def validate_filter_attachment_filename_include(self, value):
return normalize_unicode(value)
def validate_filter_attachment_filename_exclude(self, value):
return normalize_unicode(value)
class ProcessedMailSerializer(OwnedObjectSerializer): class ProcessedMailSerializer(OwnedObjectSerializer):
class Meta: class Meta:
+2 -22
View File
@@ -1,9 +1,7 @@
import logging import logging
from celery import Task
from celery import shared_task from celery import shared_task
from documents.models import PaperlessTask
from paperless_mail.mail import MailAccountHandler from paperless_mail.mail import MailAccountHandler
from paperless_mail.mail import MailError from paperless_mail.mail import MailError
from paperless_mail.models import MailAccount from paperless_mail.models import MailAccount
@@ -12,26 +10,8 @@ from paperless_mail.models import MailRule
logger = logging.getLogger("paperless.mail.tasks") logger = logging.getLogger("paperless.mail.tasks")
@shared_task(bind=True) @shared_task
def process_mail_accounts(self: Task, account_ids: list[int] | None = None) -> str: def process_mail_accounts(account_ids: list[int] | None = None) -> str:
# A scheduled check can still be running (or queued) when the next one
# ProcessedMail dedup only records a message once its
# handling has finished, so an overlapping run can still pick up the same
# not-yet-recorded message. Skip outright rather than race it.
other_mail_fetch_running = (
PaperlessTask.objects.filter(
task_type=PaperlessTask.TaskType.MAIL_FETCH,
status__in=[PaperlessTask.Status.PENDING, PaperlessTask.Status.STARTED],
)
.exclude(task_id=self.request.id)
.exists()
)
if other_mail_fetch_running:
logger.info(
"Mail account processing is already running; skipping this run.",
)
return "Skipped: mail account processing already in progress."
total_new_documents = 0 total_new_documents = 0
accounts = ( accounts = (
MailAccount.objects.filter(pk__in=account_ids) MailAccount.objects.filter(pk__in=account_ids)
@@ -1,134 +0,0 @@
from typing import Final
import pytest
import pytest_mock
from documents.models import PaperlessTask
from documents.tests.factories import PaperlessTaskFactory
from paperless_mail import tasks
from paperless_mail.tests.factories import MailAccountFactory
from paperless_mail.tests.factories import MailRuleFactory
NO_DOCUMENTS_ADDED: Final = "No new documents were added."
SKIPPED: Final = "Skipped: mail account processing already in progress."
@pytest.mark.django_db
@pytest.mark.usefixtures("account_with_rule")
class TestProcessMailAccountsOverlap:
@pytest.fixture
def account_with_rule(self) -> None:
"""An enabled mail account with a single enabled rule."""
account = MailAccountFactory.create()
MailRuleFactory.create(account=account, enabled=True)
@pytest.mark.parametrize(
("status", "expected_result", "expected_call_count"),
[
pytest.param(
PaperlessTask.Status.PENDING,
SKIPPED,
0,
id="pending-task-blocks",
),
pytest.param(
PaperlessTask.Status.STARTED,
SKIPPED,
0,
id="started-task-blocks",
),
pytest.param(
PaperlessTask.Status.SUCCESS,
NO_DOCUMENTS_ADDED,
1,
id="finished-task-does-not-block",
),
],
)
def test_skips_only_while_another_mail_fetch_task_runs(
self,
mocker: pytest_mock.MockerFixture,
status: PaperlessTask.Status,
expected_result: str,
expected_call_count: int,
) -> None:
"""
GIVEN:
- An enabled mail account with a rule
- Another mail fetch task row in the given status
WHEN:
- Mail accounts are processed
THEN:
- Processing is skipped only if that other task is pending or running
"""
PaperlessTaskFactory.create(
task_type=PaperlessTask.TaskType.MAIL_FETCH,
trigger_source=PaperlessTask.TriggerSource.SCHEDULED,
status=status,
)
mocked_handle = mocker.patch.object(
tasks.MailAccountHandler,
"handle_mail_account",
return_value=0,
)
result = tasks.process_mail_accounts()
assert mocked_handle.call_count == expected_call_count
assert result == expected_result
def test_runs_when_no_other_mail_fetch_task_exists(
self,
mocker: pytest_mock.MockerFixture,
) -> None:
"""
GIVEN:
- An enabled mail account with a rule
- No other mail fetch task rows
WHEN:
- Mail accounts are processed
THEN:
- The account is handled
"""
mocked_handle = mocker.patch.object(
tasks.MailAccountHandler,
"handle_mail_account",
return_value=0,
)
result = tasks.process_mail_accounts()
mocked_handle.assert_called_once()
assert result == NO_DOCUMENTS_ADDED
def test_does_not_skip_due_to_its_own_task_row(
self,
mocker: pytest_mock.MockerFixture,
) -> None:
"""
GIVEN:
- An enabled mail account with a rule
- A running mail fetch task row belonging to this very task
WHEN:
- Mail accounts are processed under that task id
THEN:
- The task does not skip itself and handles the account
"""
PaperlessTaskFactory.create(
task_id="self-task-id",
task_type=PaperlessTask.TaskType.MAIL_FETCH,
trigger_source=PaperlessTask.TriggerSource.SCHEDULED,
status=PaperlessTask.Status.STARTED,
)
mocked_handle = mocker.patch.object(
tasks.MailAccountHandler,
"handle_mail_account",
return_value=0,
)
result = tasks.process_mail_accounts.apply(task_id="self-task-id").result
mocked_handle.assert_called_once()
assert result == NO_DOCUMENTS_ADDED