Compare commits

..
Author SHA1 Message Date
stumpylogandClaude Sonnet 5.5 c237c93270 Performance: load a cached Hugging Face embedding model without contacting the Hub
Every chat question and AI suggestion request builds the embedding model, and sentence-transformers checks the Hub for a newer revision each time. With the model already cached that check cost about 2 seconds per request (0.2 seconds without it), and it fails outright when the Hub is unreachable.

Load from the local cache first and only download when the model is missing. A full rebuild still checks the Hub, so a new model release is picked up together with re-embedding every document instead of silently mixing revisions.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SRX1rCtSx62vSDaiFuBFCM
2026-10-31 07:07:02 -07:00
shamoon a5d3247ca7 Tweakhancement: make new pdf controls mostly hidden (#14595) 2026-10-30 10:22:50 -07:00
GitHub Actions 8a67481a9d Auto translate strings 2026-10-30 16:59:59 +00:00
shamoon 652f964f5d Tweak: move preview button in doc list (#14594) 2026-10-30 09:58:29 -07:00
shamoon 80a1827fa0 Fix: checksum light mode display and cf dropdown mobile position (#14590) 2026-10-30 07:03:08 -07:00
12 changed files with 191 additions and 85 deletions

No files matched your search

+5 -5
View File
@@ -10334,18 +10334,18 @@
<context context-type="linenumber">294,295</context>
</context-group>
</trans-unit>
<trans-unit id="2179847500064178686" datatype="html">
<source>Edit document</source>
<trans-unit id="3420321797707163677" datatype="html">
<source>Preview document</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
<context context-type="linenumber">328</context>
</context-group>
</trans-unit>
<trans-unit id="3420321797707163677" datatype="html">
<source>Preview document</source>
<trans-unit id="2179847500064178686" datatype="html">
<source>Edit document</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
<context context-type="linenumber">329</context>
<context context-type="linenumber">331</context>
</context-group>
</trans-unit>
<trans-unit id="4512084577073831437" datatype="html">
@@ -1,10 +1,3 @@
.custom-fields-dropdown {
min-width: 300px;
// correct position on mobile
@media (max-width: 575.98px) {
&.show {
margin-left: -245px !important;
}
}
}
@@ -308,7 +308,7 @@
</tr>
<tr class="align-middle">
<td i18n>Original SHA256 checksum</td>
<td><span class="badge bg-light font-monospace text-wrap text-break text-start">{{metadata()?.original_checksum}}</span></td>
<td><span class="badge bg-light text-dark font-monospace text-wrap text-break text-start">{{metadata()?.original_checksum}}</span></td>
</tr>
<tr>
<td i18n>Original file size</td>
@@ -465,7 +465,7 @@
@switch (archiveContentRenderType) {
@case (ContentRenderType.PDF) {
@if (!useNativePdfViewer) {
<div class="preview-sticky pdf-viewer-container">
<div class="preview-sticky pdf-viewer-container" tabindex="-1">
<pngx-pdf-viewer
[src]="pdfSource()"
[sourceRevision]="previewRevision()"
@@ -477,7 +477,7 @@
(loadError)="onError($event)"
(afterLoadComplete)="pdfPreviewLoaded($event)">
</pngx-pdf-viewer>
<div class="position-absolute bottom-0 start-50 translate-middle-x z-1 mb-3 d-flex gap-2 p-1 bg-body border rounded shadow">
<div class="preview-controls position-absolute bottom-0 start-50 translate-middle-x z-1 mb-3 d-flex gap-2 p-1 bg-body border rounded shadow">
@if (previewNumPages()) {
<div class="input-group input-group-sm w-auto flex-nowrap">
<input class="form-control flex-grow-0 w-auto" type="number" min="1" [max]="previewNumPages()" [ngModelOptions]="{standalone: true}" [ngModel]="previewCurrentPage()" (ngModelChange)="previewCurrentPage.set($event)" aria-label="Page" i18n-aria-label />
@@ -12,6 +12,22 @@
width: 100%;
height: 100%;
}
&:focus {
outline: none;
}
.preview-controls {
opacity: 0;
pointer-events: none;
transition: opacity 0.15s ease-in-out;
}
&:hover .preview-controls,
&:focus-within .preview-controls {
opacity: 1;
pointer-events: auto;
}
}
.btn-group .dropdown-toggle-split {
@@ -208,7 +208,7 @@
i18n>Correspondent</th>
}
@if (activeDisplayFields.includes(DisplayField.TITLE)) {
<th class="cursor-pointer"
<th class="cursor-pointer ps-5"
pngxSortable="title"
title="Sort by title" i18n-title
[currentSortField]="list.sortField"
@@ -322,13 +322,13 @@
</td>
}
@if (activeDisplayFields.includes(DisplayField.TITLE) || activeDisplayFields.includes(DisplayField.TAGS)) {
<td width="30%">
<td width="30%" class="position-relative" [class.ps-5]="activeDisplayFields.includes(DisplayField.TITLE)">
@if (activeDisplayFields.includes(DisplayField.TITLE)) {
<div class="d-inline-block" (mouseleave)="popupPreview.close()">
<a routerLink="/documents/{{d.id}}" title="Edit document" i18n-title style="overflow-wrap: anywhere;">{{d.title | documentTitle}}</a>
<pngx-preview-popup [document]="d" linkClasses="btn btn-sm btn-link text-secondary" linkTitle="Preview document" (click)="$event.stopPropagation()" i18n-linkTitle #popupPreview>
<pngx-preview-popup class="position-absolute start-0" [document]="d" linkClasses="btn btn-sm btn-link text-secondary" linkTitle="Preview document" (click)="$event.stopPropagation()" i18n-linkTitle #popupPreview>
<i-bs name="eye"></i-bs>
</pngx-preview-popup>
<a routerLink="/documents/{{d.id}}" title="Edit document" i18n-title style="overflow-wrap: anywhere;">{{d.title | documentTitle}}</a>
</div>
}
@if (activeDisplayFields.includes(DisplayField.TAGS)) {
@@ -87,6 +87,10 @@ $paperless-card-breakpoints: (
}
}
.table .ps-5 {
padding-left: 2.2rem !important;
}
a {
cursor: pointer;
}
+28 -21
View File
@@ -15,6 +15,7 @@ import dateparser
from django.conf import settings
from django.contrib.contenttypes.models import ContentType
from django.db.models import Case
from django.db.models import CharField
from django.db.models import Count
from django.db.models import Exists
from django.db.models import IntegerField
@@ -207,30 +208,36 @@ class EffectiveContentFilter(Filter):
@extend_schema_field(serializers.BooleanField)
class SharedByUser(Filter):
def filter(self, qs, value):
if value is None:
return qs
ctype = ContentType.objects.get_for_model(self.model)
UserObjectPermission = get_user_obj_perms_model()
GroupObjectPermission = get_group_obj_perms_model()
# Guardian stores object_pk as a string. Each grant table is read once,
# as an uncorrelated set of integer keys, because probing it per
# document by string key has no usable index (none leads with
# object_pk) and MariaDB re-scans the table for every owned document.
# DISTINCT keeps Postgres hashing each set once: without it a large
# group grant table exceeds work_mem and is re-scanned for every row.
shared_with_users = (
UserObjectPermission.objects.filter(content_type=ctype)
.values(shared_pk=Cast("object_pk", IntegerField()))
.distinct()
)
shared_with_groups = (
GroupObjectPermission.objects.filter(content_type=ctype)
.values(shared_pk=Cast("object_pk", IntegerField()))
.distinct()
)
return qs.filter(owner_id=value).filter(
Q(pk__in=shared_with_users) | Q(pk__in=shared_with_groups),
# see https://github.com/paperless-ngx/paperless-ngx/issues/5392, we limit subqueries
# to 1 because Postgres doesn't like returning > 1 row, but all we care about is > 0
return (
qs.filter(
owner_id=value,
)
.annotate(
num_shared_users=Count(
UserObjectPermission.objects.filter(
content_type=ctype,
object_pk=Cast(OuterRef("pk"), CharField()),
).values("user_id")[:1],
),
)
.annotate(
num_shared_groups=Count(
GroupObjectPermission.objects.filter(
content_type=ctype,
object_pk=Cast(OuterRef("pk"), CharField()),
).values("group_id")[:1],
),
)
.filter(
Q(num_shared_users__gt=0) | Q(num_shared_groups__gt=0),
)
if value is not None
else qs
)
-39
View File
@@ -1480,45 +1480,6 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
[u1_doc1.id],
)
def test_shared_by_filter_matches_user_and_group_grants_once(self) -> None:
"""
GIVEN:
- Documents owned by a user: one shared with a user, one shared with
a group, one shared with several users and a group, one unshared
- A shared document owned by someone else
WHEN:
- Documents are filtered by shared_by__id for the owner
THEN:
- Only the owner's shared documents are returned, each exactly once
"""
owner = UserFactory(username="owner")
other = UserFactory(username="other")
reader1 = UserFactory(username="reader1")
reader2 = UserFactory(username="reader2")
group = Group.objects.create(name="readers")
grant_all_global(owner)
shared_with_user = DocumentFactory.create(owner=owner)
shared_with_group = DocumentFactory.create(owner=owner)
shared_widely = DocumentFactory.create(owner=owner)
DocumentFactory.create(owner=owner)
others_shared = DocumentFactory.create(owner=other)
grant_object(reader1, shared_with_user, "view_document")
grant_object(group, shared_with_group, "view_document")
for target in (reader1, reader2, group):
grant_object(target, shared_widely, "view_document")
grant_object(reader1, others_shared, "view_document")
self.client.force_authenticate(owner)
response = self.client.get(f"/api/documents/?shared_by__id={owner.id}")
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data["count"], 3)
self.assertCountEqual(
[result["id"] for result in response.data["results"]],
[shared_with_user.id, shared_with_group.id, shared_widely.id],
)
def test_document_owned_and_group_shared_not_duplicated_when_filtering_by_tags(
self,
) -> None:
+23 -5
View File
@@ -20,7 +20,18 @@ OCR_LEADER_REGEX = re.compile(r"[._\-\u00b7]{4,}")
HORIZONTAL_WHITESPACE_REGEX = re.compile(r"[ \t\u00a0]+")
def get_embedding_model(config: AIConfig) -> "BaseEmbedding":
def get_embedding_model(
config: AIConfig,
*,
refresh: bool = False,
) -> "BaseEmbedding":
"""Build the configured embedding model.
For the Hugging Face backend, a model already in the local cache is loaded
without contacting the Hub, which saves seconds per call. ``refresh=True``
checks the Hub for a newer revision first; index rebuilds use it so a new
model release is picked up together with re-embedding every document.
"""
match config.llm_embedding_backend:
case LLMEmbeddingBackend.OPENAI_LIKE:
from llama_index.embeddings.openai_like import OpenAILikeEmbedding
@@ -52,11 +63,18 @@ def get_embedding_model(config: AIConfig) -> "BaseEmbedding":
case LLMEmbeddingBackend.HUGGINGFACE:
from llama_index.embeddings.huggingface import HuggingFaceEmbedding
return HuggingFaceEmbedding(
model_name=config.llm_embedding_model
hf_kwargs = {
"model_name": config.llm_embedding_model
or "sentence-transformers/all-MiniLM-L6-v2",
cache_folder=str(settings.DATA_DIR / "hf_cache"),
)
"cache_folder": str(settings.DATA_DIR / "hf_cache"),
}
if refresh:
return HuggingFaceEmbedding(**hf_kwargs)
try:
return HuggingFaceEmbedding(**hf_kwargs, local_files_only=True)
except OSError:
# Not cached yet, download it
return HuggingFaceEmbedding(**hf_kwargs)
case LLMEmbeddingBackend.OLLAMA:
from llama_index.embeddings.ollama import OllamaEmbedding
from ollama import AsyncClient
+1 -1
View File
@@ -470,7 +470,7 @@ def update_llm_index(
logger.warning("No documents found to index.")
chunk_size = config.llm_embedding_chunk_size
embed_model = get_embedding_model(config)
embed_model = get_embedding_model(config, refresh=rebuild)
with write_store(embed_model_name=model_name) as store:
if rebuild or not store.table_exists():
+1
View File
@@ -167,6 +167,7 @@ def test_get_embedding_model_huggingface(mock_ai_config):
MockHuggingFaceEmbedding.assert_called_once_with(
model_name="sentence-transformers/all-MiniLM-L6-v2",
cache_folder=str(settings.DATA_DIR / "hf_cache"),
local_files_only=True,
)
assert model == MockHuggingFaceEmbedding.return_value
@@ -0,0 +1,106 @@
from pathlib import Path
from unittest.mock import MagicMock
from unittest.mock import call
from unittest.mock import patch
import pytest
import pytest_mock
from django.conf import settings
from paperless.models import LLMEmbeddingBackend
from paperless_ai import indexing
from paperless_ai.embedding import get_embedding_model
from paperless_ai.tests.conftest import FakeEmbedding
from paperless_testing.factories import DocumentFactory
MODEL = "sentence-transformers/all-MiniLM-L6-v2"
CACHE = str(settings.DATA_DIR / "hf_cache")
@pytest.fixture
def hf_config() -> MagicMock:
config = MagicMock()
config.llm_embedding_backend = LLMEmbeddingBackend.HUGGINGFACE
config.llm_embedding_model = MODEL
return config
@pytest.fixture
def hf_embedding(mocker: pytest_mock.MockerFixture) -> MagicMock:
return mocker.patch("llama_index.embeddings.huggingface.HuggingFaceEmbedding")
class TestHuggingFaceLocalFirst:
def test_uses_cached_model_without_hub(
self,
hf_config: MagicMock,
hf_embedding: MagicMock,
) -> None:
model = get_embedding_model(hf_config)
hf_embedding.assert_called_once_with(
model_name=MODEL,
cache_folder=CACHE,
local_files_only=True,
)
assert model is hf_embedding.return_value
def test_downloads_when_not_cached(
self,
hf_config: MagicMock,
hf_embedding: MagicMock,
) -> None:
downloaded = MagicMock()
hf_embedding.side_effect = [OSError("not cached"), downloaded]
model = get_embedding_model(hf_config)
assert hf_embedding.call_args_list == [
call(model_name=MODEL, cache_folder=CACHE, local_files_only=True),
call(model_name=MODEL, cache_folder=CACHE),
]
assert model is downloaded
def test_refresh_checks_the_hub(
self,
hf_config: MagicMock,
hf_embedding: MagicMock,
) -> None:
get_embedding_model(hf_config, refresh=True)
hf_embedding.assert_called_once_with(model_name=MODEL, cache_folder=CACHE)
@pytest.mark.django_db
class TestIndexUpdateRefresh:
@pytest.fixture(autouse=True)
def _index_inputs(self, temp_llm_index_dir: Path) -> None:
DocumentFactory.create()
def _update(self, mock_embed_model: FakeEmbedding, *, rebuild: bool) -> MagicMock:
mock_config = MagicMock()
mock_config.llm_embedding_chunk_size = 512
with (
patch("paperless_ai.indexing.AIConfig", return_value=mock_config),
patch("paperless_ai.indexing.build_document_node", return_value=[]),
patch(
"paperless_ai.indexing.get_embedding_model",
return_value=mock_embed_model,
) as get_model,
):
indexing.update_llm_index(rebuild=rebuild)
get_model.assert_called_once()
return get_model
def test_rebuild_refreshes(self, mock_embed_model: FakeEmbedding) -> None:
get_model = self._update(mock_embed_model, rebuild=True)
assert get_model.call_args.kwargs == {"refresh": True}
def test_incremental_update_stays_local(
self,
mock_embed_model: FakeEmbedding,
) -> None:
get_model = self._update(mock_embed_model, rebuild=False)
assert get_model.call_args.kwargs == {"refresh": False}