mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-09-09 03:07:59 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c20f357697 | ||
|
|
8331923fb2 | ||
|
|
5d87bc5a70 | ||
|
|
2c04fe1382 | ||
|
|
6b25d18fc8 |
@@ -12,6 +12,7 @@ from typing import TYPE_CHECKING
|
||||
from typing import Any
|
||||
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.exceptions import FieldError
|
||||
from django.db.models import Case
|
||||
from django.db.models import CharField
|
||||
from django.db.models import Count
|
||||
@@ -52,7 +53,6 @@ from documents.models import StoragePath
|
||||
from documents.models import Tag
|
||||
from documents.permissions import permitted_document_ids
|
||||
from documents.permissions import permitted_object_ids
|
||||
from documents.versioning import annotate_effective_content
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
@@ -182,9 +182,14 @@ class TitleContentFilter(Filter):
|
||||
logger.warning(
|
||||
"Deprecated document filter parameter 'title_content' used; use `text` instead.",
|
||||
)
|
||||
return annotate_effective_content(qs).filter(
|
||||
Q(title__icontains=value) | Q(effective_content__icontains=value),
|
||||
)
|
||||
try:
|
||||
return qs.filter(
|
||||
Q(title__icontains=value) | Q(effective_content__icontains=value),
|
||||
)
|
||||
except FieldError:
|
||||
return qs.filter(
|
||||
Q(title__icontains=value) | Q(content__icontains=value),
|
||||
)
|
||||
else:
|
||||
return qs
|
||||
|
||||
@@ -195,9 +200,14 @@ class EffectiveContentFilter(Filter):
|
||||
value = value.strip() if isinstance(value, str) else value
|
||||
if not value:
|
||||
return qs
|
||||
return annotate_effective_content(qs).filter(
|
||||
**{f"effective_content__{self.lookup_expr}": value},
|
||||
)
|
||||
try:
|
||||
return qs.filter(
|
||||
**{f"effective_content__{self.lookup_expr}": value},
|
||||
)
|
||||
except FieldError:
|
||||
return qs.filter(
|
||||
**{f"content__{self.lookup_expr}": value},
|
||||
)
|
||||
|
||||
|
||||
@extend_schema_field(serializers.BooleanField)
|
||||
|
||||
@@ -72,24 +72,6 @@ class TrackedFile:
|
||||
return False
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class QueuedFile:
|
||||
"""A file handed to Celery, with enough state to decide when it's safe to re-check."""
|
||||
|
||||
task_id: str
|
||||
size: int
|
||||
mtime: float
|
||||
|
||||
@classmethod
|
||||
def from_path(cls, task_id: str, path: Path) -> QueuedFile | None:
|
||||
"""Snapshot the file's size and mtime, or None if it cannot be stat'd."""
|
||||
try:
|
||||
stat = path.stat()
|
||||
except OSError:
|
||||
return None
|
||||
return cls(task_id, stat.st_size, stat.st_mtime)
|
||||
|
||||
|
||||
class FileStabilityTracker:
|
||||
"""
|
||||
Tracks file events and determines when files are stable for consumption.
|
||||
@@ -332,7 +314,7 @@ def _consume_file(
|
||||
consumption_dir: Path,
|
||||
*,
|
||||
subdirs_as_tags: bool,
|
||||
) -> str | None:
|
||||
) -> bool:
|
||||
"""
|
||||
Queue a file for consumption.
|
||||
|
||||
@@ -342,18 +324,18 @@ def _consume_file(
|
||||
subdirs_as_tags: Whether to create tags from subdirectory names.
|
||||
|
||||
Returns:
|
||||
The Celery task id if the file was successfully handed to Celery,
|
||||
None otherwise. Callers must not record the file as queued on
|
||||
failure, or the rescan will never retry it.
|
||||
True if the file was successfully handed to Celery, False otherwise.
|
||||
Callers must not record the file as queued on failure, or the rescan
|
||||
will never retry it.
|
||||
"""
|
||||
# Verify file still exists and is accessible
|
||||
try:
|
||||
if not filepath.is_file():
|
||||
logger.debug(f"Not consuming {filepath}: not a file or doesn't exist")
|
||||
return None
|
||||
return False
|
||||
except OSError as e:
|
||||
logger.warning(f"Not consuming {filepath}: {e}")
|
||||
return None
|
||||
return False
|
||||
|
||||
# Get tags from path if configured
|
||||
tag_ids: list[int] | None = None
|
||||
@@ -366,7 +348,7 @@ def _consume_file(
|
||||
# Queue for consumption
|
||||
try:
|
||||
logger.info(f"Adding {filepath} to the task queue")
|
||||
result = consume_file.apply_async(
|
||||
consume_file.apply_async(
|
||||
kwargs={
|
||||
"input_doc": ConsumableDocument(
|
||||
source=DocumentSource.ConsumeFolder,
|
||||
@@ -378,9 +360,9 @@ def _consume_file(
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(f"Error while queuing document {filepath}")
|
||||
return None
|
||||
return False
|
||||
|
||||
return result.id
|
||||
return True
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
@@ -497,19 +479,18 @@ class Command(BaseCommand):
|
||||
recursive: bool,
|
||||
subdirs_as_tags: bool,
|
||||
consumer_filter: ConsumerFilter,
|
||||
) -> dict[Path, QueuedFile]:
|
||||
) -> set[Path]:
|
||||
"""
|
||||
Process any existing files in the consumption directory.
|
||||
|
||||
Returns a dict mapping each resolved path that was queued to its
|
||||
QueuedFile state, so the watch loop can seed its in-flight dict and
|
||||
avoid re-queuing them on the first rescan before the consume tasks
|
||||
have removed them from disk.
|
||||
Returns the set of resolved paths that were queued, so the watch loop
|
||||
can seed its in-flight set and avoid re-queuing them on the first
|
||||
rescan before the consume tasks have removed them from disk.
|
||||
"""
|
||||
logger.info(f"Processing existing files in {directory}")
|
||||
|
||||
glob_pattern = "**/*" if recursive else "*"
|
||||
queued: dict[Path, QueuedFile] = {}
|
||||
queued: set[Path] = set()
|
||||
|
||||
for filepath in directory.glob(glob_pattern):
|
||||
# Use filter to check if file should be processed
|
||||
@@ -519,17 +500,12 @@ class Command(BaseCommand):
|
||||
if not consumer_filter(Change.added, str(filepath)):
|
||||
continue
|
||||
|
||||
task_id = _consume_file(
|
||||
if _consume_file(
|
||||
filepath=filepath,
|
||||
consumption_dir=directory,
|
||||
subdirs_as_tags=subdirs_as_tags,
|
||||
)
|
||||
if task_id is None:
|
||||
continue
|
||||
|
||||
entry = QueuedFile.from_path(task_id, filepath)
|
||||
if entry is not None:
|
||||
queued[filepath.resolve()] = entry
|
||||
):
|
||||
queued.add(filepath.resolve())
|
||||
|
||||
return queued
|
||||
|
||||
@@ -540,43 +516,21 @@ class Command(BaseCommand):
|
||||
recursive: bool,
|
||||
consumer_filter: ConsumerFilter,
|
||||
tracker: FileStabilityTracker,
|
||||
queued: dict[Path, QueuedFile],
|
||||
queued: set[Path],
|
||||
) -> None:
|
||||
"""
|
||||
Re-inject on-disk files the watcher never reported into the tracker.
|
||||
|
||||
Acts as a safety net for files stranded by the watcher-recreation gap
|
||||
(see ``rescan_interval_s``). Files already being tracked, or already
|
||||
queued and still in flight (or completed but with unchanged content),
|
||||
are skipped, so a file is never queued twice and a permanently broken
|
||||
file does not retry forever. Queued paths that have since left the
|
||||
directory are pruned so a later file reusing the same name is not
|
||||
skipped forever.
|
||||
(see ``rescan_interval_s``). Files already being tracked or already
|
||||
queued and awaiting consumption are skipped, so a file is never queued
|
||||
twice. Queued paths that have since left the directory are pruned so a
|
||||
later file reusing the same name is not skipped forever.
|
||||
"""
|
||||
# Long-running process: drop stale DB connections before querying (#4265)
|
||||
db.close_old_connections()
|
||||
|
||||
# Vanished from disk: consumed (or otherwise removed), prune regardless of status
|
||||
for path in [path for path in queued if not path.exists()]:
|
||||
del queued[path]
|
||||
|
||||
if queued:
|
||||
tasks = PaperlessTask.objects.only("task_id", "status").in_bulk(
|
||||
[entry.task_id for entry in queued.values()],
|
||||
field_name="task_id",
|
||||
)
|
||||
for path, entry in list(queued.items()):
|
||||
task = tasks.get(entry.task_id)
|
||||
# No row yet means the task has not started: treat as in flight
|
||||
if task is None or task.status not in PaperlessTask.COMPLETE_STATUSES:
|
||||
continue
|
||||
try:
|
||||
current = path.stat()
|
||||
except OSError:
|
||||
continue
|
||||
# Completed and the content changed: a new file, allow a retry
|
||||
if current.st_size != entry.size or current.st_mtime != entry.mtime:
|
||||
del queued[path]
|
||||
# Prune in-flight paths that have left the directory
|
||||
for path in list(queued):
|
||||
if not path.exists():
|
||||
queued.discard(path)
|
||||
|
||||
glob_pattern = "**/*" if recursive else "*"
|
||||
|
||||
@@ -604,7 +558,7 @@ class Command(BaseCommand):
|
||||
polling_interval: float,
|
||||
stability_delay: float,
|
||||
is_testing: bool,
|
||||
queued: dict[Path, QueuedFile] | None = None,
|
||||
queued: set[Path] | None = None,
|
||||
) -> None:
|
||||
"""Watch directory for changes and process stable files."""
|
||||
use_polling = polling_interval > 0
|
||||
@@ -613,7 +567,7 @@ class Command(BaseCommand):
|
||||
# Resolved paths that have been queued and are awaiting consumption.
|
||||
# Seeded from the startup scan so the first rescan does not re-queue
|
||||
# files whose consume tasks have not yet removed them from disk.
|
||||
queued = {} if queued is None else queued
|
||||
queued = set() if queued is None else queued
|
||||
|
||||
# Full-glob safety net cadence (0 disables)
|
||||
rescan_interval_s = self.rescan_interval_s
|
||||
@@ -690,7 +644,7 @@ class Command(BaseCommand):
|
||||
# Consumed (or otherwise removed); a later file
|
||||
# reusing this name must not be skipped as
|
||||
# already-queued.
|
||||
queued.pop(path, None)
|
||||
queued.discard(path)
|
||||
if not path.is_file():
|
||||
continue
|
||||
if path in queued:
|
||||
@@ -709,17 +663,12 @@ class Command(BaseCommand):
|
||||
# rescan does not re-queue them while the consume task
|
||||
# has yet to remove them from disk, but does retry a
|
||||
# failed publish instead of stranding it
|
||||
task_id = _consume_file(
|
||||
if _consume_file(
|
||||
filepath=stable_path,
|
||||
consumption_dir=directory,
|
||||
subdirs_as_tags=subdirs_as_tags,
|
||||
)
|
||||
if task_id is None:
|
||||
continue
|
||||
|
||||
entry = QueuedFile.from_path(task_id, stable_path)
|
||||
if entry is not None:
|
||||
queued[stable_path] = entry
|
||||
):
|
||||
queued.add(stable_path)
|
||||
|
||||
# Exit watch loop to reconfigure timeout
|
||||
break
|
||||
@@ -728,16 +677,13 @@ class Command(BaseCommand):
|
||||
if rescan_timeout_ms > 0 and (
|
||||
monotonic() - last_rescan >= rescan_interval_s
|
||||
):
|
||||
try:
|
||||
self._rescan_existing_files(
|
||||
directory=directory,
|
||||
recursive=recursive,
|
||||
consumer_filter=consumer_filter,
|
||||
tracker=tracker,
|
||||
queued=queued,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Error during consume folder rescan")
|
||||
self._rescan_existing_files(
|
||||
directory=directory,
|
||||
recursive=recursive,
|
||||
consumer_filter=consumer_filter,
|
||||
tracker=tracker,
|
||||
queued=queued,
|
||||
)
|
||||
last_rescan = monotonic()
|
||||
|
||||
# Determine next timeout
|
||||
|
||||
@@ -2,12 +2,14 @@ from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest import TestCase
|
||||
from unittest import mock
|
||||
|
||||
from auditlog.models import LogEntry # type: ignore[import-untyped]
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.contrib.auth.models import User
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.exceptions import FieldError
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.test import TestCase as DjangoTestCase
|
||||
from django.utils import timezone
|
||||
@@ -20,7 +22,6 @@ from documents.filters import TitleContentFilter
|
||||
from documents.models import Document
|
||||
from documents.tests.utils import DirectoriesMixin
|
||||
from documents.tests.utils import read_streaming_response
|
||||
from documents.versioning import annotate_effective_content
|
||||
from documents.views import DocumentSelectionMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -891,104 +892,32 @@ class TestDocumentVersioningApi(DirectoriesMixin, APITestCase):
|
||||
)
|
||||
|
||||
|
||||
class TestVersionAwareFilters(DjangoTestCase):
|
||||
"""
|
||||
The filters annotate effective_content themselves rather than relying on
|
||||
the caller's queryset carrying it, so they stay version-aware on a plain
|
||||
Document queryset (e.g. the bulk-edit "select all matching" path).
|
||||
"""
|
||||
class TestVersionAwareFilters(TestCase):
|
||||
def test_title_content_filter_falls_back_to_content(self) -> None:
|
||||
queryset = mock.Mock()
|
||||
fallback_queryset = mock.Mock()
|
||||
queryset.filter.side_effect = [FieldError("missing field"), fallback_queryset]
|
||||
|
||||
def setUp(self) -> None:
|
||||
super().setUp()
|
||||
self.root = Document.objects.create(
|
||||
title="root",
|
||||
checksum="root",
|
||||
mime_type="application/pdf",
|
||||
content="superseded-content",
|
||||
)
|
||||
Document.objects.create(
|
||||
title="version",
|
||||
checksum="version",
|
||||
mime_type="application/pdf",
|
||||
root_document=self.root,
|
||||
version_index=1,
|
||||
content="latest-content",
|
||||
)
|
||||
self.unversioned = Document.objects.create(
|
||||
title="unversioned",
|
||||
checksum="unversioned",
|
||||
mime_type="application/pdf",
|
||||
content="latest-content",
|
||||
)
|
||||
result = TitleContentFilter().filter(queryset, " latest ")
|
||||
|
||||
def test_title_content_filter_matches_latest_version_content(self) -> None:
|
||||
result = TitleContentFilter().filter(
|
||||
Document.objects.filter(root_document__isnull=True),
|
||||
self.assertIs(result, fallback_queryset)
|
||||
self.assertEqual(queryset.filter.call_count, 2)
|
||||
|
||||
def test_effective_content_filter_falls_back_to_content_lookup(self) -> None:
|
||||
queryset = mock.Mock()
|
||||
fallback_queryset = mock.Mock()
|
||||
queryset.filter.side_effect = [FieldError("missing field"), fallback_queryset]
|
||||
|
||||
result = EffectiveContentFilter(lookup_expr="icontains").filter(
|
||||
queryset,
|
||||
" latest ",
|
||||
)
|
||||
|
||||
self.assertCountEqual(
|
||||
[doc.id for doc in result],
|
||||
[self.root.id, self.unversioned.id],
|
||||
)
|
||||
|
||||
def test_effective_content_filter_matches_latest_version_content(self) -> None:
|
||||
result = EffectiveContentFilter(lookup_expr="icontains").filter(
|
||||
Document.objects.filter(root_document__isnull=True),
|
||||
" latest ",
|
||||
)
|
||||
|
||||
self.assertCountEqual(
|
||||
[doc.id for doc in result],
|
||||
[self.root.id, self.unversioned.id],
|
||||
)
|
||||
|
||||
def test_effective_content_filter_ignores_superseded_content(self) -> None:
|
||||
result = EffectiveContentFilter(lookup_expr="icontains").filter(
|
||||
Document.objects.filter(root_document__isnull=True),
|
||||
"superseded",
|
||||
)
|
||||
|
||||
self.assertEqual(list(result), [])
|
||||
|
||||
def test_filters_reuse_an_existing_annotation(self) -> None:
|
||||
"""
|
||||
Annotating twice under the same alias is an error, so an already
|
||||
annotated queryset (the search path) has to be left alone.
|
||||
"""
|
||||
annotated = annotate_effective_content(
|
||||
Document.objects.filter(root_document__isnull=True),
|
||||
)
|
||||
self.assertIs(annotate_effective_content(annotated), annotated)
|
||||
|
||||
result = EffectiveContentFilter(lookup_expr="icontains").filter(
|
||||
annotated,
|
||||
"latest",
|
||||
)
|
||||
|
||||
self.assertCountEqual(
|
||||
[doc.id for doc in result],
|
||||
[self.root.id, self.unversioned.id],
|
||||
)
|
||||
|
||||
def test_bulk_selection_does_not_match_superseded_content(self) -> None:
|
||||
"""
|
||||
Bulk edit's "select all matching" builds its own queryset, so before
|
||||
the filters annotated for themselves it matched the root document's
|
||||
superseded content -- selecting documents the list view, filtered by
|
||||
the same term, does not show.
|
||||
"""
|
||||
user = User.objects.create_superuser(username="bulk_selection")
|
||||
|
||||
selected = DocumentSelectionMixin()._resolve_document_ids(
|
||||
user=user,
|
||||
validated_data={
|
||||
"all": True,
|
||||
"filters": {"content__icontains": "superseded"},
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(selected, [])
|
||||
self.assertIs(result, fallback_queryset)
|
||||
first_kwargs = queryset.filter.call_args_list[0].kwargs
|
||||
second_kwargs = queryset.filter.call_args_list[1].kwargs
|
||||
self.assertEqual(first_kwargs, {"effective_content__icontains": "latest"})
|
||||
self.assertEqual(second_kwargs, {"content__icontains": "latest"})
|
||||
|
||||
def test_effective_content_filter_returns_input_for_empty_values(self) -> None:
|
||||
queryset = mock.Mock()
|
||||
|
||||
@@ -1947,29 +1947,6 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
|
||||
self.assertEqual(len(response.data["documents"]), 1)
|
||||
self.assertEqual(response.data["documents"][0]["id"], title_match.id)
|
||||
|
||||
def test_global_search_returns_latest_version_content(self) -> None:
|
||||
root = Document.objects.create(
|
||||
title="bank statement",
|
||||
content="superseded content",
|
||||
checksum="GSV1",
|
||||
pk=23,
|
||||
)
|
||||
Document.objects.create(
|
||||
title="bank statement v2",
|
||||
content="latest content",
|
||||
checksum="GSV2",
|
||||
pk=24,
|
||||
root_document=root,
|
||||
version_index=1,
|
||||
)
|
||||
|
||||
self.client.force_authenticate(self.user)
|
||||
|
||||
response = self.client.get("/api/search/?query=bank&db_only=true")
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
returned = {doc["id"]: doc["content"] for doc in response.data["documents"]}
|
||||
self.assertEqual(returned.get(root.id), "latest content")
|
||||
|
||||
def test_global_search_filters_owned_mail_objects(self) -> None:
|
||||
user1 = User.objects.create_user("mail-search-user")
|
||||
user2 = User.objects.create_user("other-mail-search-user")
|
||||
|
||||
@@ -1,165 +0,0 @@
|
||||
"""
|
||||
Regression test for GH discussion #13969.
|
||||
|
||||
A consume-folder file that fails (e.g. a scanner's 0-byte placeholder
|
||||
hitting "Unsupported mime type inode/x-empty") must be re-detected once
|
||||
its content changes, not permanently stranded in the watcher's queued
|
||||
set. See docs/superpowers/specs/2026-09-08-consume-folder-stuck-queue-spec.md.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
from time import monotonic
|
||||
from time import sleep
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
|
||||
from documents.management.commands.document_consumer import Command
|
||||
from documents.models import PaperlessTask
|
||||
from documents.tests.test_management_consumer import consumption_dir # noqa: F401
|
||||
from documents.tests.test_management_consumer import (
|
||||
mock_consume_file_delay, # noqa: F401
|
||||
)
|
||||
from documents.tests.test_management_consumer import (
|
||||
mock_supported_extensions, # noqa: F401
|
||||
)
|
||||
from documents.tests.test_management_consumer import sample_pdf # noqa: F401
|
||||
from documents.tests.test_management_consumer import scratch_dir # noqa: F401
|
||||
from documents.tests.test_management_consumer import start_consumer # noqa: F401
|
||||
from documents.tests.test_management_consumer import wait_for_mock_call
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from pytest_mock import MockerFixture
|
||||
|
||||
from documents.tests.test_management_consumer import ConsumerThread
|
||||
|
||||
|
||||
@pytest.mark.management
|
||||
# transaction=True: the background consumer thread needs to see rows
|
||||
# committed by this test.
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
class TestStuckQueueAfterConsumptionFailure:
|
||||
def test_scanner_placeholder_recovers_after_failure(
|
||||
self,
|
||||
consumption_dir: Path, # noqa: F811
|
||||
sample_pdf: Path, # noqa: F811
|
||||
mock_consume_file_delay: MagicMock, # noqa: F811
|
||||
start_consumer: Callable[..., ConsumerThread], # noqa: F811
|
||||
) -> None:
|
||||
"""
|
||||
Reproduces discussion #13969: a scanner creates a 0-byte file,
|
||||
consumption fails on it, then the scanner writes real content —
|
||||
the watcher must pick it up on the next rescan instead of
|
||||
ignoring it forever.
|
||||
"""
|
||||
apply_async = mock_consume_file_delay.apply_async
|
||||
apply_async.return_value.id = "scan-task-1"
|
||||
|
||||
thread = start_consumer(
|
||||
stability_delay=0.1,
|
||||
rescan_interval=0.3,
|
||||
)
|
||||
|
||||
target = consumption_dir / "scan.pdf"
|
||||
target.write_bytes(b"") # scanner's 0-byte placeholder
|
||||
|
||||
assert wait_for_mock_call(apply_async, timeout_s=5.0)
|
||||
if thread.exception:
|
||||
raise thread.exception
|
||||
assert apply_async.call_count == 1
|
||||
|
||||
# The Celery task fails on inode/x-empty, exactly as consumer.py's
|
||||
# mime-type check would in production. Create a real PaperlessTask row
|
||||
# with FAILURE status to simulate the task's result when the real
|
||||
# consumer.py rescan queries for it. This exercises the actual
|
||||
# batched query path: PaperlessTask.objects.filter(task_id__in=[...]).
|
||||
# values_list("task_id", "status")
|
||||
PaperlessTask.objects.create(
|
||||
task_id="scan-task-1",
|
||||
task_type=PaperlessTask.TaskType.CONSUME_FILE,
|
||||
status=PaperlessTask.Status.FAILURE,
|
||||
)
|
||||
|
||||
# Scanner finishes writing the real scan.
|
||||
shutil.copy(sample_pdf, target)
|
||||
|
||||
# Needs to clear: rescan_interval (0.3s, until the entry is
|
||||
# released) + a fresh stability_delay (0.1s, before _consume_file
|
||||
# is called again) + polling slop + test margin.
|
||||
deadline = monotonic() + 8.0
|
||||
while apply_async.call_count < 2 and monotonic() < deadline:
|
||||
sleep(0.1)
|
||||
|
||||
if thread.exception:
|
||||
raise thread.exception
|
||||
|
||||
assert apply_async.call_count == 2, (
|
||||
"Expected the file to be re-consumed after the scanner wrote "
|
||||
f"real content, but apply_async was called "
|
||||
f"{apply_async.call_count} time(s)"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.management
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
class TestRescanErrorHandling:
|
||||
def test_rescan_exception_does_not_break_the_watch_loop(
|
||||
self,
|
||||
consumption_dir: Path, # noqa: F811
|
||||
mock_consume_file_delay: MagicMock, # noqa: F811
|
||||
start_consumer: Callable[..., ConsumerThread], # noqa: F811
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""
|
||||
A rescan that raises must not kill the watcher, and must not
|
||||
cause the loop to busy-retry the database on every wake. The
|
||||
watch loop updates ``last_rescan`` even when the rescan raises,
|
||||
so a broken rescan still waits a full ``rescan_interval_s``
|
||||
between attempts instead of spinning.
|
||||
|
||||
A file kept perpetually "pending" (rewritten faster than
|
||||
``stability_delay``) forces the watch loop to wake more often
|
||||
than ``rescan_interval_s``, which is what surfaces the busy-retry
|
||||
regression: with the broken shape, every one of those frequent
|
||||
wakes re-attempts the rescan instead of only every
|
||||
``rescan_interval_s``.
|
||||
"""
|
||||
rescan = mocker.patch.object(
|
||||
Command,
|
||||
"_rescan_existing_files",
|
||||
side_effect=Exception("db down"),
|
||||
)
|
||||
|
||||
thread = start_consumer(
|
||||
stability_delay=0.02,
|
||||
rescan_interval=0.5,
|
||||
)
|
||||
|
||||
# Keep a file perpetually unstable so the watch loop's timeout is
|
||||
# floored at stability_delay (0.02s) rather than rescan_interval_s
|
||||
# (0.5s). Otherwise the loop only wakes every 0.5s regardless of
|
||||
# the rescan bug, and the two shapes would be indistinguishable.
|
||||
target = consumption_dir / "busy.pdf"
|
||||
deadline = monotonic() + 2.0
|
||||
counter = 0
|
||||
while monotonic() < deadline:
|
||||
counter += 1
|
||||
target.write_bytes(f"%PDF-1.4\n{counter}\n".encode())
|
||||
sleep(0.01)
|
||||
|
||||
assert thread.is_alive()
|
||||
if thread.exception:
|
||||
raise thread.exception
|
||||
|
||||
assert rescan.called
|
||||
assert rescan.call_count < 15, (
|
||||
"Expected the rescan to be retried roughly once per "
|
||||
"rescan_interval_s, but it was called "
|
||||
f"{rescan.call_count} time(s), suggesting a busy-loop"
|
||||
)
|
||||
@@ -33,11 +33,9 @@ from documents.data_models import DocumentSource
|
||||
from documents.management.commands.document_consumer import Command
|
||||
from documents.management.commands.document_consumer import ConsumerFilter
|
||||
from documents.management.commands.document_consumer import FileStabilityTracker
|
||||
from documents.management.commands.document_consumer import QueuedFile
|
||||
from documents.management.commands.document_consumer import TrackedFile
|
||||
from documents.management.commands.document_consumer import _consume_file
|
||||
from documents.management.commands.document_consumer import _tags_from_path
|
||||
from documents.models import PaperlessTask
|
||||
from documents.models import Tag
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -447,14 +445,13 @@ class TestConsumeFile:
|
||||
target = consumption_dir / "document.pdf"
|
||||
shutil.copy(sample_pdf, target)
|
||||
|
||||
mock_consume_file_delay.apply_async.return_value.id = "abc123"
|
||||
result = _consume_file(
|
||||
filepath=target,
|
||||
consumption_dir=consumption_dir,
|
||||
subdirs_as_tags=False,
|
||||
)
|
||||
|
||||
assert result == mock_consume_file_delay.apply_async.return_value.id
|
||||
assert result is True
|
||||
mock_consume_file_delay.apply_async.assert_called_once()
|
||||
call_args = mock_consume_file_delay.apply_async.call_args
|
||||
consumable_doc = call_args.kwargs["kwargs"]["input_doc"]
|
||||
@@ -473,7 +470,7 @@ class TestConsumeFile:
|
||||
consumption_dir=consumption_dir,
|
||||
subdirs_as_tags=False,
|
||||
)
|
||||
assert result is None
|
||||
assert result is False
|
||||
mock_consume_file_delay.apply_async.assert_not_called()
|
||||
|
||||
def test_consume_directory(
|
||||
@@ -490,7 +487,7 @@ class TestConsumeFile:
|
||||
consumption_dir=consumption_dir,
|
||||
subdirs_as_tags=False,
|
||||
)
|
||||
assert result is None
|
||||
assert result is False
|
||||
mock_consume_file_delay.apply_async.assert_not_called()
|
||||
|
||||
def test_consume_with_permission_error(
|
||||
@@ -510,7 +507,7 @@ class TestConsumeFile:
|
||||
consumption_dir=consumption_dir,
|
||||
subdirs_as_tags=False,
|
||||
)
|
||||
assert result is None
|
||||
assert result is False
|
||||
mock_consume_file_delay.apply_async.assert_not_called()
|
||||
|
||||
def test_consume_with_apply_async_failure(
|
||||
@@ -530,7 +527,7 @@ class TestConsumeFile:
|
||||
consumption_dir=consumption_dir,
|
||||
subdirs_as_tags=False,
|
||||
)
|
||||
assert result is None
|
||||
assert result is False
|
||||
|
||||
def test_consume_with_tags_error(
|
||||
self,
|
||||
@@ -548,13 +545,12 @@ class TestConsumeFile:
|
||||
side_effect=DatabaseError("Something happened"),
|
||||
)
|
||||
|
||||
mock_consume_file_delay.apply_async.return_value.id = "abc123"
|
||||
result = _consume_file(
|
||||
filepath=target,
|
||||
consumption_dir=consumption_dir,
|
||||
subdirs_as_tags=True,
|
||||
)
|
||||
assert result == "abc123"
|
||||
assert result is True
|
||||
mock_consume_file_delay.apply_async.assert_called_once()
|
||||
call_args = mock_consume_file_delay.apply_async.call_args
|
||||
overrides = call_args.kwargs["kwargs"]["overrides"]
|
||||
@@ -1120,7 +1116,6 @@ class TestCommandWatchEdgeCases:
|
||||
Tag.objects.all().delete()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestRescanExistingFiles:
|
||||
"""
|
||||
Unit tests for the rescan safety net.
|
||||
@@ -1139,23 +1134,12 @@ class TestRescanExistingFiles:
|
||||
ignore_patterns=[],
|
||||
)
|
||||
|
||||
def _queued_as_is(self, target: Path, task_id: str) -> dict[Path, QueuedFile]:
|
||||
"""A queued entry whose snapshot matches the file's current content."""
|
||||
stat = target.stat()
|
||||
return {
|
||||
target.resolve(): QueuedFile(
|
||||
task_id=task_id,
|
||||
size=stat.st_size,
|
||||
mtime=stat.st_mtime,
|
||||
),
|
||||
}
|
||||
|
||||
def _rescan(
|
||||
self,
|
||||
directory: Path,
|
||||
consumer_filter: ConsumerFilter,
|
||||
tracker: FileStabilityTracker,
|
||||
queued: dict[Path, QueuedFile],
|
||||
queued: set[Path],
|
||||
*,
|
||||
recursive: bool = False,
|
||||
) -> None:
|
||||
@@ -1178,7 +1162,7 @@ class TestRescanExistingFiles:
|
||||
shutil.copy(sample_pdf, target)
|
||||
tracker = FileStabilityTracker(stability_delay=0.1)
|
||||
|
||||
self._rescan(consumption_dir, pdf_only_filter, tracker, {})
|
||||
self._rescan(consumption_dir, pdf_only_filter, tracker, set())
|
||||
|
||||
assert tracker.is_tracking(target) is True
|
||||
assert tracker.pending_count == 1
|
||||
@@ -1195,7 +1179,7 @@ class TestRescanExistingFiles:
|
||||
tracker = FileStabilityTracker(stability_delay=0.1)
|
||||
tracker.track(target, Change.added)
|
||||
|
||||
self._rescan(consumption_dir, pdf_only_filter, tracker, {})
|
||||
self._rescan(consumption_dir, pdf_only_filter, tracker, set())
|
||||
|
||||
assert tracker.pending_count == 1
|
||||
|
||||
@@ -1209,17 +1193,11 @@ class TestRescanExistingFiles:
|
||||
target = consumption_dir / "inflight.pdf"
|
||||
shutil.copy(sample_pdf, target)
|
||||
tracker = FileStabilityTracker(stability_delay=0.1)
|
||||
PaperlessTask.objects.create(
|
||||
task_id="task-inflight",
|
||||
task_type=PaperlessTask.TaskType.CONSUME_FILE,
|
||||
status=PaperlessTask.Status.STARTED,
|
||||
)
|
||||
queued = self._queued_as_is(target, "task-inflight")
|
||||
queued = {target.resolve()}
|
||||
|
||||
self._rescan(consumption_dir, pdf_only_filter, tracker, queued)
|
||||
|
||||
assert tracker.pending_count == 0
|
||||
assert target.resolve() in queued
|
||||
|
||||
def test_prunes_vanished_queued_paths(
|
||||
self,
|
||||
@@ -1229,7 +1207,7 @@ class TestRescanExistingFiles:
|
||||
"""Queued paths no longer on disk are dropped so the name can recur."""
|
||||
gone = (consumption_dir / "gone.pdf").resolve()
|
||||
tracker = FileStabilityTracker(stability_delay=0.1)
|
||||
queued = {gone: QueuedFile(task_id="task-gone", size=0, mtime=0.0)}
|
||||
queued = {gone}
|
||||
|
||||
self._rescan(consumption_dir, pdf_only_filter, tracker, queued)
|
||||
|
||||
@@ -1244,7 +1222,7 @@ class TestRescanExistingFiles:
|
||||
(consumption_dir / "notes.xyz").write_bytes(b"content")
|
||||
tracker = FileStabilityTracker(stability_delay=0.1)
|
||||
|
||||
self._rescan(consumption_dir, pdf_only_filter, tracker, {})
|
||||
self._rescan(consumption_dir, pdf_only_filter, tracker, set())
|
||||
|
||||
assert tracker.pending_count == 0
|
||||
|
||||
@@ -1261,151 +1239,13 @@ class TestRescanExistingFiles:
|
||||
shutil.copy(sample_pdf, target)
|
||||
|
||||
shallow = FileStabilityTracker(stability_delay=0.1)
|
||||
self._rescan(consumption_dir, pdf_only_filter, shallow, {})
|
||||
self._rescan(consumption_dir, pdf_only_filter, shallow, set())
|
||||
assert shallow.pending_count == 0
|
||||
|
||||
deep = FileStabilityTracker(stability_delay=0.1)
|
||||
self._rescan(consumption_dir, pdf_only_filter, deep, {}, recursive=True)
|
||||
self._rescan(consumption_dir, pdf_only_filter, deep, set(), recursive=True)
|
||||
assert deep.is_tracking(target) is True
|
||||
|
||||
def test_completed_but_content_unchanged_stays_queued(
|
||||
self,
|
||||
consumption_dir: Path,
|
||||
sample_pdf: Path,
|
||||
pdf_only_filter: ConsumerFilter,
|
||||
) -> None:
|
||||
"""
|
||||
A task that failed (or succeeded) but whose file content never
|
||||
changed since being queued stays put — this is what makes a
|
||||
permanently-broken file (e.g. a corrupt PDF) fail once instead of
|
||||
retrying forever (C2).
|
||||
"""
|
||||
target = consumption_dir / "broken.pdf"
|
||||
shutil.copy(sample_pdf, target)
|
||||
tracker = FileStabilityTracker(stability_delay=0.1)
|
||||
PaperlessTask.objects.create(
|
||||
task_id="task-failed-unchanged",
|
||||
task_type=PaperlessTask.TaskType.CONSUME_FILE,
|
||||
status=PaperlessTask.Status.FAILURE,
|
||||
)
|
||||
queued = self._queued_as_is(target, "task-failed-unchanged")
|
||||
|
||||
self._rescan(consumption_dir, pdf_only_filter, tracker, queued)
|
||||
|
||||
assert target.resolve() in queued
|
||||
assert tracker.pending_count == 0
|
||||
|
||||
def test_completed_and_content_changed_is_released(
|
||||
self,
|
||||
consumption_dir: Path,
|
||||
sample_pdf: Path,
|
||||
pdf_only_filter: ConsumerFilter,
|
||||
) -> None:
|
||||
"""
|
||||
A task that completed AND whose file content has since changed is
|
||||
released and re-tracked — this is the discussion #13969 fix: the
|
||||
scanner's 0-byte file failed, then real content arrived.
|
||||
"""
|
||||
target = consumption_dir / "scanned.pdf"
|
||||
target.write_bytes(b"") # simulate the 0-byte placeholder that was queued
|
||||
tracker = FileStabilityTracker(stability_delay=0.1)
|
||||
PaperlessTask.objects.create(
|
||||
task_id="task-failed-changed",
|
||||
task_type=PaperlessTask.TaskType.CONSUME_FILE,
|
||||
status=PaperlessTask.Status.FAILURE,
|
||||
)
|
||||
queued = {
|
||||
target.resolve(): QueuedFile(
|
||||
task_id="task-failed-changed",
|
||||
size=0,
|
||||
mtime=0.0,
|
||||
),
|
||||
}
|
||||
shutil.copy(sample_pdf, target) # scanner writes real content
|
||||
|
||||
self._rescan(consumption_dir, pdf_only_filter, tracker, queued)
|
||||
|
||||
assert target.resolve() not in queued
|
||||
assert tracker.is_tracking(target.resolve()) is True
|
||||
|
||||
def test_no_paperlesstask_row_stays_queued(
|
||||
self,
|
||||
consumption_dir: Path,
|
||||
sample_pdf: Path,
|
||||
pdf_only_filter: ConsumerFilter,
|
||||
) -> None:
|
||||
"""No matching PaperlessTask row is treated as still in flight, not released."""
|
||||
target = consumption_dir / "no_row.pdf"
|
||||
shutil.copy(sample_pdf, target)
|
||||
tracker = FileStabilityTracker(stability_delay=0.1)
|
||||
queued = self._queued_as_is(target, "task-does-not-exist")
|
||||
|
||||
self._rescan(consumption_dir, pdf_only_filter, tracker, queued)
|
||||
|
||||
assert target.resolve() in queued
|
||||
|
||||
def test_revoked_status_is_treated_as_complete(
|
||||
self,
|
||||
consumption_dir: Path,
|
||||
sample_pdf: Path,
|
||||
pdf_only_filter: ConsumerFilter,
|
||||
) -> None:
|
||||
"""
|
||||
The release guard checks membership in COMPLETE_STATUSES (SUCCESS,
|
||||
FAILURE, REVOKED), not just FAILURE. A cancelled/revoked task (e.g.
|
||||
after a worker restart discards a stale queue entry) whose content
|
||||
has since changed must also be released, not stuck treating REVOKED
|
||||
as still in-flight.
|
||||
"""
|
||||
target = consumption_dir / "revoked.pdf"
|
||||
target.write_bytes(b"")
|
||||
tracker = FileStabilityTracker(stability_delay=0.1)
|
||||
PaperlessTask.objects.create(
|
||||
task_id="task-revoked",
|
||||
task_type=PaperlessTask.TaskType.CONSUME_FILE,
|
||||
status=PaperlessTask.Status.REVOKED,
|
||||
)
|
||||
queued = {
|
||||
target.resolve(): QueuedFile(task_id="task-revoked", size=0, mtime=0.0),
|
||||
}
|
||||
shutil.copy(sample_pdf, target)
|
||||
|
||||
self._rescan(consumption_dir, pdf_only_filter, tracker, queued)
|
||||
|
||||
assert target.resolve() not in queued
|
||||
assert tracker.is_tracking(target.resolve()) is True
|
||||
|
||||
def test_rescan_issues_one_batched_query_for_multiple_queued_files(
|
||||
self,
|
||||
consumption_dir: Path,
|
||||
sample_pdf: Path,
|
||||
pdf_only_filter: ConsumerFilter,
|
||||
django_assert_num_queries,
|
||||
) -> None:
|
||||
"""
|
||||
The status lookup for every queued file must be a single batched
|
||||
`task_id__in=[...]` query, not one query per file — otherwise a
|
||||
consume folder with many in-flight files turns every rescan into
|
||||
an N+1.
|
||||
"""
|
||||
tracker = FileStabilityTracker(stability_delay=0.1)
|
||||
queued: dict[Path, QueuedFile] = {}
|
||||
for i in range(3):
|
||||
target = consumption_dir / f"doc{i}.pdf"
|
||||
shutil.copy(sample_pdf, target)
|
||||
task_id = f"task-batched-{i}"
|
||||
PaperlessTask.objects.create(
|
||||
task_id=task_id,
|
||||
task_type=PaperlessTask.TaskType.CONSUME_FILE,
|
||||
status=PaperlessTask.Status.STARTED,
|
||||
)
|
||||
queued.update(self._queued_as_is(target, task_id))
|
||||
|
||||
with django_assert_num_queries(1):
|
||||
self._rescan(consumption_dir, pdf_only_filter, tracker, queued)
|
||||
|
||||
assert len(queued) == 3
|
||||
|
||||
|
||||
class TestProcessExistingFilesQueued:
|
||||
"""Tests that startup processing reports which paths it queued."""
|
||||
@@ -1418,8 +1258,7 @@ class TestProcessExistingFilesQueued:
|
||||
mock_consume_file_delay: MagicMock,
|
||||
settings: Settings,
|
||||
) -> None:
|
||||
"""The dict returned seeds the rescan's queued dict, avoiding re-queue."""
|
||||
mock_consume_file_delay.apply_async.return_value.id = "startup-task-id"
|
||||
"""The set returned seeds the rescan's queued set, avoiding re-queue."""
|
||||
target = consumption_dir / "document.pdf"
|
||||
shutil.copy(sample_pdf, target)
|
||||
settings.CONSUMER_IGNORE_PATTERNS = []
|
||||
@@ -1432,9 +1271,6 @@ class TestProcessExistingFilesQueued:
|
||||
)
|
||||
|
||||
assert target.resolve() in queued
|
||||
entry = queued[target.resolve()]
|
||||
assert entry.task_id == "startup-task-id"
|
||||
assert entry.size == target.stat().st_size
|
||||
|
||||
|
||||
@pytest.mark.management
|
||||
@@ -1459,13 +1295,11 @@ class TestCommandRetryAfterQueueFailure:
|
||||
"""A publish failure from the watch loop is retried by the rescan."""
|
||||
apply_async = mock_consume_file_delay.apply_async
|
||||
|
||||
def fail_first_call(*args: object, **kwargs: object) -> MagicMock | None:
|
||||
def fail_first_call(*args: object, **kwargs: object) -> None:
|
||||
if apply_async.call_count == 1:
|
||||
raise Exception("broker down")
|
||||
return apply_async.return_value
|
||||
|
||||
apply_async.side_effect = fail_first_call
|
||||
apply_async.return_value.id = "task_id_test"
|
||||
|
||||
thread = start_consumer(stability_delay=0.1, rescan_interval=0.3)
|
||||
|
||||
|
||||
@@ -27,13 +27,10 @@ def versions_newest_first(documents: QuerySet[Document]) -> QuerySet[Document]:
|
||||
|
||||
def annotate_effective_content(documents: QuerySet[Document]) -> QuerySet[Document]:
|
||||
"""
|
||||
Annotates documents with the content of their newest version unless the
|
||||
queryset already carries the annotation, falling back to their own, so
|
||||
get_effective_content() can answer from the row rather than querying for
|
||||
the versions of each document.
|
||||
Annotates documents with the content of their newest version, falling back
|
||||
to their own, so get_effective_content() can answer from the row rather
|
||||
than querying for the versions of each document
|
||||
"""
|
||||
if "effective_content" in documents.query.annotations:
|
||||
return documents
|
||||
return documents.annotate(
|
||||
effective_content=Coalesce(
|
||||
Subquery(
|
||||
|
||||
@@ -232,7 +232,6 @@ from documents.tasks import train_classifier
|
||||
from documents.tasks import update_document_parent_tags
|
||||
from documents.utils import get_boolean
|
||||
from documents.versioning import VersionResolutionError
|
||||
from documents.versioning import annotate_effective_content
|
||||
from documents.versioning import get_latest_version_for_root
|
||||
from documents.versioning import get_request_version_param
|
||||
from documents.versioning import get_root_document
|
||||
@@ -3633,13 +3632,8 @@ class GlobalSearchView(PassUserMixin):
|
||||
OBJECT_LIMIT = 3
|
||||
docs = []
|
||||
if request.user.has_perm("documents.view_document"):
|
||||
# Never more than OBJECT_LIMIT rows come back here, so annotating
|
||||
# is cheap -- and without it these results show the root
|
||||
# document's superseded content.
|
||||
all_docs = annotate_effective_content(
|
||||
Document.objects.filter(
|
||||
id__in=permitted_document_ids(request.user),
|
||||
),
|
||||
all_docs = Document.objects.filter(
|
||||
id__in=permitted_document_ids(request.user),
|
||||
)
|
||||
if db_only:
|
||||
docs = all_docs.filter(title__icontains=query)[:OBJECT_LIMIT]
|
||||
|
||||
@@ -2,7 +2,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: paperless-ngx\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-09-08 15:56+0000\n"
|
||||
"POT-Creation-Date: 2026-09-08 15:31+0000\n"
|
||||
"PO-Revision-Date: 2022-02-17 04:17\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: English\n"
|
||||
@@ -21,39 +21,39 @@ msgstr ""
|
||||
msgid "Documents"
|
||||
msgstr ""
|
||||
|
||||
#: documents/filters.py:463
|
||||
#: documents/filters.py:473
|
||||
msgid "Value must be valid JSON."
|
||||
msgstr ""
|
||||
|
||||
#: documents/filters.py:482
|
||||
#: documents/filters.py:492
|
||||
msgid "Invalid custom field query expression"
|
||||
msgstr ""
|
||||
|
||||
#: documents/filters.py:492
|
||||
#: documents/filters.py:502
|
||||
msgid "Invalid expression list. Must be nonempty."
|
||||
msgstr ""
|
||||
|
||||
#: documents/filters.py:513
|
||||
#: documents/filters.py:523
|
||||
msgid "Invalid logical operator {op!r}"
|
||||
msgstr ""
|
||||
|
||||
#: documents/filters.py:527
|
||||
#: documents/filters.py:537
|
||||
msgid "Maximum number of query conditions exceeded."
|
||||
msgstr ""
|
||||
|
||||
#: documents/filters.py:591
|
||||
#: documents/filters.py:601
|
||||
msgid "{name!r} is not a valid custom field."
|
||||
msgstr ""
|
||||
|
||||
#: documents/filters.py:628
|
||||
#: documents/filters.py:638
|
||||
msgid "{data_type} does not support query expr {expr!r}."
|
||||
msgstr ""
|
||||
|
||||
#: documents/filters.py:747 documents/models.py:136
|
||||
#: documents/filters.py:757 documents/models.py:136
|
||||
msgid "Maximum nesting depth exceeded."
|
||||
msgstr ""
|
||||
|
||||
#: documents/filters.py:1109
|
||||
#: documents/filters.py:1119
|
||||
msgid "Custom field not found"
|
||||
msgstr ""
|
||||
|
||||
@@ -1632,7 +1632,7 @@ msgid "workflow runs"
|
||||
msgstr ""
|
||||
|
||||
#: documents/serialisers.py:524 documents/serialisers.py:881
|
||||
#: documents/serialisers.py:2841 documents/views.py:315 documents/views.py:2625
|
||||
#: documents/serialisers.py:2841 documents/views.py:314 documents/views.py:2624
|
||||
#: paperless_mail/serialisers.py:156
|
||||
msgid "Insufficient permissions."
|
||||
msgstr ""
|
||||
@@ -1673,7 +1673,7 @@ msgstr ""
|
||||
msgid "Duplicate document identifiers are not allowed."
|
||||
msgstr ""
|
||||
|
||||
#: documents/serialisers.py:2927 documents/views.py:4632
|
||||
#: documents/serialisers.py:2927 documents/views.py:4626
|
||||
#, python-format
|
||||
msgid "Documents not found: %(ids)s"
|
||||
msgstr ""
|
||||
@@ -1941,36 +1941,36 @@ msgstr ""
|
||||
msgid "Unable to parse URI {value}"
|
||||
msgstr ""
|
||||
|
||||
#: documents/views.py:308 documents/views.py:2622
|
||||
#: documents/views.py:307 documents/views.py:2621
|
||||
msgid "Invalid more_like_id"
|
||||
msgstr ""
|
||||
|
||||
#: documents/views.py:1592
|
||||
#: documents/views.py:1591
|
||||
msgid "Invalid AI configuration."
|
||||
msgstr ""
|
||||
|
||||
#: documents/views.py:1603
|
||||
#: documents/views.py:1602
|
||||
msgid "AI backend request timed out."
|
||||
msgstr ""
|
||||
|
||||
#: documents/views.py:2447 documents/views.py:2768
|
||||
#: documents/views.py:2446 documents/views.py:2767
|
||||
msgid "Specify only one of text, title_search, query, or more_like_id."
|
||||
msgstr ""
|
||||
|
||||
#: documents/views.py:4645
|
||||
#: documents/views.py:4639
|
||||
#, python-format
|
||||
msgid "Insufficient permissions to share document %(id)s."
|
||||
msgstr ""
|
||||
|
||||
#: documents/views.py:4691
|
||||
#: documents/views.py:4685
|
||||
msgid "Bundle is already being processed."
|
||||
msgstr ""
|
||||
|
||||
#: documents/views.py:4755
|
||||
#: documents/views.py:4749
|
||||
msgid "The share link bundle is still being prepared. Please try again later."
|
||||
msgstr ""
|
||||
|
||||
#: documents/views.py:4769
|
||||
#: documents/views.py:4763
|
||||
msgid "The share link bundle is unavailable."
|
||||
msgstr ""
|
||||
|
||||
|
||||
@@ -4,21 +4,24 @@ from django.conf import settings
|
||||
from django.contrib.auth.models import User
|
||||
|
||||
from documents.models import Document
|
||||
from documents.permissions import get_objects_for_user_owner_aware
|
||||
from documents.permissions import permitted_object_ids
|
||||
from documents.permissions import restrict_queryset_to_visible
|
||||
from documents.permissions import user_is_unrestricted
|
||||
from paperless.config import AIConfig
|
||||
from paperless_ai.base_model import ClassificationSuggestions
|
||||
from paperless_ai.base_model import TaxonomyChoiceDict
|
||||
from paperless_ai.base_model import classification_suggestions_to_model
|
||||
from paperless_ai.client import AIClient
|
||||
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 truncate_content
|
||||
from paperless_ai.prompts.context import ClassificationPromptContext
|
||||
from paperless_ai.prompts.context import LocalizationPromptContext
|
||||
from paperless_ai.prompts.context import RagContextPromptContext
|
||||
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 _node_document_weights
|
||||
from paperless_ai.taxonomy import build_taxonomy_candidates
|
||||
from paperless_ai.taxonomy import empty_taxonomy_candidates
|
||||
from paperless_ai.taxonomy import format_taxonomy_for_prompt
|
||||
@@ -37,6 +40,48 @@ logger = logging.getLogger("paperless_ai.rag_classifier")
|
||||
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:
|
||||
normalized_language_code = language_code.lower()
|
||||
for code, name in settings.LANGUAGES:
|
||||
@@ -136,43 +181,52 @@ def get_taxonomy_context(
|
||||
user: User | None = None,
|
||||
max_docs: int = 5,
|
||||
) -> tuple[TaxonomyCandidates, str]:
|
||||
"""One retrieval feeds both taxonomy candidates and RAG text context.
|
||||
On any retrieval failure, degrades to empty candidates/context rather than
|
||||
propagating the exception - a vector-store outage should not block
|
||||
classification, only its RAG-assisted enrichment.
|
||||
"""One retrieval feeds both taxonomy candidates and RAG text context. Uses
|
||||
vector similarity when an embedding backend is configured, otherwise
|
||||
falls back to Tantivy full-text "More Like This" similarity - see
|
||||
_fulltext_similar_documents. On any retrieval failure, degrades to empty
|
||||
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:
|
||||
# None means "no restriction" to retrieve_similar_nodes. A superuser
|
||||
# (like no user at all) can see every document, so skip materializing
|
||||
# every visible pk into a Python list and passing it through as an IN
|
||||
# filter: for a large library that is a wasted quadratic scan in the
|
||||
# vector store at best, and past ~32,763 documents a hard
|
||||
# sqlite3.OperationalError (SQLite's bound-parameter limit) at worst.
|
||||
# get_objects_for_user_owner_aware() would return every Document for a
|
||||
# superuser anyway (guardian's own with_superuser shortcut), so this
|
||||
# changes nothing about which documents are considered -- only how we
|
||||
# get there.
|
||||
visible_document_ids = (
|
||||
None
|
||||
if user is None or user.is_superuser
|
||||
else list(
|
||||
get_objects_for_user_owner_aware(
|
||||
user,
|
||||
"view_document",
|
||||
Document,
|
||||
).values_list("pk", flat=True),
|
||||
if ai_config.llm_embedding_backend:
|
||||
# None means "no restriction" to retrieve_similar_nodes. An
|
||||
# unrestricted user (no user at all, or an active superuser -- see
|
||||
# user_is_unrestricted) can see every document, so skip
|
||||
# materializing every visible pk into a Python list and passing it
|
||||
# through as an IN filter: for a large library that is a wasted
|
||||
# quadratic scan in the vector store at best, and past ~32,763
|
||||
# documents a hard sqlite3.OperationalError (SQLite's
|
||||
# bound-parameter limit) at worst.
|
||||
# permitted_object_ids() has its own superuser shortcut that would
|
||||
# return every Document's id anyway, so this changes nothing about
|
||||
# which documents are considered -- only how we get there.
|
||||
visible_document_ids = (
|
||||
None
|
||||
if user_is_unrestricted(user)
|
||||
else list(permitted_object_ids(user, Document, "view_document"))
|
||||
)
|
||||
nodes = retrieve_similar_nodes(
|
||||
document,
|
||||
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(nodes, user)
|
||||
candidates = build_taxonomy_candidates(similar_documents, user)
|
||||
|
||||
# ``nodes`` are already ordered by descending vector similarity; don't lose it.
|
||||
similar_document_ids = list(dict.fromkeys(_node_document_ids(nodes)))
|
||||
# similar_documents is already ordered by descending weight; don't lose it.
|
||||
similar_document_ids = [s["document_id"] for s in similar_documents]
|
||||
similar_documents_by_id = Document.objects.in_bulk(similar_document_ids)
|
||||
similar_docs = [
|
||||
similar_documents_by_id[document_id]
|
||||
@@ -186,8 +240,8 @@ def get_taxonomy_context(
|
||||
context_blocks.append(f"TITLE: {title}\n{text}")
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Failed to retrieve RAG neighbours for document %s; continuing "
|
||||
"without taxonomy candidates or similar-document context.",
|
||||
"Failed to retrieve similar-document context for document %s; "
|
||||
"continuing without taxonomy candidates or similar-document context.",
|
||||
document.pk,
|
||||
)
|
||||
return empty_taxonomy_candidates(), ""
|
||||
@@ -241,17 +295,13 @@ def get_ai_document_classification(
|
||||
) -> ClassificationSuggestions:
|
||||
ai_config = AIConfig()
|
||||
|
||||
if ai_config.llm_embedding_backend:
|
||||
candidates, context = get_taxonomy_context(document, user)
|
||||
prompt = build_prompt_with_rag(
|
||||
document,
|
||||
ai_config,
|
||||
candidates=candidates,
|
||||
context=context,
|
||||
)
|
||||
else:
|
||||
candidates = empty_taxonomy_candidates()
|
||||
prompt = build_prompt_without_rag(document, ai_config, candidates=candidates)
|
||||
candidates, context = get_taxonomy_context(document, user)
|
||||
prompt = build_prompt_with_rag(
|
||||
document,
|
||||
ai_config,
|
||||
candidates=candidates,
|
||||
context=context,
|
||||
)
|
||||
|
||||
client = AIClient()
|
||||
# Hand the pooled DB connection back while the (slow) LLM query runs so it
|
||||
|
||||
@@ -721,20 +721,3 @@ def retrieve_similar_nodes(
|
||||
continue
|
||||
filtered.append(node)
|
||||
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
|
||||
|
||||
@@ -31,6 +31,11 @@ class TaxonomyCandidate(TypedDict):
|
||||
weight: float
|
||||
|
||||
|
||||
class SimilarDocument(TypedDict):
|
||||
document_id: int
|
||||
weight: float
|
||||
|
||||
|
||||
class TaxonomyCandidates(TypedDict):
|
||||
tags: list[TaxonomyCandidate]
|
||||
document_types: list[TaxonomyCandidate]
|
||||
@@ -49,10 +54,10 @@ def empty_taxonomy_candidates() -> TaxonomyCandidates:
|
||||
)
|
||||
|
||||
|
||||
def _node_document_weights(nodes: list["NodeWithScore"]) -> dict[int, float]:
|
||||
"""document_id -> that node's similarity score, summed if a document_id
|
||||
appears more than once across the retrieved nodes (e.g. multiple chunks
|
||||
of the same source document)."""
|
||||
def _node_document_weights(nodes: list["NodeWithScore"]) -> list[SimilarDocument]:
|
||||
"""Sum each node's similarity score into its document_id (a document can
|
||||
appear via multiple chunks/nodes) and return one SimilarDocument per
|
||||
distinct document_id."""
|
||||
weights: dict[int, float] = defaultdict(float)
|
||||
for node in nodes:
|
||||
document_id = node.metadata.get("document_id")
|
||||
@@ -65,7 +70,14 @@ def _node_document_weights(nodes: list["NodeWithScore"]) -> dict[int, float]:
|
||||
weights[int(document_id)] += float(node.score or 0.0)
|
||||
except (TypeError, ValueError): # pragma: no cover
|
||||
continue
|
||||
return weights
|
||||
return sorted(
|
||||
(
|
||||
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(
|
||||
@@ -101,21 +113,26 @@ def _visible_ranked_candidates(
|
||||
|
||||
|
||||
def build_taxonomy_candidates(
|
||||
nodes: list["NodeWithScore"],
|
||||
similar_documents: list[SimilarDocument],
|
||||
user: User | None,
|
||||
) -> TaxonomyCandidates:
|
||||
"""Resolve each neighbour node's document_id to a live Document, read its
|
||||
*current* tags/type/correspondent/storage_path via the ORM (never the
|
||||
possibly-stale names cached in vector-index node metadata), weight each
|
||||
distinct taxonomy object by aggregate neighbour similarity, permission-filter
|
||||
"""Resolve each similar document's id to a live Document, read its
|
||||
*current* tags/type/correspondent/storage_path via the ORM (never any
|
||||
possibly-stale names an adapter's source might have cached), weight each
|
||||
distinct taxonomy object by aggregate similarity weight, permission-filter
|
||||
against what ``user`` can see, and return each category ranked by weight
|
||||
and capped.
|
||||
and capped. ``similar_documents`` may come from either the vector-RAG
|
||||
adapter or the full-text fallback adapter - both produce this same shape.
|
||||
"""
|
||||
|
||||
document_weights = _node_document_weights(nodes)
|
||||
if not document_weights:
|
||||
if not similar_documents:
|
||||
return empty_taxonomy_candidates()
|
||||
|
||||
# Both adapters guarantee at most one SimilarDocument per document_id, so
|
||||
# this never silently drops a duplicate's weight.
|
||||
document_weights: dict[int, float] = {
|
||||
s["document_id"]: s["weight"] for s in similar_documents
|
||||
}
|
||||
|
||||
# Only .tags.all() needs prefetching (a reverse M2M, one extra query for
|
||||
# the whole batch). document_type/correspondent/storage_path are read
|
||||
# below via their *_id columns (neighbour.document_type_id, etc.), which
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import datetime
|
||||
from collections.abc import Generator
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
from unittest.mock import patch
|
||||
@@ -6,18 +7,24 @@ from unittest.mock import patch
|
||||
import pytest
|
||||
import pytest_mock
|
||||
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.search import TantivyBackend
|
||||
from documents.tests.factories import DocumentFactory
|
||||
from documents.tests.factories import TagFactory
|
||||
from documents.tests.factories import UserFactory
|
||||
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_prompt_with_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_language_name
|
||||
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 TaxonomyCandidates
|
||||
|
||||
@@ -220,12 +227,10 @@ def test_use_rag_if_configured(
|
||||
|
||||
@pytest.mark.django_db
|
||||
@patch("paperless_ai.client.AIClient.run_llm_query")
|
||||
@patch("paperless_ai.ai_classifier.build_prompt_without_rag")
|
||||
@patch("paperless_ai.ai_classifier.AIConfig")
|
||||
@patch("paperless_ai.ai_classifier.build_prompt_with_rag")
|
||||
@override_settings(LLM_BACKEND="ollama", LLM_MODEL="some_model")
|
||||
def test_use_without_rag_if_not_configured(
|
||||
mock_ai_config,
|
||||
mock_build_prompt_without_rag,
|
||||
def test_use_rag_prompt_even_without_embedding_backend(
|
||||
mock_build_prompt_with_rag,
|
||||
mock_run_llm_query,
|
||||
mock_document,
|
||||
):
|
||||
@@ -235,13 +240,13 @@ def test_use_without_rag_if_not_configured(
|
||||
WHEN:
|
||||
- get_ai_document_classification() is called
|
||||
THEN:
|
||||
- The non-RAG prompt builder is used
|
||||
- The RAG-context prompt builder is still used (fed by the full-text
|
||||
fallback's context/candidates instead of the vector store's)
|
||||
"""
|
||||
mock_ai_config.return_value.llm_embedding_backend = None
|
||||
mock_build_prompt_without_rag.return_value = "Prompt without RAG"
|
||||
mock_build_prompt_with_rag.return_value = "Prompt with RAG"
|
||||
mock_run_llm_query.return_value = NESTED_SUGGESTIONS
|
||||
get_ai_document_classification(mock_document)
|
||||
mock_build_prompt_without_rag.assert_called_once()
|
||||
mock_build_prompt_with_rag.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@@ -320,6 +325,7 @@ def test_build_localization_prompt_preserves_unicode_characters():
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@override_settings(LLM_EMBEDDING_BACKEND="huggingface")
|
||||
def test_get_taxonomy_context_assembles_rag_text_and_candidates():
|
||||
"""
|
||||
GIVEN:
|
||||
@@ -354,6 +360,7 @@ def test_get_taxonomy_context_assembles_rag_text_and_candidates():
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@override_settings(LLM_EMBEDDING_BACKEND="huggingface")
|
||||
def test_get_taxonomy_context_preserves_similarity_order_and_distinct_documents():
|
||||
"""
|
||||
GIVEN:
|
||||
@@ -424,6 +431,7 @@ def test_get_taxonomy_context_preserves_similarity_order_and_distinct_documents(
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@override_settings(LLM_EMBEDDING_BACKEND="huggingface")
|
||||
def test_get_taxonomy_context_no_similar_docs():
|
||||
"""
|
||||
GIVEN:
|
||||
@@ -447,6 +455,67 @@ 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:
|
||||
"""get_taxonomy_context must not materialize every visible document id
|
||||
for a user who can already see the whole library: a superuser (like no
|
||||
@@ -459,6 +528,7 @@ class TestGetTaxonomyContextVisibility:
|
||||
"""
|
||||
|
||||
@pytest.mark.django_db
|
||||
@override_settings(LLM_EMBEDDING_BACKEND="huggingface")
|
||||
def test_skips_permission_lookup_for_superuser(
|
||||
self,
|
||||
mocker: pytest_mock.MockerFixture,
|
||||
@@ -477,17 +547,18 @@ class TestGetTaxonomyContextVisibility:
|
||||
"paperless_ai.ai_classifier.retrieve_similar_nodes",
|
||||
return_value=[],
|
||||
)
|
||||
mock_get_objects = mocker.patch(
|
||||
"paperless_ai.ai_classifier.get_objects_for_user_owner_aware",
|
||||
mock_permitted = mocker.patch(
|
||||
"paperless_ai.ai_classifier.permitted_object_ids",
|
||||
)
|
||||
user = UserFactory.create(is_superuser=True)
|
||||
|
||||
get_taxonomy_context(document, user)
|
||||
|
||||
mock_get_objects.assert_not_called()
|
||||
mock_permitted.assert_not_called()
|
||||
assert mock_retrieve.call_args.kwargs["document_ids"] is None
|
||||
|
||||
@pytest.mark.django_db
|
||||
@override_settings(LLM_EMBEDDING_BACKEND="huggingface")
|
||||
def test_skips_permission_lookup_when_no_user(
|
||||
self,
|
||||
mocker: pytest_mock.MockerFixture,
|
||||
@@ -506,16 +577,17 @@ class TestGetTaxonomyContextVisibility:
|
||||
"paperless_ai.ai_classifier.retrieve_similar_nodes",
|
||||
return_value=[],
|
||||
)
|
||||
mock_get_objects = mocker.patch(
|
||||
"paperless_ai.ai_classifier.get_objects_for_user_owner_aware",
|
||||
mock_permitted = mocker.patch(
|
||||
"paperless_ai.ai_classifier.permitted_object_ids",
|
||||
)
|
||||
|
||||
get_taxonomy_context(document, None)
|
||||
|
||||
mock_get_objects.assert_not_called()
|
||||
mock_permitted.assert_not_called()
|
||||
assert mock_retrieve.call_args.kwargs["document_ids"] is None
|
||||
|
||||
@pytest.mark.django_db
|
||||
@override_settings(LLM_EMBEDDING_BACKEND="huggingface")
|
||||
def test_restricts_to_visible_documents_for_non_superuser(
|
||||
self,
|
||||
mocker: pytest_mock.MockerFixture,
|
||||
@@ -526,7 +598,7 @@ class TestGetTaxonomyContextVisibility:
|
||||
WHEN:
|
||||
- get_taxonomy_context() is called
|
||||
THEN:
|
||||
- The user's visible document ids are looked up and passed to
|
||||
- The user's permitted document ids are looked up and passed to
|
||||
retrieve_similar_nodes() as a restriction
|
||||
"""
|
||||
document = DocumentFactory.create(content="Some content")
|
||||
@@ -534,21 +606,232 @@ class TestGetTaxonomyContextVisibility:
|
||||
"paperless_ai.ai_classifier.retrieve_similar_nodes",
|
||||
return_value=[],
|
||||
)
|
||||
mock_queryset = mocker.MagicMock()
|
||||
mock_queryset.values_list.return_value = [1, 2, 3]
|
||||
mock_get_objects = mocker.patch(
|
||||
"paperless_ai.ai_classifier.get_objects_for_user_owner_aware",
|
||||
return_value=mock_queryset,
|
||||
mock_permitted = mocker.patch(
|
||||
"paperless_ai.ai_classifier.permitted_object_ids",
|
||||
return_value=[1, 2, 3],
|
||||
)
|
||||
user = UserFactory.create(is_superuser=False)
|
||||
|
||||
get_taxonomy_context(document, user)
|
||||
|
||||
mock_get_objects.assert_called_once_with(user, "view_document", Document)
|
||||
mock_permitted.assert_called_once_with(user, Document, "view_document")
|
||||
assert mock_retrieve.call_args.kwargs["document_ids"] == [1, 2, 3]
|
||||
|
||||
|
||||
@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")
|
||||
def test_get_taxonomy_context_retrieval_failure_degrades_to_no_hints(mock_retrieve):
|
||||
"""
|
||||
@@ -575,6 +858,7 @@ def test_get_taxonomy_context_retrieval_failure_degrades_to_no_hints(mock_retrie
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@override_settings(LLM_EMBEDDING_BACKEND="huggingface")
|
||||
@patch("paperless_ai.ai_classifier.build_taxonomy_candidates")
|
||||
@patch("paperless_ai.ai_classifier.retrieve_similar_nodes")
|
||||
def test_get_taxonomy_context_candidate_building_failure_degrades_to_no_hints(
|
||||
|
||||
@@ -1188,9 +1188,7 @@ class TestRetrieveSimilarNodesAgainstRealIndex:
|
||||
|
||||
nodes = indexing.retrieve_similar_nodes(a, document_ids=[b.id])
|
||||
|
||||
assert all(
|
||||
document_id == b.id for document_id in indexing._node_document_ids(nodes)
|
||||
)
|
||||
assert all(int(node.metadata["document_id"]) == b.id for node in nodes)
|
||||
|
||||
def test_excludes_self(
|
||||
self,
|
||||
@@ -1212,7 +1210,7 @@ class TestRetrieveSimilarNodesAgainstRealIndex:
|
||||
|
||||
nodes = indexing.retrieve_similar_nodes(a, top_k=5)
|
||||
|
||||
assert set(indexing._node_document_ids(nodes)) == {b.id}
|
||||
assert {int(node.metadata["document_id"]) for node in nodes} == {b.id}
|
||||
|
||||
def test_excludes_self_with_multiple_chunks(
|
||||
self,
|
||||
@@ -1235,4 +1233,4 @@ class TestRetrieveSimilarNodesAgainstRealIndex:
|
||||
|
||||
nodes = indexing.retrieve_similar_nodes(a, top_k=3)
|
||||
|
||||
assert set(indexing._node_document_ids(nodes)) == {b.id}
|
||||
assert {int(node.metadata["document_id"]) for node in nodes} == {b.id}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
import pytest_mock
|
||||
@@ -10,14 +9,14 @@ from documents.tests.factories import DocumentTypeFactory
|
||||
from documents.tests.factories import StoragePathFactory
|
||||
from documents.tests.factories import TagFactory
|
||||
from documents.tests.factories import UserFactory
|
||||
from paperless_ai.taxonomy import SimilarDocument
|
||||
from paperless_ai.taxonomy import TaxonomyCandidates
|
||||
from paperless_ai.taxonomy import build_taxonomy_candidates
|
||||
from paperless_ai.taxonomy import format_taxonomy_for_prompt
|
||||
|
||||
|
||||
def make_node(document_id: int, score: float) -> SimpleNamespace:
|
||||
"""A stand-in for NodeWithScore: only ``.metadata``/``.score`` are read."""
|
||||
return SimpleNamespace(metadata={"document_id": str(document_id)}, score=score)
|
||||
def make_similar(document_id: int, weight: float) -> SimilarDocument:
|
||||
return SimilarDocument(document_id=document_id, weight=weight)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@@ -53,9 +52,9 @@ class TestBuildTaxonomyCandidates:
|
||||
doc_a.tags.add(tag)
|
||||
doc_b = DocumentFactory.create()
|
||||
doc_b.tags.add(tag)
|
||||
nodes = [make_node(doc_a.pk, 0.9), make_node(doc_b.pk, 0.4)]
|
||||
similar_documents = [make_similar(doc_a.pk, 0.9), make_similar(doc_b.pk, 0.4)]
|
||||
|
||||
result = build_taxonomy_candidates(nodes, user=None)
|
||||
result = build_taxonomy_candidates(similar_documents, user=None)
|
||||
|
||||
assert len(result["tags"]) == 1
|
||||
assert result["tags"][0]["id"] == tag.pk
|
||||
@@ -80,9 +79,9 @@ class TestBuildTaxonomyCandidates:
|
||||
document.tags.add(tag)
|
||||
tag.name = "New Name"
|
||||
tag.save()
|
||||
nodes = [make_node(document.pk, 0.5)]
|
||||
similar_documents = [make_similar(document.pk, 0.5)]
|
||||
|
||||
result = build_taxonomy_candidates(nodes, user=None)
|
||||
result = build_taxonomy_candidates(similar_documents, user=None)
|
||||
|
||||
assert result["tags"][0]["name"] == "New Name"
|
||||
|
||||
@@ -102,9 +101,9 @@ class TestBuildTaxonomyCandidates:
|
||||
document = DocumentFactory.create()
|
||||
document.tags.add(tag)
|
||||
tag.delete()
|
||||
nodes = [make_node(document.pk, 0.5)]
|
||||
similar_documents = [make_similar(document.pk, 0.5)]
|
||||
|
||||
result = build_taxonomy_candidates(nodes, user=None)
|
||||
result = build_taxonomy_candidates(similar_documents, user=None)
|
||||
|
||||
assert result["tags"] == []
|
||||
|
||||
@@ -123,9 +122,12 @@ class TestBuildTaxonomyCandidates:
|
||||
strong_doc.tags.add(strong_tag)
|
||||
weak_doc = DocumentFactory.create()
|
||||
weak_doc.tags.add(weak_tag)
|
||||
nodes = [make_node(strong_doc.pk, 0.9), make_node(weak_doc.pk, 0.1)]
|
||||
similar_documents = [
|
||||
make_similar(strong_doc.pk, 0.9),
|
||||
make_similar(weak_doc.pk, 0.1),
|
||||
]
|
||||
|
||||
result = build_taxonomy_candidates(nodes, user=None)
|
||||
result = build_taxonomy_candidates(similar_documents, user=None)
|
||||
|
||||
assert [c["name"] for c in result["tags"]] == ["Strong", "Weak"]
|
||||
|
||||
@@ -141,9 +143,9 @@ class TestBuildTaxonomyCandidates:
|
||||
document = DocumentFactory.create()
|
||||
for i in range(15):
|
||||
document.tags.add(TagFactory.create(name=f"Tag{i}"))
|
||||
nodes = [make_node(document.pk, 0.5)]
|
||||
similar_documents = [make_similar(document.pk, 0.5)]
|
||||
|
||||
result = build_taxonomy_candidates(nodes, user=None)
|
||||
result = build_taxonomy_candidates(similar_documents, user=None)
|
||||
|
||||
assert len(result["tags"]) == 10
|
||||
|
||||
@@ -157,12 +159,12 @@ class TestBuildTaxonomyCandidates:
|
||||
- Only 5 correspondents are returned
|
||||
"""
|
||||
correspondents = CorrespondentFactory.create_batch(7)
|
||||
nodes = [
|
||||
make_node(DocumentFactory.create(correspondent=c).pk, 0.5)
|
||||
similar_documents = [
|
||||
make_similar(DocumentFactory.create(correspondent=c).pk, 0.5)
|
||||
for c in correspondents
|
||||
]
|
||||
|
||||
result = build_taxonomy_candidates(nodes, user=None)
|
||||
result = build_taxonomy_candidates(similar_documents, user=None)
|
||||
|
||||
assert len(result["correspondents"]) == 5
|
||||
|
||||
@@ -177,9 +179,9 @@ class TestBuildTaxonomyCandidates:
|
||||
"""
|
||||
document_type = DocumentTypeFactory.create(name="Invoice")
|
||||
document = DocumentFactory.create(document_type=document_type)
|
||||
nodes = [make_node(document.pk, 0.5)]
|
||||
similar_documents = [make_similar(document.pk, 0.5)]
|
||||
|
||||
result = build_taxonomy_candidates(nodes, user=None)
|
||||
result = build_taxonomy_candidates(similar_documents, user=None)
|
||||
|
||||
assert len(result["document_types"]) == 1
|
||||
assert result["document_types"][0]["id"] == document_type.pk
|
||||
@@ -195,12 +197,12 @@ class TestBuildTaxonomyCandidates:
|
||||
- Only 5 document_types are returned
|
||||
"""
|
||||
document_types = DocumentTypeFactory.create_batch(7)
|
||||
nodes = [
|
||||
make_node(DocumentFactory.create(document_type=dt).pk, 0.5)
|
||||
similar_documents = [
|
||||
make_similar(DocumentFactory.create(document_type=dt).pk, 0.5)
|
||||
for dt in document_types
|
||||
]
|
||||
|
||||
result = build_taxonomy_candidates(nodes, user=None)
|
||||
result = build_taxonomy_candidates(similar_documents, user=None)
|
||||
|
||||
assert len(result["document_types"]) == 5
|
||||
|
||||
@@ -215,9 +217,9 @@ class TestBuildTaxonomyCandidates:
|
||||
"""
|
||||
storage_path = StoragePathFactory.create(name="Invoices")
|
||||
document = DocumentFactory.create(storage_path=storage_path)
|
||||
nodes = [make_node(document.pk, 0.5)]
|
||||
similar_documents = [make_similar(document.pk, 0.5)]
|
||||
|
||||
result = build_taxonomy_candidates(nodes, user=None)
|
||||
result = build_taxonomy_candidates(similar_documents, user=None)
|
||||
|
||||
assert len(result["storage_paths"]) == 1
|
||||
assert result["storage_paths"][0]["id"] == storage_path.pk
|
||||
@@ -233,12 +235,12 @@ class TestBuildTaxonomyCandidates:
|
||||
- Only 5 storage_paths are returned
|
||||
"""
|
||||
storage_paths = StoragePathFactory.create_batch(7)
|
||||
nodes = [
|
||||
make_node(DocumentFactory.create(storage_path=sp).pk, 0.5)
|
||||
similar_documents = [
|
||||
make_similar(DocumentFactory.create(storage_path=sp).pk, 0.5)
|
||||
for sp in storage_paths
|
||||
]
|
||||
|
||||
result = build_taxonomy_candidates(nodes, user=None)
|
||||
result = build_taxonomy_candidates(similar_documents, user=None)
|
||||
|
||||
assert len(result["storage_paths"]) == 5
|
||||
|
||||
@@ -258,14 +260,14 @@ class TestBuildTaxonomyCandidates:
|
||||
tag = TagFactory.create(name="Restricted")
|
||||
document = DocumentFactory.create()
|
||||
document.tags.add(tag)
|
||||
nodes = [make_node(document.pk, 0.5)]
|
||||
similar_documents = [make_similar(document.pk, 0.5)]
|
||||
user = UserFactory.create()
|
||||
mocker.patch(
|
||||
"documents.permissions.permitted_object_ids",
|
||||
return_value=[], # user cannot see this tag
|
||||
)
|
||||
|
||||
result = build_taxonomy_candidates(nodes, user=user)
|
||||
result = build_taxonomy_candidates(similar_documents, user=user)
|
||||
|
||||
assert result["tags"] == []
|
||||
|
||||
@@ -295,10 +297,10 @@ class TestBuildTaxonomyCandidates:
|
||||
tag.save()
|
||||
document = DocumentFactory.create()
|
||||
document.tags.add(tag)
|
||||
nodes = [make_node(document.pk, 0.5)]
|
||||
similar_documents = [make_similar(document.pk, 0.5)]
|
||||
spy = mocker.patch("documents.permissions.permitted_object_ids")
|
||||
|
||||
result = build_taxonomy_candidates(nodes, user=None)
|
||||
result = build_taxonomy_candidates(similar_documents, user=None)
|
||||
|
||||
assert result["tags"][0]["name"] == "Owned"
|
||||
spy.assert_not_called()
|
||||
|
||||
Reference in New Issue
Block a user