Compare commits

..
Author SHA1 Message Date
shamoon d37c31ed36 Maybe fix the android keyboard CF dropdown weird thing 2026-08-14 13:40:22 -07:00
113 changed files with 60508 additions and 96784 deletions
@@ -1,4 +1,4 @@
<div ngbDropdown #fieldDropdown="ngbDropdown" (openChange)="onOpenClose($event)" [popperOptions]="popperOptions">
<div ngbDropdown #fieldDropdown="ngbDropdown" (openChange)="onOpenClose($event)" placement="bottom-end" [popperOptions]="popperOptions">
<button type="button" class="btn btn-sm btn-outline-primary" id="customFieldsDropdown" [disabled]="disabled" ngbDropdownToggle>
<i-bs name="ui-radios"></i-bs><div class="d-none d-lg-inline ms-1"><ng-container i18n>Custom Fields</ng-container></div>
</button>
@@ -6,5 +6,16 @@
&.show {
margin-left: -245px !important;
}
.list-group {
max-height: min(50dvh, 20rem);
overflow-y: auto;
}
.list-group-item:first-child {
position: sticky;
top: 0;
z-index: 1;
}
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+2 -15
View File
@@ -41,16 +41,7 @@ class SuggestionCacheData:
CLASSIFIER_VERSION_KEY: Final[str] = "classifier_version"
CLASSIFIER_HASH_KEY: Final[str] = "classifier_hash"
CLASSIFIER_MODIFIED_KEY: Final[str] = "classifier_modified"
# Marker distinguishing LLM suggestions from classifier-generated ones (whose
# FORMAT_VERSION lives in a much lower range - see DocumentClassifier). Bump
# this whenever the *shape* of the cached `suggestions` dict changes, so a
# cache entry written by a previous release can never be read back by code
# that expects a different shape:
# 1000 - initial LLM suggestions cache (flat lists of resolved object ids
# per taxonomy field)
# 1001 - suggestions reshaped to {"existing_ids": [...], "new_names":
# [...]} per taxonomy field (#13676)
LLM_CACHE_CLASSIFIER_VERSION: Final[int] = 1001
LLM_CACHE_CLASSIFIER_VERSION: Final[int] = 1000 # Marker distinguishing LLM suggestions
CACHE_1_MINUTE: Final[int] = 60
CACHE_5_MINUTES: Final[int] = 5 * CACHE_1_MINUTE
@@ -213,11 +204,7 @@ def get_llm_suggestion_cache(
doc_key = get_suggestion_cache_key(document_id)
data: SuggestionCacheData = cache.get(doc_key)
if (
data
and data.classifier_version == LLM_CACHE_CLASSIFIER_VERSION
and data.classifier_hash == backend
):
if data and data.classifier_hash == backend:
return data
return None
-53
View File
@@ -1,5 +1,4 @@
from typing import Any
from typing import TypeVar
from django.contrib.auth.models import Group
from django.contrib.auth.models import Permission
@@ -236,58 +235,6 @@ def permitted_object_ids(
).values_list("id", flat=True)
ModelT = TypeVar("ModelT", bound=Model)
def user_is_unrestricted(user: User | None) -> bool:
"""
True when ``user`` means "no restriction at all" (an absent user, or an
*active* superuser) without needing a database check to know it.
``permitted_object_ids(None, ...)`` itself means the much narrower "only
unowned rows", which is NOT the same thing as "no user filtering
requested", so callers must special-case this before ever calling it.
A deactivated superuser is deliberately NOT unrestricted here, matching
permitted_object_ids's own is_active-before-is_superuser ordering.
Callers that can avoid a database round trip entirely when this is true
(e.g. checking a single already-loaded object's visibility rather than
filtering a queryset) should do so via this function directly, rather
than through restrict_queryset_to_visible() below.
"""
if user is None:
return True
return (
getattr(user, "is_authenticated", False)
and getattr(user, "is_active", False)
and getattr(user, "is_superuser", False)
)
def restrict_queryset_to_visible(
queryset: QuerySet[ModelT],
user: User | None,
perm: str,
) -> QuerySet[ModelT]:
"""
Restrict ``queryset`` to the rows ``user`` may see with ``perm``.
Delegates the visibility check to the database as a
``WHERE id IN (subquery)`` rather than materializing the full
permitted-id set into a Python collection first: a caller that only
needs to check a small handful of rows (a resolved-id list, a few
RAG-neighbour candidate ids) never pays for scanning or holding the
installation's entire taxonomy in memory to do it.
Returns ``queryset`` unchanged for user_is_unrestricted(user); every
other case is delegated to ``permitted_object_ids`` rather than
re-deciding the ordering here.
"""
if user_is_unrestricted(user):
return queryset
return queryset.filter(pk__in=permitted_object_ids(user, queryset.model, perm))
def permitted_document_ids(
user: User | None,
*,
@@ -22,7 +22,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.permissions import restrict_queryset_to_visible
from documents.serialisers import _get_viewable_duplicates
from documents.tests.factories import CorrespondentFactory
from documents.tests.factories import DocumentFactory
@@ -737,7 +736,7 @@ class TestBulkEditObjectsTagDescendantPartialPermission:
NOTE: this uses ``set_permissions`` (owner reassignment) rather than
``delete`` as the operation, because Tag.tn_parent (django-treenode)
cascades deletes to descendants at the database/ORM level regardless
of which tags the view resolved into ``objs`` - a delete-based test
of which tags the view resolved into ``objs`` -- a delete-based test
would pass/fail based on FK cascade behavior, not on whether the
descendant-expansion logic itself respected per-object permissions.
"""
@@ -784,97 +783,3 @@ class TestBulkEditObjectsTagDescendantPartialPermission:
assert parent.owner == requester
assert permitted_child.owner == requester
assert unpermitted_child.owner == owner
@pytest.mark.django_db
class TestRestrictQuerysetToVisible:
"""restrict_queryset_to_visible() returns its queryset argument
unchanged only for "no restriction at all", so the cases that may do
that have to be kept narrow."""
def test_no_user_means_no_restriction(self) -> None:
"""
GIVEN:
- No user at all (a system-triggered call)
WHEN:
- restrict_queryset_to_visible() is called
THEN:
- The queryset is returned unfiltered, rather than
permitted_object_ids(None, ...)'s narrower "unowned rows only"
"""
owner = User.objects.create_user(username="vis_none_owner")
tag = TagFactory(owner=owner)
visible = restrict_queryset_to_visible(Tag.objects.all(), None, "view_tag")
assert tag.pk in visible.values_list("pk", flat=True)
def test_active_superuser_means_no_restriction(self) -> None:
"""
GIVEN:
- An active superuser
WHEN:
- restrict_queryset_to_visible() is called
THEN:
- The queryset is returned unfiltered, skipping the permission
lookup entirely
"""
superuser = User.objects.create_superuser(username="vis_active_super")
owner = User.objects.create_user(username="vis_active_super_owner")
tag = TagFactory(owner=owner)
visible = restrict_queryset_to_visible(
Tag.objects.all(),
superuser,
"view_tag",
)
assert tag.pk in visible.values_list("pk", flat=True)
def test_inactive_superuser_is_denied_not_unrestricted(self) -> None:
"""
GIVEN:
- A deactivated superuser
WHEN:
- restrict_queryset_to_visible() is called
THEN:
- No rows are visible, never the whole unrestricted queryset -
deactivation has to win over the superuser shortcut, matching
permitted_object_ids's own ordering
"""
user = User.objects.create_user(
username="vis_inactive_super",
is_active=False,
is_superuser=True,
)
TagFactory(owner=None)
TagFactory(owner=user)
visible = restrict_queryset_to_visible(Tag.objects.all(), user, "view_tag")
assert not visible.exists()
def test_regular_user_gets_permitted_ids(self) -> None:
"""
GIVEN:
- An ordinary active user and a tag owned by someone else
WHEN:
- restrict_queryset_to_visible() is called
THEN:
- Only the rows permitted_object_ids() reports are visible
"""
user = User.objects.create_user(username="vis_regular")
other = User.objects.create_user(username="vis_regular_other")
own = TagFactory(owner=user)
hidden = TagFactory(owner=other)
visible_ids = set(
restrict_queryset_to_visible(
Tag.objects.all(),
user,
"view_tag",
).values_list("pk", flat=True),
)
assert own.pk in visible_ids
assert hidden.pk not in visible_ids
+18 -225
View File
@@ -352,95 +352,20 @@ class TestAISuggestions(DirectoriesMixin, TestCase):
mock_refresh_cache,
mock_get_cache,
) -> None:
"""
GIVEN:
- A cached LLM classification holding the raw existing_ids/
new_names choices (never resolved object ids)
WHEN:
- ai_suggestions is requested
THEN:
- The cached choices are resolved into ids for this request
(not returned verbatim from the cache) and the cache's TTL is
refreshed
"""
mock_get_cache.return_value = MagicMock(
suggestions={
"title": "Cached Title",
"tags": {"existing_ids": [self.tag1.pk], "new_names": []},
"correspondents": {"existing_ids": [], "new_names": []},
"document_types": {"existing_ids": [], "new_names": []},
"storage_paths": {"existing_ids": [], "new_names": []},
"dates": [],
},
)
mock_get_cache.return_value = MagicMock(suggestions={"tags": ["tag1", "tag2"]})
self.client.force_login(user=self.user)
response = self.client.get(
f"/api/documents/{self.document.pk}/ai_suggestions/",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.json()["title"], "Cached Title")
self.assertEqual(response.json()["tags"], [self.tag1.pk])
self.assertEqual(response.json(), {"tags": ["tag1", "tag2"]})
mock_get_cache.assert_called_once_with(
self.document.pk,
backend="mock_backend",
)
mock_refresh_cache.assert_called_once_with(self.document.pk)
@patch("documents.views.get_llm_suggestion_cache")
@patch("documents.views.refresh_suggestions_cache")
@override_settings(
AI_ENABLED=True,
LLM_BACKEND="mock_backend",
)
def test_ai_suggestions_cache_hit_re_filters_for_narrower_requester(
self,
mock_refresh_cache,
mock_get_cache,
) -> None:
"""
GIVEN:
- A cached LLM classification whose existing_ids include a tag
only visible to a broader-visibility user (e.g. the requester
who originally generated it)
- A second, non-superuser requester who may change the document
but has no permission to view that tag
WHEN:
- ai_suggestions is requested by the second requester and the
cache is hit
THEN:
- The cache hit still runs permission filtering fresh for this
requester; the invisible tag id does not leak into either the
matched or suggested tags
"""
tag_owner = User.objects.create_user(username="cache_tag_owner")
invisible_tag = Tag.objects.create(name="cache_restricted", owner=tag_owner)
requester = User.objects.create_user(username="cache_requester")
requester.user_permissions.add(
*Permission.objects.filter(
codename__in=["view_document", "change_document", "view_tag"],
),
)
mock_get_cache.return_value = MagicMock(
suggestions={
"title": "Untitled",
"tags": {"existing_ids": [invisible_tag.pk], "new_names": []},
"correspondents": {"existing_ids": [], "new_names": []},
"document_types": {"existing_ids": [], "new_names": []},
"storage_paths": {"existing_ids": [], "new_names": []},
"dates": [],
},
)
self.client.force_login(user=requester)
response = self.client.get(
f"/api/documents/{self.document.pk}/ai_suggestions/",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.json()["tags"], [])
self.assertEqual(response.json()["suggested_tags"], [])
@patch("documents.views.get_ai_document_classification")
@override_settings(
AI_ENABLED=True,
@@ -452,16 +377,10 @@ class TestAISuggestions(DirectoriesMixin, TestCase):
) -> None:
mock_get_ai_classification.return_value = {
"title": "AI Title",
"tags": {"existing_ids": [self.tag1.pk], "new_names": ["tag2"]},
"correspondents": {
"existing_ids": [self.correspondent1.pk],
"new_names": [],
},
"document_types": {
"existing_ids": [self.document_type1.pk],
"new_names": [],
},
"storage_paths": {"existing_ids": [self.path1.pk], "new_names": []},
"tags": ["tag1", "tag2"],
"correspondents": ["correspondent1"],
"document_types": ["type1"],
"storage_paths": ["path1"],
"dates": ["2023-01-01"],
}
@@ -503,10 +422,10 @@ class TestAISuggestions(DirectoriesMixin, TestCase):
UiSettings.objects.create(user=self.user, settings={"language": "de-de"})
mock_get_ai_classification.return_value = {
"title": "KI Title",
"tags": {"existing_ids": [], "new_names": []},
"correspondents": {"existing_ids": [], "new_names": []},
"document_types": {"existing_ids": [], "new_names": []},
"storage_paths": {"existing_ids": [], "new_names": []},
"tags": [],
"correspondents": [],
"document_types": [],
"storage_paths": [],
"dates": [],
}
@@ -542,10 +461,10 @@ class TestAISuggestions(DirectoriesMixin, TestCase):
UiSettings.objects.create(user=self.user, settings={"language": "de-de"})
mock_get_ai_classification.return_value = {
"title": "Titre IA",
"tags": {"existing_ids": [], "new_names": []},
"correspondents": {"existing_ids": [], "new_names": []},
"document_types": {"existing_ids": [], "new_names": []},
"storage_paths": {"existing_ids": [], "new_names": []},
"tags": [],
"correspondents": [],
"document_types": [],
"storage_paths": [],
"dates": [],
}
@@ -583,10 +502,10 @@ class TestAISuggestions(DirectoriesMixin, TestCase):
either yields a cache miss instead of a stale hit."""
mock_get_ai_classification.return_value = {
"title": "Answer A",
"tags": {"existing_ids": [], "new_names": []},
"correspondents": {"existing_ids": [], "new_names": []},
"document_types": {"existing_ids": [], "new_names": []},
"storage_paths": {"existing_ids": [], "new_names": []},
"tags": [],
"correspondents": [],
"document_types": [],
"storage_paths": [],
"dates": [],
}
@@ -660,132 +579,6 @@ class TestAISuggestions(DirectoriesMixin, TestCase):
get_llm_suggestion_cache(self.document.pk, backend="openai-like"),
)
@patch("documents.views.get_ai_document_classification")
@override_settings(
AI_ENABLED=True,
LLM_BACKEND="mock_backend",
)
def test_ai_suggestions_combines_existing_ids_and_new_names(
self,
mock_get_ai_classification,
) -> None:
"""
GIVEN:
- AI classification returns a taxonomy choice with both an
existing tag id and a new tag name not present in the database
WHEN:
- ai_suggestions is requested
THEN:
- the existing id is resolved into the matched tags list
- the new name is fuzzy-matched, and since it doesn't match any
existing tag, it is surfaced as a suggested tag
"""
mock_get_ai_classification.return_value = {
"title": "Lab Report",
"tags": {"existing_ids": [self.tag1.pk], "new_names": ["Follow-up"]},
"correspondents": {"existing_ids": [], "new_names": []},
"document_types": {"existing_ids": [], "new_names": []},
"storage_paths": {"existing_ids": [], "new_names": []},
"dates": [],
}
self.client.force_login(user=self.user)
response = self.client.get(
f"/api/documents/{self.document.pk}/ai_suggestions/",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.json()["tags"], [self.tag1.pk])
self.assertEqual(response.json()["suggested_tags"], ["Follow-up"])
@patch("documents.views.get_ai_document_classification")
@override_settings(
AI_ENABLED=True,
LLM_BACKEND="mock_backend",
)
def test_ai_suggestions_deduplicates_id_matched_via_both_paths(
self,
mock_get_ai_classification,
) -> None:
"""
GIVEN:
- AI classification returns the same tag both as an existing_id
and as a new_name that fuzzy-matches that same tag
WHEN:
- ai_suggestions is requested
THEN:
- The tag's id appears exactly once in the response, not twice
"""
mock_get_ai_classification.return_value = {
"title": "Lab Report",
"tags": {
"existing_ids": [self.tag1.pk],
"new_names": [self.tag1.name],
},
"correspondents": {"existing_ids": [], "new_names": []},
"document_types": {"existing_ids": [], "new_names": []},
"storage_paths": {"existing_ids": [], "new_names": []},
"dates": [],
}
self.client.force_login(user=self.user)
response = self.client.get(
f"/api/documents/{self.document.pk}/ai_suggestions/",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.json()["tags"], [self.tag1.pk])
self.assertEqual(response.json()["suggested_tags"], [])
@patch("documents.views.get_ai_document_classification")
@override_settings(
AI_ENABLED=True,
LLM_BACKEND="mock_backend",
)
def test_ai_suggestions_existing_id_not_visible_falls_through_to_suggested(
self,
mock_get_ai_classification,
) -> None:
"""
GIVEN:
- A non-superuser who may change the document but has no
permission to view a tag owned by somebody else
- AI classification returns that tag's id in existing_ids (e.g.
from a cached response generated for a broader-visibility user)
WHEN:
- ai_suggestions is requested by that user
THEN:
- the invisible id is silently dropped by resolve_tag_ids, so
permission filtering survives the full request path
- it does not appear in either the matched or suggested tags
"""
tag_owner = User.objects.create_user(username="tagowner")
invisible_tag = Tag.objects.create(name="restricted", owner=tag_owner)
requester = User.objects.create_user(username="requester")
requester.user_permissions.add(
*Permission.objects.filter(
codename__in=["view_document", "change_document", "view_tag"],
),
)
mock_get_ai_classification.return_value = {
"title": "Untitled",
"tags": {"existing_ids": [invisible_tag.pk], "new_names": []},
"correspondents": {"existing_ids": [], "new_names": []},
"document_types": {"existing_ids": [], "new_names": []},
"storage_paths": {"existing_ids": [], "new_names": []},
"dates": [],
}
self.client.force_login(user=requester)
response = self.client.get(
f"/api/documents/{self.document.pk}/ai_suggestions/",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.json()["tags"], [])
self.assertEqual(response.json()["suggested_tags"], [])
def test_invalidate_suggestions_cache(self) -> None:
self.client.force_login(user=self.user)
suggestions = {
+45 -99
View File
@@ -7,7 +7,6 @@ import tempfile
import zipfile
from collections import defaultdict
from collections import deque
from collections.abc import Callable
from datetime import datetime
from datetime import timedelta
from http import HTTPStatus
@@ -250,10 +249,6 @@ from paperless_ai.matching import match_correspondents_by_name
from paperless_ai.matching import match_document_types_by_name
from paperless_ai.matching import match_storage_paths_by_name
from paperless_ai.matching import match_tags_by_name
from paperless_ai.matching import resolve_correspondent_ids
from paperless_ai.matching import resolve_document_type_ids
from paperless_ai.matching import resolve_storage_path_ids
from paperless_ai.matching import resolve_tag_ids
from paperless_mail.models import MailAccount
from paperless_mail.models import MailRule
from paperless_mail.oauth import PaperlessMailOAuth2Manager
@@ -263,9 +258,6 @@ from paperless_mail.serialisers import MailRuleSerializer
if settings.AUDIT_LOG_ENABLED:
from auditlog.models import LogEntry
if TYPE_CHECKING:
from paperless_ai.base_model import TaxonomyChoiceDict
logger = logging.getLogger("paperless.api")
@@ -1554,126 +1546,80 @@ class DocumentViewSet(
)
if cached_llm_suggestions:
# Only the raw model choices are cached, never resolved object
# ids. resolve_choice() below still runs permission filtering
# freshly for this requester on every request, cache hit or not,
# so a resolved id cached for one user's visibility can never be
# handed unfiltered to a second, less-privileged requester of
# the same (backend-keyed, not user-keyed) cache entry.
refresh_suggestions_cache(doc.pk)
llm_suggestions = cached_llm_suggestions.suggestions
else:
try:
llm_suggestions = get_ai_document_classification(
doc,
request.user,
output_language,
)
except ValueError as exc:
logger.exception(
"Invalid AI configuration while generating suggestions for "
"document %s: %s",
doc.pk,
exc,
exc_info=True,
)
raise ValidationError(
{"ai": [_("Invalid AI configuration.")]},
) from exc
except LLMTimeoutError as exc:
logger.exception(
"AI backend timed out while generating suggestions for "
"document %s: %s",
doc.pk,
exc,
exc_info=True,
)
return Response(
{"ai": [_("AI backend request timed out.")]},
status=status.HTTP_503_SERVICE_UNAVAILABLE,
)
set_llm_suggestions_cache(
doc.pk,
llm_suggestions,
backend=llm_cache_backend,
)
return Response(cached_llm_suggestions.suggestions)
tags_choice: TaxonomyChoiceDict = llm_suggestions["tags"]
correspondents_choice: TaxonomyChoiceDict = llm_suggestions["correspondents"]
document_types_choice: TaxonomyChoiceDict = llm_suggestions["document_types"]
storage_paths_choice: TaxonomyChoiceDict = llm_suggestions["storage_paths"]
def resolve_choice(
choice: "TaxonomyChoiceDict",
resolve_ids: Callable[[list[int], User], list],
match_names: Callable[[list[str], User], list],
) -> list:
"""The ids the model picked from the candidates it was shown, plus
name matches for the values it proposed as new. The schema allows
the same object to satisfy both an existing_id and a new_name in
one valid response, so results are deduplicated by pk (keeping
first-seen order) rather than trusting the two lookups to be
disjoint.
"""
matched = resolve_ids(choice["existing_ids"], request.user) + match_names(
choice["new_names"],
try:
llm_suggestions = get_ai_document_classification(
doc,
request.user,
output_language,
)
except ValueError as exc:
logger.exception(
"Invalid AI configuration while generating suggestions for "
"document %s: %s",
doc.pk,
exc,
exc_info=True,
)
raise ValidationError({"ai": [_("Invalid AI configuration.")]}) from exc
except LLMTimeoutError as exc:
logger.exception(
"AI backend timed out while generating suggestions for document %s: %s",
doc.pk,
exc,
exc_info=True,
)
return Response(
{"ai": [_("AI backend request timed out.")]},
status=status.HTTP_503_SERVICE_UNAVAILABLE,
)
seen_ids: set[int] = set()
deduped = []
for obj in matched:
if obj.pk in seen_ids:
continue
seen_ids.add(obj.pk)
deduped.append(obj)
return deduped
matched_tags = resolve_choice(
tags_choice,
resolve_tag_ids,
match_tags_by_name,
matched_tags = match_tags_by_name(
llm_suggestions.get("tags", []),
request.user,
)
matched_correspondents = resolve_choice(
correspondents_choice,
resolve_correspondent_ids,
match_correspondents_by_name,
matched_correspondents = match_correspondents_by_name(
llm_suggestions.get("correspondents", []),
request.user,
)
matched_types = resolve_choice(
document_types_choice,
resolve_document_type_ids,
match_document_types_by_name,
matched_types = match_document_types_by_name(
llm_suggestions.get("document_types", []),
request.user,
)
matched_paths = resolve_choice(
storage_paths_choice,
resolve_storage_path_ids,
match_storage_paths_by_name,
matched_paths = match_storage_paths_by_name(
llm_suggestions.get("storage_paths", []),
request.user,
)
resp_data = {
"title": llm_suggestions["title"],
"title": llm_suggestions.get("title"),
"tags": [t.id for t in matched_tags],
"suggested_tags": extract_unmatched_names(
tags_choice["new_names"],
llm_suggestions.get("tags", []),
matched_tags,
),
"correspondents": [c.id for c in matched_correspondents],
"suggested_correspondents": extract_unmatched_names(
correspondents_choice["new_names"],
llm_suggestions.get("correspondents", []),
matched_correspondents,
),
"document_types": [d.id for d in matched_types],
"suggested_document_types": extract_unmatched_names(
document_types_choice["new_names"],
llm_suggestions.get("document_types", []),
matched_types,
),
"storage_paths": [s.id for s in matched_paths],
"suggested_storage_paths": extract_unmatched_names(
storage_paths_choice["new_names"],
llm_suggestions.get("storage_paths", []),
matched_paths,
),
"dates": llm_suggestions["dates"],
"dates": llm_suggestions.get("dates", []),
}
set_llm_suggestions_cache(doc.pk, resp_data, backend=llm_cache_backend)
return Response(resp_data)
@action(methods=["get"], detail=True, filter_backends=[])
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+11 -11
View File
@@ -2,7 +2,7 @@ msgid ""
msgstr ""
"Project-Id-Version: paperless-ngx\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-08-14 22:52+0000\n"
"POT-Creation-Date: 2026-08-13 19:47+0000\n"
"PO-Revision-Date: 2022-02-17 04:17\n"
"Last-Translator: \n"
"Language-Team: English\n"
@@ -1576,7 +1576,7 @@ msgid "workflow runs"
msgstr ""
#: documents/serialisers.py:522 documents/serialisers.py:874
#: documents/serialisers.py:2769 documents/views.py:307 documents/views.py:2609
#: documents/serialisers.py:2769 documents/views.py:299 documents/views.py:2555
#: paperless_mail/serialisers.py:155
msgid "Insufficient permissions."
msgstr ""
@@ -1617,7 +1617,7 @@ msgstr ""
msgid "Duplicate document identifiers are not allowed."
msgstr ""
#: documents/serialisers.py:2855 documents/views.py:4563
#: documents/serialisers.py:2855 documents/views.py:4509
#, python-format
msgid "Documents not found: %(ids)s"
msgstr ""
@@ -1885,36 +1885,36 @@ msgstr ""
msgid "Unable to parse URI {value}"
msgstr ""
#: documents/views.py:300 documents/views.py:2606
#: documents/views.py:292 documents/views.py:2552
msgid "Invalid more_like_id"
msgstr ""
#: documents/views.py:1581
#: documents/views.py:1566
msgid "Invalid AI configuration."
msgstr ""
#: documents/views.py:1592
#: documents/views.py:1575
msgid "AI backend request timed out."
msgstr ""
#: documents/views.py:2431 documents/views.py:2752
#: documents/views.py:2377 documents/views.py:2698
msgid "Specify only one of text, title_search, query, or more_like_id."
msgstr ""
#: documents/views.py:4576
#: documents/views.py:4522
#, python-format
msgid "Insufficient permissions to share document %(id)s."
msgstr ""
#: documents/views.py:4622
#: documents/views.py:4568
msgid "Bundle is already being processed."
msgstr ""
#: documents/views.py:4683
#: documents/views.py:4629
msgid "The share link bundle is still being prepared. Please try again later."
msgstr ""
#: documents/views.py:4693
#: documents/views.py:4639
msgid "The share link bundle is unavailable."
msgstr ""
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More