Files
paperless-ngx/src/paperless_ai/tests/test_matching.py
T
stumpylog ef90c418cd Feature: prefer existing tags, types, correspondents, and storage paths in AI suggestions
AI Suggestions previously invented near-duplicate metadata because the classification
prompt had no knowledge of the installation's own taxonomy. This surfaces
a small, ranked, permission-filtered set of existing tags/document
types/correspondents/storage paths - drawn from the document's RAG
neighbors plus its own already-assigned metadata - so the model prefers
reusing what already exists.

The LLM response schema now returns existing_ids (IDs of reused
candidates) separately from new_names (genuinely new suggestions).
Only new_names goes through localization and fuzzy name-matching;
existing_ids is resolved deterministically and never touched by the
localization pass, so exact matches can no longer be silently
corrupted by translation.
2026-08-13 13:00:06 -07:00

220 lines
8.5 KiB
Python

from collections.abc import Callable
from unittest.mock import patch
import pytest
import pytest_mock
from django.contrib.auth.models import User
from django.test import TestCase
from factory.django import DjangoModelFactory
from documents.models import Correspondent
from documents.models import DocumentType
from documents.models import StoragePath
from documents.models import Tag
from documents.tests.factories import CorrespondentFactory
from documents.tests.factories import DocumentTypeFactory
from documents.tests.factories import StoragePathFactory
from documents.tests.factories import TagFactory
from documents.tests.factories import UserFactory
from paperless_ai.matching import extract_unmatched_names
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
class TestAIMatching(TestCase):
def setUp(self) -> None:
# Create test data for Tag
self.tag1 = Tag.objects.create(name="Test Tag 1")
self.tag2 = Tag.objects.create(name="Test Tag 2")
# Create test data for Correspondent
self.correspondent1 = Correspondent.objects.create(name="Test Correspondent 1")
self.correspondent2 = Correspondent.objects.create(name="Test Correspondent 2")
# Create test data for DocumentType
self.document_type1 = DocumentType.objects.create(name="Test Document Type 1")
self.document_type2 = DocumentType.objects.create(name="Test Document Type 2")
# Create test data for StoragePath
self.storage_path1 = StoragePath.objects.create(name="Test Storage Path 1")
self.storage_path2 = StoragePath.objects.create(name="Test Storage Path 2")
@patch("paperless_ai.matching.get_objects_for_user_owner_aware")
def test_match_tags_by_name(self, mock_get_objects) -> None:
mock_get_objects.return_value = Tag.objects.all()
names = ["Test Tag 1", "Nonexistent Tag"]
result = match_tags_by_name(names, user=None)
self.assertEqual(len(result), 1)
self.assertEqual(result[0].name, "Test Tag 1")
@patch("paperless_ai.matching.get_objects_for_user_owner_aware")
def test_match_correspondents_by_name(self, mock_get_objects) -> None:
mock_get_objects.return_value = Correspondent.objects.all()
names = ["Test Correspondent 1", "Nonexistent Correspondent"]
result = match_correspondents_by_name(names, user=None)
self.assertEqual(len(result), 1)
self.assertEqual(result[0].name, "Test Correspondent 1")
@patch("paperless_ai.matching.get_objects_for_user_owner_aware")
def test_match_document_types_by_name(self, mock_get_objects) -> None:
mock_get_objects.return_value = DocumentType.objects.all()
names = ["Test Document Type 1", "Nonexistent Document Type"]
result = match_document_types_by_name(names, user=None)
self.assertEqual(len(result), 1)
self.assertEqual(result[0].name, "Test Document Type 1")
@patch("paperless_ai.matching.get_objects_for_user_owner_aware")
def test_match_storage_paths_by_name(self, mock_get_objects) -> None:
mock_get_objects.return_value = StoragePath.objects.all()
names = ["Test Storage Path 1", "Nonexistent Storage Path"]
result = match_storage_paths_by_name(names, user=None)
self.assertEqual(len(result), 1)
self.assertEqual(result[0].name, "Test Storage Path 1")
def test_extract_unmatched_names(self) -> None:
llm_names = ["Test Tag 1", "Nonexistent Tag"]
matched_objects = [self.tag1]
unmatched_names = extract_unmatched_names(llm_names, matched_objects)
self.assertEqual(unmatched_names, ["Nonexistent Tag"])
@patch("paperless_ai.matching.get_objects_for_user_owner_aware")
def test_match_tags_by_name_with_empty_names(self, mock_get_objects) -> None:
mock_get_objects.return_value = Tag.objects.all()
names = [None, "", " "]
result = match_tags_by_name(names, user=None)
self.assertEqual(result, [])
@patch("paperless_ai.matching.get_objects_for_user_owner_aware")
def test_match_tags_with_fuzzy_matching(self, mock_get_objects) -> None:
mock_get_objects.return_value = Tag.objects.all()
names = ["Test Taag 1", "Teest Tag 2"]
result = match_tags_by_name(names, user=None)
self.assertEqual(len(result), 2)
self.assertEqual(result[0].name, "Test Tag 1")
self.assertEqual(result[1].name, "Test Tag 2")
@pytest.mark.django_db
class TestExtractUnmatchedNamesNormalization:
def test_punctuated_name_already_matched_is_not_returned_as_unmatched(
self,
) -> None:
correspondent = Correspondent.objects.create(name="J Smith")
llm_names = ["J. Smith"]
matched_objects: list[Correspondent] = [correspondent]
unmatched = extract_unmatched_names(llm_names, matched_objects)
assert "J. Smith" not in unmatched
@pytest.mark.django_db
class TestResolveTagIds:
def test_resolves_valid_visible_id(self) -> None:
"""GIVEN a tag and a user with no restrictions
WHEN resolving the tag's id
THEN the tag is returned.
"""
tag = TagFactory.create(name="Bloodwork")
user = UserFactory.create()
result = resolve_tag_ids([tag.pk], user)
assert result == [tag]
def test_drops_nonexistent_id(self) -> None:
"""GIVEN an id that does not correspond to any tag
WHEN resolving that id
THEN an empty list is returned.
"""
user = UserFactory.create()
result = resolve_tag_ids([999999], user)
assert result == []
def test_drops_id_not_visible_to_user(
self,
mocker: pytest_mock.MockerFixture,
) -> None:
"""GIVEN a valid tag id that permitted_object_ids reports as not
visible to the user
WHEN resolving that id
THEN the tag is dropped from the result.
"""
tag = TagFactory.create(name="Restricted")
user = UserFactory.create()
mocker.patch(
"documents.permissions.permitted_object_ids",
return_value=[],
)
result = resolve_tag_ids([tag.pk], user)
assert result == []
def test_empty_input_returns_empty(self) -> None:
"""GIVEN an empty list of ids
WHEN resolving tag ids
THEN an empty list is returned.
"""
user = UserFactory.create()
assert resolve_tag_ids([], user) == []
def test_user_none_means_unrestricted_not_owner_isnull(
self,
mocker: pytest_mock.MockerFixture,
) -> None:
"""GIVEN a tag owned by another user and user=None
WHEN resolving the tag's id
THEN the tag is returned unfiltered and permitted_object_ids is never
called - user=None means "no restriction", not the narrower
"only unowned rows" meaning permitted_object_ids(None, ...) has.
Same convention as build_taxonomy_candidates's own call site.
"""
tag = TagFactory.create(name="Owned")
owner = UserFactory.create()
tag.owner = owner
tag.save()
spy = mocker.patch("documents.permissions.permitted_object_ids")
result = resolve_tag_ids([tag.pk], None)
assert result == [tag]
spy.assert_not_called()
@pytest.mark.django_db
class TestResolveOtherTaxonomyIds:
"""The non-tag resolvers share resolve_tag_ids' implementation, so they
only need the happy path covered here."""
@pytest.mark.parametrize(
("factory", "name", "resolve"),
[
(CorrespondentFactory, "IRS", resolve_correspondent_ids),
(DocumentTypeFactory, "Invoice", resolve_document_type_ids),
(StoragePathFactory, "Financial", resolve_storage_path_ids),
],
)
def test_resolves_valid_id(
self,
factory: type[DjangoModelFactory],
name: str,
resolve: Callable[[list[int], User], list],
) -> None:
"""GIVEN a taxonomy object and a user with no restrictions
WHEN resolving that object's id
THEN the object is returned.
"""
obj = factory.create(name=name)
user = UserFactory.create()
assert resolve([obj.pk], user) == [obj]