mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-10-11 02:27:13 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
78017d501c | ||
|
|
3e0345478b |
No files matched your search
@@ -50,6 +50,7 @@ from documents.models import ShareLink
|
|||||||
from documents.models import ShareLinkBundle
|
from documents.models import ShareLinkBundle
|
||||||
from documents.models import StoragePath
|
from documents.models import StoragePath
|
||||||
from documents.models import Tag
|
from documents.models import Tag
|
||||||
|
from documents.permissions import annotate_authorizing_fields
|
||||||
from documents.permissions import permitted_document_ids
|
from documents.permissions import permitted_document_ids
|
||||||
from documents.permissions import permitted_object_ids
|
from documents.permissions import permitted_object_ids
|
||||||
from documents.versioning import annotate_effective_content
|
from documents.versioning import annotate_effective_content
|
||||||
@@ -1075,6 +1076,9 @@ class PermittedObjectsFilter(BaseFilterBackend):
|
|||||||
|
|
||||||
include_granted: bool = True
|
include_granted: bool = True
|
||||||
perm_codename: str | None = None
|
perm_codename: str | None = None
|
||||||
|
# A self-referencing foreign key whose target authorizes a row, so a row is
|
||||||
|
# judged by its parent's owner and grants (``Document.root_document``).
|
||||||
|
parent_field: str | None = None
|
||||||
|
|
||||||
def filter_queryset(self, request, queryset, view):
|
def filter_queryset(self, request, queryset, view):
|
||||||
# Before the superuser and owner-only paths, neither of which consults
|
# Before the superuser and owner-only paths, neither of which consults
|
||||||
@@ -1086,11 +1090,23 @@ class PermittedObjectsFilter(BaseFilterBackend):
|
|||||||
if request.user.is_superuser:
|
if request.user.is_superuser:
|
||||||
return queryset
|
return queryset
|
||||||
if not self.include_granted:
|
if not self.include_granted:
|
||||||
return queryset.filter(Q(owner=request.user) | Q(owner__isnull=True))
|
owner_field = "owner"
|
||||||
|
if self.parent_field is not None:
|
||||||
|
queryset = annotate_authorizing_fields(queryset, self.parent_field)
|
||||||
|
owner_field = "authorizing_owner"
|
||||||
|
return queryset.filter(
|
||||||
|
Q(**{owner_field: request.user.pk})
|
||||||
|
| Q(**{f"{owner_field}__isnull": True}),
|
||||||
|
)
|
||||||
model = queryset.model
|
model = queryset.model
|
||||||
perm = self.perm_codename or f"view_{model._meta.model_name}"
|
perm = self.perm_codename or f"view_{model._meta.model_name}"
|
||||||
return queryset.filter(
|
return queryset.filter(
|
||||||
id__in=permitted_object_ids(request.user, model, perm),
|
id__in=permitted_object_ids(
|
||||||
|
request.user,
|
||||||
|
model,
|
||||||
|
perm,
|
||||||
|
parent_field=self.parent_field,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -389,14 +389,7 @@ def permitted_object_ids(
|
|||||||
owner_field, key_field = "owner", "pk"
|
owner_field, key_field = "owner", "pk"
|
||||||
if parent_field is not None:
|
if parent_field is not None:
|
||||||
owner_field, key_field = "authorizing_owner", "authorizing_id"
|
owner_field, key_field = "authorizing_owner", "authorizing_id"
|
||||||
base_qs = base_qs.annotate(
|
base_qs = annotate_authorizing_fields(base_qs, parent_field)
|
||||||
authorizing_id=Coalesce(f"{parent_field}_id", "id"),
|
|
||||||
authorizing_owner=Case(
|
|
||||||
When(**{f"{parent_field}_id__isnull": True}, then=F("owner_id")),
|
|
||||||
default=F(f"{parent_field}__owner_id"),
|
|
||||||
output_field=IntegerField(),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
unowned = Q(**{f"{owner_field}__isnull": True})
|
unowned = Q(**{f"{owner_field}__isnull": True})
|
||||||
|
|
||||||
if user is None or not getattr(user, "is_authenticated", False):
|
if user is None or not getattr(user, "is_authenticated", False):
|
||||||
@@ -449,6 +442,26 @@ def permitted_object_ids(
|
|||||||
ModelT = TypeVar("ModelT", bound=Model)
|
ModelT = TypeVar("ModelT", bound=Model)
|
||||||
|
|
||||||
|
|
||||||
|
def annotate_authorizing_fields(
|
||||||
|
queryset: QuerySet[ModelT],
|
||||||
|
parent_field: str,
|
||||||
|
) -> QuerySet[ModelT]:
|
||||||
|
"""
|
||||||
|
Annotate each row with ``authorizing_id`` and ``authorizing_owner``: the id
|
||||||
|
and owner of the row that authorizes it. A row with a parent (the
|
||||||
|
self-referencing foreign key ``parent_field``) is authorized by its parent,
|
||||||
|
any other row by itself.
|
||||||
|
"""
|
||||||
|
return queryset.annotate(
|
||||||
|
authorizing_id=Coalesce(f"{parent_field}_id", "id"),
|
||||||
|
authorizing_owner=Case(
|
||||||
|
When(**{f"{parent_field}_id__isnull": True}, then=F("owner_id")),
|
||||||
|
default=F(f"{parent_field}__owner_id"),
|
||||||
|
output_field=IntegerField(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def user_is_unrestricted(user: User | None) -> bool:
|
def user_is_unrestricted(user: User | None) -> bool:
|
||||||
"""
|
"""
|
||||||
True when ``user`` means "no restriction at all" (an absent user, or an
|
True when ``user`` means "no restriction at all" (an absent user, or an
|
||||||
|
|||||||
+1
-17
@@ -5602,23 +5602,7 @@ class TrashView(ListModelMixin, PassUserMixin):
|
|||||||
|
|
||||||
class _TrashPermittedObjectsFilter(PermittedObjectsFilter):
|
class _TrashPermittedObjectsFilter(PermittedObjectsFilter):
|
||||||
include_granted = False
|
include_granted = False
|
||||||
|
parent_field = "root_document"
|
||||||
def filter_queryset(self, request, queryset, view):
|
|
||||||
if request.user.is_superuser or not request.user.is_active:
|
|
||||||
return super().filter_queryset(request, queryset, view)
|
|
||||||
|
|
||||||
# A version belongs to whoever owns its root
|
|
||||||
def owned_or_unowned(prefix: str) -> Q:
|
|
||||||
return Q(**{f"{prefix}owner": request.user}) | Q(
|
|
||||||
**{f"{prefix}owner__isnull": True},
|
|
||||||
)
|
|
||||||
|
|
||||||
return queryset.filter(
|
|
||||||
(Q(root_document__isnull=True) & owned_or_unowned(""))
|
|
||||||
| (
|
|
||||||
Q(root_document__isnull=False) & owned_or_unowned("root_document__")
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
filter_backends = (_TrashPermittedObjectsFilter,)
|
filter_backends = (_TrashPermittedObjectsFilter,)
|
||||||
pagination_class = StandardPagination
|
pagination_class = StandardPagination
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from django.contrib.auth.models import User
|
|||||||
from documents.models import Document
|
from documents.models import Document
|
||||||
from documents.permissions import permitted_document_ids
|
from documents.permissions import permitted_document_ids
|
||||||
from documents.permissions import user_is_unrestricted
|
from documents.permissions import user_is_unrestricted
|
||||||
|
from documents.versioning import annotate_effective_content
|
||||||
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
|
||||||
@@ -111,7 +112,7 @@ def build_prompt_without_rag(
|
|||||||
) -> str:
|
) -> str:
|
||||||
filename = document.filename or ""
|
filename = document.filename or ""
|
||||||
content = truncate_content(
|
content = truncate_content(
|
||||||
document.content[:4000] or "",
|
(document.get_effective_content() or "")[:4000],
|
||||||
chunk_size=config.llm_embedding_chunk_size,
|
chunk_size=config.llm_embedding_chunk_size,
|
||||||
context_size=config.llm_context_size,
|
context_size=config.llm_context_size,
|
||||||
)
|
)
|
||||||
@@ -225,7 +226,9 @@ def get_taxonomy_context(
|
|||||||
|
|
||||||
# similar_documents is already ordered by descending weight; don't lose it.
|
# similar_documents is already ordered by descending weight; don't lose it.
|
||||||
similar_document_ids = [s["document_id"] for s in similar_documents]
|
similar_document_ids = [s["document_id"] for s in similar_documents]
|
||||||
similar_documents_by_id = Document.objects.in_bulk(similar_document_ids)
|
similar_documents_by_id = annotate_effective_content(
|
||||||
|
Document.objects.all(),
|
||||||
|
).in_bulk(similar_document_ids)
|
||||||
similar_docs = [
|
similar_docs = [
|
||||||
similar_documents_by_id[document_id]
|
similar_documents_by_id[document_id]
|
||||||
for document_id in similar_document_ids
|
for document_id in similar_document_ids
|
||||||
@@ -233,7 +236,7 @@ def get_taxonomy_context(
|
|||||||
][:max_docs]
|
][:max_docs]
|
||||||
context_blocks = []
|
context_blocks = []
|
||||||
for similar in similar_docs:
|
for similar in similar_docs:
|
||||||
text = similar.content[:1000] or ""
|
text = (similar.get_effective_content() or "")[:1000]
|
||||||
title = similar.title or similar.filename or "Untitled"
|
title = similar.title or similar.filename or "Untitled"
|
||||||
context_blocks.append(f"TITLE: {title}\n{text}")
|
context_blocks.append(f"TITLE: {title}\n{text}")
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|||||||
@@ -135,6 +135,6 @@ def build_llm_index_text(doc: Document) -> str:
|
|||||||
lines.append(f"Custom Field - {instance.field.name}: {instance}")
|
lines.append(f"Custom Field - {instance.field.name}: {instance}")
|
||||||
|
|
||||||
lines.append("\nContent:\n")
|
lines.append("\nContent:\n")
|
||||||
lines.append(doc.content or "")
|
lines.append(doc.get_effective_content() or "")
|
||||||
|
|
||||||
return _normalize_llm_index_text("\n".join(lines))
|
return _normalize_llm_index_text("\n".join(lines))
|
||||||
@@ -17,6 +17,7 @@ from documents.models import PaperlessTask
|
|||||||
from documents.utils import IterWrapper
|
from documents.utils import IterWrapper
|
||||||
from documents.utils import QuerySetStream
|
from documents.utils import QuerySetStream
|
||||||
from documents.utils import identity
|
from documents.utils import identity
|
||||||
|
from documents.versioning import annotate_effective_content
|
||||||
from documents.versioning import root_document_ids
|
from documents.versioning import root_document_ids
|
||||||
from paperless.config import AIConfig
|
from paperless.config import AIConfig
|
||||||
from paperless_ai.db import db_connection_released
|
from paperless_ai.db import db_connection_released
|
||||||
@@ -444,10 +445,10 @@ def update_llm_index(
|
|||||||
"Skipping LLM index update: migration check deferred; "
|
"Skipping LLM index update: migration check deferred; "
|
||||||
"will retry next run."
|
"will retry next run."
|
||||||
)
|
)
|
||||||
documents = (
|
documents = annotate_effective_content(
|
||||||
Document.objects.filter(root_document__isnull=True)
|
Document.objects.filter(root_document__isnull=True)
|
||||||
.select_related("correspondent", "document_type", "storage_path")
|
.select_related("correspondent", "document_type", "storage_path")
|
||||||
.prefetch_related("tags", "notes", "custom_fields__field")
|
.prefetch_related("tags", "notes", "custom_fields__field"),
|
||||||
)
|
)
|
||||||
no_documents = not documents.exists()
|
no_documents = not documents.exists()
|
||||||
|
|
||||||
@@ -694,7 +695,7 @@ def retrieve_similar_nodes(
|
|||||||
)
|
)
|
||||||
|
|
||||||
query_text = truncate_embedding_query(
|
query_text = truncate_embedding_query(
|
||||||
(document.title or "") + "\n" + (document.content or ""),
|
(document.title or "") + "\n" + (document.get_effective_content() or ""),
|
||||||
chunk_size=config.llm_embedding_chunk_size,
|
chunk_size=config.llm_embedding_chunk_size,
|
||||||
)
|
)
|
||||||
# Hold the shared read lock for the whole retrieval so the connection is
|
# Hold the shared read lock for the whole retrieval so the connection is
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ def mock_document():
|
|||||||
doc.storage_path = None
|
doc.storage_path = None
|
||||||
doc.archive_serial_number = "12345"
|
doc.archive_serial_number = "12345"
|
||||||
doc.content = "This is the document content."
|
doc.content = "This is the document content."
|
||||||
|
doc.get_effective_content.return_value = "This is the document content."
|
||||||
|
|
||||||
cf1 = MagicMock(__str__=lambda x: "Value1")
|
cf1 = MagicMock(__str__=lambda x: "Value1")
|
||||||
cf1.field = MagicMock()
|
cf1.field = MagicMock()
|
||||||
@@ -434,6 +435,55 @@ def test_get_taxonomy_context_preserves_similarity_order_and_distinct_documents(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
class TestClassifierEffectiveContent:
|
||||||
|
"""A root document's text for the LLM is its newest version's content."""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _root_with_version() -> Document:
|
||||||
|
root = DocumentFactory(title="Statement", content="stale text")
|
||||||
|
DocumentFactory(root_document=root, version_index=1, content="latest text")
|
||||||
|
return root
|
||||||
|
|
||||||
|
def test_prompt_uses_the_newest_versions_content(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- A root document with a version
|
||||||
|
WHEN:
|
||||||
|
- The classification prompt is built for the root
|
||||||
|
THEN:
|
||||||
|
- It contains the newest version's content
|
||||||
|
"""
|
||||||
|
prompt = build_prompt_without_rag(self._root_with_version(), AIConfig())
|
||||||
|
|
||||||
|
assert "latest text" in prompt
|
||||||
|
assert "stale text" not in prompt
|
||||||
|
|
||||||
|
@override_settings(LLM_EMBEDDING_BACKEND="huggingface")
|
||||||
|
def test_similar_document_context_uses_the_newest_versions_content(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- A similar root document with a version
|
||||||
|
WHEN:
|
||||||
|
- The similar-document context is built
|
||||||
|
THEN:
|
||||||
|
- It contains the newest version's content
|
||||||
|
"""
|
||||||
|
similar = self._root_with_version()
|
||||||
|
document = DocumentFactory(content="Some content")
|
||||||
|
fake_nodes = [
|
||||||
|
SimpleNamespace(metadata={"document_id": str(similar.pk)}, score=0.9),
|
||||||
|
]
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"paperless_ai.ai_classifier.retrieve_similar_nodes",
|
||||||
|
return_value=fake_nodes,
|
||||||
|
):
|
||||||
|
_candidates, context = get_taxonomy_context(document, user=None)
|
||||||
|
|
||||||
|
assert context == "TITLE: Statement\nlatest text"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
@override_settings(LLM_EMBEDDING_BACKEND="huggingface")
|
@override_settings(LLM_EMBEDDING_BACKEND="huggingface")
|
||||||
def test_get_taxonomy_context_no_similar_docs():
|
def test_get_taxonomy_context_no_similar_docs():
|
||||||
|
|||||||
@@ -438,6 +438,31 @@ class TestLlmIndexVersions:
|
|||||||
|
|
||||||
assert set(indexed) == {str(root.pk)}
|
assert set(indexed) == {str(root.pk)}
|
||||||
|
|
||||||
|
def test_rebuild_indexes_the_newest_versions_content(
|
||||||
|
self,
|
||||||
|
temp_llm_index_dir: Path,
|
||||||
|
mock_embed_model: FakeEmbedding,
|
||||||
|
mocker: pytest_mock.MockerFixture,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- A root document with a version
|
||||||
|
WHEN:
|
||||||
|
- The LLM index is rebuilt
|
||||||
|
THEN:
|
||||||
|
- The root's text for the index is the version's content, answered
|
||||||
|
from the query rather than a query per document
|
||||||
|
"""
|
||||||
|
root = DocumentFactory(content="stale text")
|
||||||
|
DocumentFactory(root_document=root, version_index=1, content="latest text")
|
||||||
|
spy = mocker.spy(indexing, "build_document_node")
|
||||||
|
|
||||||
|
indexing.update_llm_index(rebuild=True)
|
||||||
|
|
||||||
|
indexed = spy.call_args.args[0]
|
||||||
|
assert indexed.pk == root.pk
|
||||||
|
assert indexed.effective_content == "latest text"
|
||||||
|
|
||||||
def test_incremental_update_by_version_id_refreshes_the_root(
|
def test_incremental_update_by_version_id_refreshes_the_root(
|
||||||
self,
|
self,
|
||||||
temp_llm_index_dir: Path,
|
temp_llm_index_dir: Path,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from paperless_ai.embedding import _normalize_llm_index_text
|
|||||||
from paperless_ai.embedding import build_llm_index_text
|
from paperless_ai.embedding import build_llm_index_text
|
||||||
from paperless_ai.embedding import get_configured_model_name
|
from paperless_ai.embedding import get_configured_model_name
|
||||||
from paperless_ai.embedding import get_embedding_model
|
from paperless_ai.embedding import get_embedding_model
|
||||||
|
from paperless_testing.factories import DocumentFactory
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@@ -46,6 +47,7 @@ def mock_document():
|
|||||||
doc.correspondent.name = "Test Correspondent"
|
doc.correspondent.name = "Test Correspondent"
|
||||||
doc.archive_serial_number = "12345"
|
doc.archive_serial_number = "12345"
|
||||||
doc.content = "This is the document content."
|
doc.content = "This is the document content."
|
||||||
|
doc.get_effective_content.return_value = "This is the document content."
|
||||||
|
|
||||||
cf1 = MagicMock(__str__=lambda x: "Value1")
|
cf1 = MagicMock(__str__=lambda x: "Value1")
|
||||||
cf1.field = MagicMock()
|
cf1.field = MagicMock()
|
||||||
@@ -280,7 +282,7 @@ def test_build_llm_index_text(mock_document):
|
|||||||
|
|
||||||
|
|
||||||
def test_build_llm_index_text_normalizes_ocr_punctuation_runs(mock_document):
|
def test_build_llm_index_text_normalizes_ocr_punctuation_runs(mock_document):
|
||||||
mock_document.content = (
|
mock_document.get_effective_content.return_value = (
|
||||||
"Introduction ................................................ 7\n"
|
"Introduction ................................................ 7\n"
|
||||||
"Hardware Limitation ________________________________________ 9\n"
|
"Hardware Limitation ________________________________________ 9\n"
|
||||||
"Keep short punctuation like INV-100 and ellipses..."
|
"Keep short punctuation like INV-100 and ellipses..."
|
||||||
@@ -294,6 +296,43 @@ def test_build_llm_index_text_normalizes_ocr_punctuation_runs(mock_document):
|
|||||||
assert "ellipses..." in result
|
assert "ellipses..." in result
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
class TestBuildLlmIndexTextVersions:
|
||||||
|
"""A root document is indexed with its effective content, like in the search index."""
|
||||||
|
|
||||||
|
def test_root_uses_the_newest_versions_content(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- A root document with two versions
|
||||||
|
WHEN:
|
||||||
|
- The LLM index text is built for the root
|
||||||
|
THEN:
|
||||||
|
- It contains the newest version's content and not the others'
|
||||||
|
"""
|
||||||
|
root = DocumentFactory(content="stale text")
|
||||||
|
DocumentFactory(root_document=root, version_index=1, content="older text")
|
||||||
|
DocumentFactory(root_document=root, version_index=2, content="latest text")
|
||||||
|
|
||||||
|
text = build_llm_index_text(root)
|
||||||
|
|
||||||
|
assert "latest text" in text
|
||||||
|
assert "stale text" not in text
|
||||||
|
assert "older text" not in text
|
||||||
|
|
||||||
|
def test_root_without_versions_uses_its_own_content(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- A root document without versions
|
||||||
|
WHEN:
|
||||||
|
- The LLM index text is built for it
|
||||||
|
THEN:
|
||||||
|
- It contains the document's own content
|
||||||
|
"""
|
||||||
|
root = DocumentFactory(content="own text")
|
||||||
|
|
||||||
|
assert "own text" in build_llm_index_text(root)
|
||||||
|
|
||||||
|
|
||||||
def test_normalize_llm_index_text_collapses_ocr_leaders_without_joining_lines():
|
def test_normalize_llm_index_text_collapses_ocr_leaders_without_joining_lines():
|
||||||
assert _normalize_llm_index_text("A........B\nC____D----E") == "A B\nC D E"
|
assert _normalize_llm_index_text("A........B\nC____D----E") == "A B\nC D E"
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user