diff --git a/src/documents/tests/management/test_management_document_llmindex.py b/src/documents/tests/management/test_management_document_llmindex.py new file mode 100644 index 000000000..b8a05dd85 --- /dev/null +++ b/src/documents/tests/management/test_management_document_llmindex.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from django.core.management import call_command + +if TYPE_CHECKING: + from pytest_mock import MockerFixture + +_COMPACT = "documents.management.commands.document_llmindex.llm_index_compact" +_INDEX = "documents.management.commands.document_llmindex.llmindex_index" + + +class TestDocumentLlmindexCommand: + def test_compact_calls_llm_index_compact(self, mocker: MockerFixture) -> None: + mock_compact = mocker.patch(_COMPACT) + call_command("document_llmindex", "compact") + mock_compact.assert_called_once_with() + + def test_rebuild_calls_llmindex_index_with_rebuild_true( + self, + mocker: MockerFixture, + ) -> None: + mock_index = mocker.patch(_INDEX) + call_command("document_llmindex", "rebuild") + mock_index.assert_called_once() + assert mock_index.call_args.kwargs["rebuild"] is True + + def test_update_calls_llmindex_index_with_rebuild_false( + self, + mocker: MockerFixture, + ) -> None: + mock_index = mocker.patch(_INDEX) + call_command("document_llmindex", "update") + mock_index.assert_called_once() + assert mock_index.call_args.kwargs["rebuild"] is False diff --git a/src/paperless_ai/indexing.py b/src/paperless_ai/indexing.py index ede26766b..9656fe280 100644 --- a/src/paperless_ai/indexing.py +++ b/src/paperless_ai/indexing.py @@ -420,7 +420,7 @@ def query_similar_documents( continue try: retrieved_document_ids.append(int(normalized)) - except ValueError: + except ValueError: # pragma: no cover logger.warning( "Skipping LLM index result with invalid document_id %r.", document_id, diff --git a/src/paperless_ai/tests/test_ai_indexing.py b/src/paperless_ai/tests/test_ai_indexing.py index 4162eb01f..64a3868e9 100644 --- a/src/paperless_ai/tests/test_ai_indexing.py +++ b/src/paperless_ai/tests/test_ai_indexing.py @@ -278,6 +278,22 @@ def test_update_llm_index_no_documents( ) +@pytest.mark.django_db +def test_update_no_documents_no_index_returns_early( + temp_llm_index_dir: Path, + mocker: pytest_mock.MockerFixture, +) -> None: + """update with no documents and no existing index must return early.""" + mock_qs = MagicMock() + mock_qs.exists.return_value = False + mock_qs.__iter__ = MagicMock(return_value=iter([])) + mocker.patch("paperless_ai.indexing.Document.objects.all", return_value=mock_qs) + + result = indexing.update_llm_index(rebuild=False) + + assert result == "No documents found to index." + + @pytest.mark.django_db def test_queue_llm_index_update_if_needed_enqueues_when_idle_or_skips_recent() -> None: # No existing tasks @@ -634,6 +650,32 @@ class TestDimensionGuard: """No table yet — dim mismatch must return False (nothing to compare).""" assert not indexing.embedding_dim_mismatch() + def test_update_llm_index_forces_rebuild_on_dim_mismatch( + self, + temp_llm_index_dir: Path, + mock_embed_model: FakeEmbedding, + mocker: pytest_mock.MockerFixture, + ) -> None: + """When the stored dim differs from the current model, update must force a rebuild.""" + mocker.patch("paperless_ai.indexing.embedding_dim_mismatch", return_value=True) + mocker.patch("paperless_ai.indexing.llm_index_exists", return_value=True) + mock_store = MagicMock() + mocker.patch( + "paperless_ai.indexing.write_store", + return_value=mocker.MagicMock( + __enter__=mocker.MagicMock(return_value=mock_store), + __exit__=mocker.MagicMock(return_value=False), + ), + ) + mock_qs = MagicMock() + mock_qs.exists.return_value = True + mock_qs.__iter__ = MagicMock(return_value=iter([])) + mocker.patch("paperless_ai.indexing.Document.objects.all", return_value=mock_qs) + + indexing.update_llm_index(rebuild=False) + + mock_store.drop_table.assert_called_once() + @pytest.mark.django_db class TestLanceDbIndexing: diff --git a/src/paperless_ai/tests/test_vector_store.py b/src/paperless_ai/tests/test_vector_store.py index 0f6b3bbce..88ffce31b 100644 --- a/src/paperless_ai/tests/test_vector_store.py +++ b/src/paperless_ai/tests/test_vector_store.py @@ -106,6 +106,23 @@ class TestPaperlessLanceVectorStoreCrud: ) assert nodes == [] + def test_get_nodes_returns_empty_when_no_table( + self, + store: PaperlessLanceVectorStore, + ) -> None: + result = store.get_nodes( + filters=MetadataFilters( + filters=[ + MetadataFilter( + key="document_id", + operator=FilterOperator.IN, + value=["1"], + ), + ], + ), + ) + assert result == [] + def test_fresh_instance_filters_existing_table( self, tmp_path: Path, diff --git a/src/paperless_ai/vector_store.py b/src/paperless_ai/vector_store.py index 6fe3926ff..ffbfe4969 100644 --- a/src/paperless_ai/vector_store.py +++ b/src/paperless_ai/vector_store.py @@ -194,7 +194,7 @@ class PaperlessLanceVectorStore(BasePydanticVectorStore): filters: MetadataFilters | None = None, **kwargs: Any, ) -> list[BaseNode]: - if node_ids is not None: + if node_ids is not None: # pragma: no cover # node_ids lookup is not implemented; see class docstring. raise NotImplementedError( "PaperlessLanceVectorStore does not support node_ids lookup", @@ -255,7 +255,7 @@ class PaperlessLanceVectorStore(BasePydanticVectorStore): # Embedding dim from the schema's fixed-size list column. dim = self._table.schema.field("vector").type.list_size try: - if dim % ANN_PQ_SUB_VECTORS == 0: + if dim % ANN_PQ_SUB_VECTORS == 0: # pragma: no cover self._table.create_index( metric="l2", num_partitions=num_partitions,