mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-11-01 04:25:44 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c237c93270 | ||
|
|
a5d3247ca7 | ||
|
|
8a67481a9d | ||
|
|
652f964f5d | ||
|
|
80a1827fa0 |
No files matched your search
+5
-5
@@ -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">
|
||||
|
||||
-7
@@ -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;
|
||||
}
|
||||
|
||||
@@ -291,19 +291,12 @@ def get_permission_grantees(
|
||||
"permission__codename__in": list(action_by_codename),
|
||||
"object_pk__in": [str(pk) for pk in pks],
|
||||
}
|
||||
for perm_model, identity_model, identity_field, key in (
|
||||
(UserObjectPermission, User, "user_id", "users"),
|
||||
(GroupObjectPermission, Group, "group_id", "groups"),
|
||||
for perm_model, identity_field, key in (
|
||||
(UserObjectPermission, "user_id", "users"),
|
||||
(GroupObjectPermission, "group_id", "groups"),
|
||||
):
|
||||
# Every grant belongs to an existing user or group, so restricting to
|
||||
# them changes no result. It lets MariaDB use guardian's
|
||||
# (identity, content_type, object_pk) index; the content type alone
|
||||
# matches every grant of the model and is scanned in full.
|
||||
rows = (
|
||||
perm_model.objects.filter(
|
||||
**{f"{identity_field}__in": identity_model.objects.values("pk")},
|
||||
**grant_filter,
|
||||
)
|
||||
perm_model.objects.filter(**grant_filter)
|
||||
.values_list("object_pk", "permission__codename", identity_field)
|
||||
.order_by(identity_field)
|
||||
)
|
||||
|
||||
@@ -397,14 +397,10 @@ class OwnedObjectSerializer(
|
||||
object_pks = list(obj.pk for obj in objects)
|
||||
pk_type = type(first_obj.pk)
|
||||
|
||||
def get_pks_for_permission_type(model, identity_field, identity_model):
|
||||
# Restricting to existing users/groups changes no result but lets
|
||||
# MariaDB use guardian's (identity, content_type, object_pk) index
|
||||
# instead of scanning every grant of the model.
|
||||
def get_pks_for_permission_type(model):
|
||||
return map(
|
||||
pk_type, # coerce the pk to be the same type of the provided objects
|
||||
model.objects.filter(
|
||||
**{f"{identity_field}__in": identity_model.objects.values("pk")},
|
||||
content_type=ctype,
|
||||
object_pk__in=object_pks,
|
||||
)
|
||||
@@ -414,16 +410,8 @@ class OwnedObjectSerializer(
|
||||
|
||||
UserObjectPermission = get_user_obj_perms_model()
|
||||
GroupObjectPermission = get_group_obj_perms_model()
|
||||
user_permission_pks = get_pks_for_permission_type(
|
||||
UserObjectPermission,
|
||||
"user_id",
|
||||
User,
|
||||
)
|
||||
group_permission_pks = get_pks_for_permission_type(
|
||||
GroupObjectPermission,
|
||||
"group_id",
|
||||
Group,
|
||||
)
|
||||
user_permission_pks = get_pks_for_permission_type(UserObjectPermission)
|
||||
group_permission_pks = get_pks_for_permission_type(GroupObjectPermission)
|
||||
|
||||
return set(user_permission_pks) | set(group_permission_pks)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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}
|
||||
Reference in new issue
Block a user