diff --git a/src/backend/core/services/search_indexers.py b/src/backend/core/services/search_indexers.py index 28efd890..4c5e24c0 100644 --- a/src/backend/core/services/search_indexers.py +++ b/src/backend/core/services/search_indexers.py @@ -8,7 +8,6 @@ from functools import cache from django.conf import settings from django.contrib.auth.models import AnonymousUser from django.core.exceptions import ImproperlyConfigured -from django.db.models import Subquery from django.utils.module_loading import import_string import requests @@ -193,7 +192,7 @@ class BaseDocumentIndexer(ABC): Returns ids of the documents Args: - q (str): Text search content. + q (str): user query. token (str): OIDC Authentication token. visited (list, optional): List of ids of active public documents with LinkTrace @@ -202,7 +201,7 @@ class BaseDocumentIndexer(ABC): The number of results to return. Defaults to 50 if not specified. path (str, optional): - The path to filter documents. + The parent path to search descendants of. """ nb_results = nb_results or self.search_limit results = self.search_query( @@ -229,11 +228,57 @@ class BaseDocumentIndexer(ABC): """ -class SearchIndexer(BaseDocumentIndexer): +class FindDocumentIndexer(BaseDocumentIndexer): """ - Document indexer that pushes documents to La Suite Find app. + Document indexer that indexes and searches documents with La Suite Find app. """ + # pylint: disable=too-many-arguments,too-many-positional-arguments + def search(self, q, token, visited=(), nb_results=None, path=None): + """format Find search results""" + search_results = super().search(q, token, visited, nb_results, path) + return [ + { + **hit["_source"], + "id": hit["_id"], + "title": self.get_title(hit["_source"]), + } + for hit in search_results + ] + + @staticmethod + def get_title(source): + """ + Find returns the titles with an extension depending on the language. + This function extracts the title in a generic way. + + Handles multiple cases: + - Localized title fields like "title." + - Fallback to plain "title" field if localized version not found + - Returns empty string if no title field exists + + Args: + source (dict): The _source dictionary from a search hit + + Returns: + str: The extracted title or empty string if not found + + Example: + >>> get_title({"title.fr": "Bonjour", "id": 1}) + "Bonjour" + >>> get_title({"title": "Hello", "id": 1}) + "Hello" + >>> get_title({"id": 1}) + "" + """ + titles = utils.get_value_by_pattern(source, r"^title\.") + for title in titles: + if title: + return title + if "title" in source: + return source["title"] + return "" + def serialize_document(self, document, accesses): """ Convert a Document to the JSON format expected by La Suite Find. diff --git a/src/backend/core/tests/test_services_search_indexers.py b/src/backend/core/tests/test_services_search_indexers.py index 61488a92..43fc6fda 100644 --- a/src/backend/core/tests/test_services_search_indexers.py +++ b/src/backend/core/tests/test_services_search_indexers.py @@ -633,3 +633,51 @@ def test_services_search_indexers_search_nb_results(mock_post, indexer_settings) assert args[0] == indexer_settings.SEARCH_INDEXER_QUERY_URL assert kwargs.get("json")["nb_results"] == 109 + + +def test_search_indexer_get_title_with_localized_field(): + """Test extracting title from localized title field.""" + source = {"title.extension": "Bonjour", "id": 1, "content": "test"} + result = SearchIndexer.get_title(source) + + assert result == "Bonjour" + + +def test_search_indexer_get_title_with_multiple_localized_fields(): + """Test that first matching localized title is returned.""" + source = {"title.extension": "Bonjour", "title.en": "Hello", "id": 1} + result = SearchIndexer.get_title(source) + + assert result in ["Bonjour", "Hello"] + + +def test_search_indexer_get_title_fallback_to_plain_title(): + """Test fallback to plain 'title' field when no localized field exists.""" + source = {"title": "Hello World", "id": 1} + result = SearchIndexer.get_title(source) + + assert result == "Hello World" + + +def test_search_indexer_get_title_no_title_field(): + """Test that empty string is returned when no title field exists.""" + source = {"id": 1, "content": "test"} + result = SearchIndexer.get_title(source) + + assert result == "" + + +def test_search_indexer_get_title_with_empty_localized_title(): + """Test that fallback works when localized title is empty.""" + source = {"title.extension": "", "title": "Fallback Title", "id": 1} + result = SearchIndexer.get_title(source) + + assert result == "Fallback Title" + + +def test_search_indexer_get_title_with_multiple_extension(): + """Test extracting title from title field with multiple extensions.""" + source = {"title.extension_1.extension_2": "Bonjour", "id": 1, "content": "test"} + result = SearchIndexer.get_title(source) + + assert result == "Bonjour" diff --git a/src/backend/core/tests/test_utils.py b/src/backend/core/tests/test_utils.py index 6ab5e32c..e12960c5 100644 --- a/src/backend/core/tests/test_utils.py +++ b/src/backend/core/tests/test_utils.py @@ -205,3 +205,38 @@ def test_utils_users_sharing_documents_with_empty_result(): cached_data = cache.get(cache_key) assert cached_data == {} + + +def test_utils_get_value_by_pattern_matching_key(): + """Test extracting value from a dictionary with a matching key pattern.""" + data = {"title.extension": "Bonjour", "id": 1, "content": "test"} + result = utils.get_value_by_pattern(data, r"^title\.") + + assert set(result) == {"Bonjour"} + + +def test_utils_get_value_by_pattern_multiple_matches(): + """Test that all matching keys are returned.""" + data = {"title.extension_1": "Bonjour", "title.extension_2": "Hello", "id": 1} + result = utils.get_value_by_pattern(data, r"^title\.") + + assert set(result) == { + "Bonjour", + "Hello", + } + + +def test_utils_get_value_by_pattern_multiple_extensions(): + """Test that all matching keys are returned.""" + data = {"title.extension_1.extension_2": "Bonjour", "id": 1} + result = utils.get_value_by_pattern(data, r"^title\.") + + assert set(result) == {"Bonjour"} + + +def test_utils_get_value_by_pattern_no_match(): + """Test that empty list is returned when no key matches the pattern.""" + data = {"name": "Test", "id": 1} + result = utils.get_value_by_pattern(data, r"^title\.") + + assert result == [] diff --git a/src/backend/core/utils.py b/src/backend/core/utils.py index fbfac598..50bee73e 100644 --- a/src/backend/core/utils.py +++ b/src/backend/core/utils.py @@ -18,6 +18,27 @@ from core import enums, models logger = logging.getLogger(__name__) +def get_value_by_pattern(data, pattern): + """ + Get all values from keys matching a regex pattern in a dictionary. + + Args: + data (dict): Source dictionary to search + pattern (str): Regex pattern to match against keys + + Returns: + list: List of values for all matching keys, empty list if no matches + + Example: + >>> get_value_by_pattern({"title.fr": "Bonjour", "id": 1}, r"^title\\.") + ["Bonjour"] + >>> get_value_by_pattern({"title.fr": "Bonjour", "title.en": "Hello"}, r"^title\\.") + ["Bonjour", "Hello"] + """ + regex = re.compile(pattern) + return [value for key, value in data.items() if regex.match(key)] + + def get_ancestor_to_descendants_map(paths, steplen): """ Given a list of document paths, return a mapping of ancestor_path -> set of descendant_paths.