diff --git a/src/documents/caching.py b/src/documents/caching.py index e877d5827..ae697b719 100644 --- a/src/documents/caching.py +++ b/src/documents/caching.py @@ -45,14 +45,16 @@ 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: +# this whenever cached suggestions must not be reused, including changes to +# their shape or interpretation, so a previous release's result cannot leak +# incompatible or obsolete behavior into the new one: # 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 +# 1002 - names are always generated and optional candidate mappings are +# validated separately, so candidate-anchored 1001 results are stale +LLM_CACHE_CLASSIFIER_VERSION: Final[int] = 1002 CACHE_1_MINUTE: Final[int] = 60 CACHE_5_MINUTES: Final[int] = 5 * CACHE_1_MINUTE diff --git a/src/paperless_ai/tests/test_ai_classifier.py b/src/paperless_ai/tests/test_ai_classifier.py index bb0f8f9e1..39bb21cb4 100644 --- a/src/paperless_ai/tests/test_ai_classifier.py +++ b/src/paperless_ai/tests/test_ai_classifier.py @@ -175,6 +175,7 @@ def test_get_ai_document_classification_failure(mock_run_llm_query, mock_documen @pytest.mark.django_db @patch("paperless_ai.client.AIClient.run_llm_query") @patch("paperless_ai.ai_classifier.build_prompt_with_rag") +@patch("paperless_ai.ai_classifier.build_taxonomy_candidates") @patch("paperless_ai.ai_classifier.retrieve_similar_nodes") @override_settings( LLM_EMBEDDING_BACKEND="huggingface", @@ -184,6 +185,7 @@ def test_get_ai_document_classification_failure(mock_run_llm_query, mock_documen ) def test_use_rag_if_configured( mock_retrieve, + mock_build_candidates, mock_build_prompt_with_rag, mock_run_llm_query, mock_document, @@ -195,12 +197,29 @@ def test_use_rag_if_configured( - get_ai_document_classification() is called THEN: - The RAG-augmented prompt builder is used + - Classification and candidate reconciliation happen in one LLM call + - Only candidate IDs from the permission-filtered candidate set are allowed """ mock_retrieve.return_value = [] + mock_build_candidates.return_value = TaxonomyCandidates( + tags=[TaxonomyCandidate(id=12, name="Contractor", weight=1.0)], + document_types=[], + correspondents=[], + storage_paths=[], + ) mock_build_prompt_with_rag.return_value = "Prompt with RAG" mock_run_llm_query.return_value = NESTED_SUGGESTIONS get_ai_document_classification(mock_document) mock_build_prompt_with_rag.assert_called_once() + mock_run_llm_query.assert_called_once_with( + "Prompt with RAG", + allowed_candidate_ids={ + "tags": {12}, + "document_types": set(), + "correspondents": set(), + "storage_paths": set(), + }, + ) @pytest.mark.django_db