diff --git a/froide/helper/search/queryset.py b/froide/helper/search/queryset.py index 3432f6779..57fe04fdd 100644 --- a/froide/helper/search/queryset.py +++ b/froide/helper/search/queryset.py @@ -1,4 +1,7 @@ +import difflib +import html import logging +import re from django.utils.safestring import mark_safe @@ -170,10 +173,62 @@ def __iter__(self): hit = self._es_map[obj.pk] # mark_safe should work because highlight_options # has been set with encoder="html" - obj.query_highlight = mark_safe(" ".join(self._get_highlight(hit))) + obj.query_highlight = mark_safe( + html.unescape(" [...] ".join(self._get_highlight(hit))) + ) yield obj def _get_highlight(self, hit): if hasattr(hit.meta, "highlight"): + highlighted = set() + highlight_count = 0 for key in hit.meta.highlight: - yield from hit.meta.highlight[key] + for snippet in hit.meta.highlight[key]: + for s in filter_highlight_snippet(snippet): + if not has_similar_match(s, highlighted): + highlight_count += 1 + yield s + + if highlight_count == 5: + return + + highlighted.add(s) + + +def filter_highlight_snippet(snippet): + """ + Split a highlight snippet into sections based on whitespace clusters + and yields only those sections that contain tags but are not fully + enclosed by them. + """ + # Cluster of 2 or more whitespace characters + whitespace_cluster = re.compile(r"\s{2,}") + + sections = whitespace_cluster.split(snippet) + + for s in sections: + if "" in s and not (s.startswith("") and s.endswith("")): + yield s + + +def has_similar_match(word, possibilities, cutoff=0.9): + """ + Return True if `word` is close to any string in `possibilities` + with a similarity >= `cutoff`. + + Implementation inspired by difflib.get_close_matches: + https://github.com/python/cpython/blob/3.13/Lib/difflib.py#L=666 + """ + s = difflib.SequenceMatcher(isjunk=lambda c: c in " \r\n\t") + s.set_seq2(word) + + for x in possibilities: + s.set_seq1(x) + if ( + s.real_quick_ratio() >= cutoff + and s.quick_ratio() >= cutoff + and s.ratio() >= cutoff + ): + return True + + return False diff --git a/froide/helper/search/views.py b/froide/helper/search/views.py index 25615fde2..0809bb609 100644 --- a/froide/helper/search/views.py +++ b/froide/helper/search/views.py @@ -63,7 +63,10 @@ def get_search(self): if not self.has_query: s = s.sort(self.default_sort) else: - s = s.highlight_options(encoder="html").highlight("content") + # Retrieve 10 fragments of highlighted text, to be reduced to 5 later on. + s = s.highlight_options(encoder="html", number_of_fragments=10).highlight( + "content" + ) s = s.sort("_score") return s diff --git a/froide/helper/tests/test_search.py b/froide/helper/tests/test_search.py new file mode 100644 index 000000000..a0a96248a --- /dev/null +++ b/froide/helper/tests/test_search.py @@ -0,0 +1,165 @@ +from django.utils.safestring import SafeString + +import pytest + +from froide.helper.search.queryset import ESQuerySetWrapper + + +class DummyHitMeta: + def __init__(self, id, highlight=None): + self.id = id + self.highlight = highlight or {} + + +class DummyHit: + def __init__(self, id, highlight=None): + self.meta = DummyHitMeta(id, highlight) + + +class DummyObj: + def __init__(self, pk): + self.pk = pk + self.query_highlight = None + + +class DummyQS: + def __init__(self, objs): + self._objs = objs + + def __iter__(self): + return iter(self._objs) + + +# List of test cases to be used in the parameterized test below. +# Each test case is a tuple of (highlight_list, query_highlight) where highlight_list is the list +# of highlighted strings from Elasticsearch and expected_query_highlight is the expected post-processed +# string that will be shown to the user. +test_cases = [ + ( + [ + "Unterlagen zum "Gender-Verbot"\n\nAlle Unterlagen (interne und externe Korrespondenz, Vermerke", + ", Dienstanweisungen etc.) im Zusammenhang mit dem sogenannten "Gender-Verbot" an sächsischen", + "Schulen\n\nAnfrage erfolgreich \n\n\n\n\n \n Unterlagen zum "Gender-Verbot" [#284078]\n Antrag", + "externe Korrespondenz, Vermerke, Dienstanweisungen etc.) im Zusammenhang mit dem sogenannten "Gender-Verbot", + ], + ( + "Unterlagen zum "Gender-Verbot" [...] " + ", Dienstanweisungen etc.) im Zusammenhang mit dem sogenannten "Gender-Verbot" an sächsischen [...] " + # "Unterlagen zum "Gender-Verbot" [#284078] [...] " + "externe Korrespondenz, Vermerke, Dienstanweisungen etc.) im Zusammenhang mit dem sogenannten "Gender-Verbot" + ), + ), + ( + [ + "Genderverbot\n\nDie Regelung (Schreiben, Erlass, Weisung) des BMF zur internen Sprachregelung in Bezug aufs Gendern", + "zu:\n\nDie Regelung (Schreiben, Erlass, Weisung) des BMF zur internen Sprachregelung in Bezug aufs Gendern", + ], + "Die Regelung (Schreiben, Erlass, Weisung) des BMF zur internen Sprachregelung in Bezug aufs Gendern", + ), + ( + [ + "SIS II [#279515] # IFG-780/005 II#1095\n Der Bundesbeauftragte für den Datenschutz\nund die Informationsfreiheit", + "SIS II [#279515] # IFG-780/005 II#1095\n Der Bundesbeauftragte für den Datenschutz\nund die Informationsfreiheit", + "SIS II [#279515] # IFG-780/005 II#1095\n Der Bundesbeauftragte für den Datenschutz und die Informationsfreiheit", + "SIS II [#279515] # IFG-780/005 II#1095\n Der Bundesbeauftragte für den Datenschutz und die Informationsfreiheit", + "SIS II [#279515] # IFG-780/005 II#1095\n Der Bundesbeauftragte für den Datenschutz und die Informationsfreiheit", + "SIS II [#279515] # IFG-780/005 II#1095\n Der Bundesbeauftragte für den Datenschutz und die Informationsfreiheit", + "melek-bazgan-bfdi-12-12-2023.pdf\n \n \n\n\nDie Bundesbeauftragte für den Datenschutz und die Informationsfreiheit", + "Beauftragte für Datenschutz und Informationsfreiheit\n\n\n Datenschutz\n\n Informationsfreiheit", + ], + ( + "Der Bundesbeauftragte für den Datenschutz\nund die Informationsfreiheit [...] " + # "Die Bundesbeauftragte für den Datenschutz und die Informationsfreiheit [...] " + "Beauftragte für Datenschutz und Informationsfreiheit" + # "Informationsfreiheit" + ), + ), + ( + [ + "://fragdenstaat.de/hilfe/fuer-behoerden/\n\n \n \n\n \n Ihre Beschwerde im Bereich Informationsfreiheit", + "Der Landesbeauftragte für den Datenschutz\nund die Informationsfreiheit Rheinland-Pfalz\n\nInternet", + "Zeichen:\tfragdenstaat.de # 186145\n\n\n<<E-Mail-Adresse>>\n\n\nIhre Beschwerde im Bereich Informationsfreiheit", + "Sie darauf hinweisen, dass die Anrufung des Landesbeauftragten für den Datenschutz und die Informationsfreiheit", + "Slfdiprn0220071607220.pdf\n \n \n\n \n Ihre Beschwerde im Bereich Informationsfreiheit", + "Der Landesbeauftragte für den Datenschutz\nund die Informationsfreiheit Rheinland-Pfalz\n\nInternet", + "Zeichen:\tfragdenstaat.de # 186145\n\n\n<<E-Mail-Adresse>>\n\n\nIhre Beschwerde im Bereich Informationsfreiheit", + "Mit freundlichen Grüßen\n \n \n\n \n AW: Ihre Beschwerde im Bereich Informationsfreiheit [#186145", + "Ihr Antrag auf Informationszugang\n Der Landesbeauftragte für den Datenschutz\nund die Informationsfreiheit", + "Der Widerspruch ist bei dem Landesbeauftragten für den Datenschutz und die Informationsfreiheit Rheinland-Pfalz", + ], + ( + "Ihre Beschwerde im Bereich Informationsfreiheit [...] " + "Der Landesbeauftragte für den Datenschutz\nund die Informationsfreiheit Rheinland-Pfalz [...] " + "Sie darauf hinweisen, dass die Anrufung des Landesbeauftragten für den Datenschutz und die Informationsfreiheit [...] " + "AW: Ihre Beschwerde im Bereich Informationsfreiheit [#186145 [...] " + "Der Widerspruch ist bei dem Landesbeauftragten für den Datenschutz und die Informationsfreiheit Rheinland-Pfalz" + ), + ), + ( + [ + "]\n HmbTG Antrag auf Übersendung der beim Hamburgischen Beauftragten für Datenschutz und Informationsfreiheit", + "Die Prüfung auf Übersendung der beim Hamburgischen Beauftragten für Datenschutz und Informationsfreiheit", + "Ihrer Mail vom 02.02.2017 auf Zugang zu der dem Hamburgischen Beauftragten für Datenschutz und Informationsfreiheit", + "Hintergrund war Ihr Antrag auf Zugang zu der dem Harnburgischen Beauftragten für Datenschutz und Informationsfreiheit", + "Monats nach Bekanntgabe Widerspruch bei dem Harnburgischen Beauftragten für Datenschutz und Informationsfreiheit", + "Möglichkeit, Widerspruch zu erheben - den Harnburgischen Beauftragten für Datenschutz und Informationsfreiheit", + "hmbfdi-eao.pdf\n \n \n\n\nDer Hamburgische Beauftragte für Datenschutz und Informationsfreiheit", + "Landesbeauftragte für Datenschutz und Informationsfreiheit\n\n\n Inneres\n\n Datenschutz\n\n Informationsfreiheit", + ], + ( + "HmbTG Antrag auf Übersendung der beim Hamburgischen Beauftragten für Datenschutz und Informationsfreiheit [...] " + # "Die Prüfung auf Übersendung der beim Hamburgischen Beauftragten für Datenschutz und Informationsfreiheit [...] " + "Ihrer Mail vom 02.02.2017 auf Zugang zu der dem Hamburgischen Beauftragten für Datenschutz und Informationsfreiheit [...] " + "Hintergrund war Ihr Antrag auf Zugang zu der dem Harnburgischen Beauftragten für Datenschutz und Informationsfreiheit [...] " + "Monats nach Bekanntgabe Widerspruch bei dem Harnburgischen Beauftragten für Datenschutz und Informationsfreiheit [...] " + "Möglichkeit, Widerspruch zu erheben - den Harnburgischen Beauftragten für Datenschutz und Informationsfreiheit" + # "Der Hamburgische Beauftragte für Datenschutz und Informationsfreiheit [...] " + # "Landesbeauftragte für Datenschutz und Informationsfreiheit" + ), + ), + ( + [ + "Schriftverkehr zwischen BMI und AA in Bezug auf Schreiben an Seenotrettungsorganisationen\n\nSämtlichen", + "Schriftverkehr zwischen dem BMI und dem AA in Bezug auf das Schreiben des MinDir Weinbrenneran Seenotrettungsorganisationen", + "Information nicht vorhanden \n\n\n\n\n \n Schriftverkehr zwischen BMI und AA in Bezug auf Schreiben an", + "/VIG\r\n\r\nSehr geehrte<< Anrede >>\n\r\nbitte senden Sie mir Folgendes zu:\n\nSämtlichen Schriftverkehr", + "notwendig wäre, besuchen Sie:\nhttps://fragdenstaat.de/hilfe/fuer-behoerden/\n\n \n \n\n \n Schriftverkehr", + "geehrter Herr Semsrott,\n\n\xa0\n\nin Erledigung Ihres IFG- Antrages teile ich Ihnen mit, dass kein\nSchriftverkehr", + ], + ( + "Schriftverkehr zwischen BMI und AA in Bezug auf Schreiben an Seenotrettungsorganisationen [...] " + "Schriftverkehr zwischen dem BMI und dem AA in Bezug auf das Schreiben des MinDir Weinbrenneran Seenotrettungsorganisationen [...] " + "Schriftverkehr zwischen BMI und AA in Bezug auf Schreiben an [...] " + "Sämtlichen Schriftverkehr [...] " + "in Erledigung Ihres IFG- Antrages teile ich Ihnen mit, dass kein\nSchriftverkehr" + ), + ), +] + + +@pytest.mark.parametrize( + "highlight_list, query_highlight", test_cases, ids=[x[1][:20] for x in test_cases] +) +def test_es_queryset_wrapper_iter_highlight(highlight_list, query_highlight): + obj = DummyObj(1) + hit = DummyHit(1, {"field": highlight_list}) + qs = DummyQS([obj]) + es_response = [hit] + + wrapper = ESQuerySetWrapper(qs, es_response) + result = list(wrapper) + + assert isinstance(result[0].query_highlight, SafeString) + assert result[0].query_highlight == query_highlight + + +def test_es_queryset_wrapper_iter_no_highlight(): + obj = DummyObj(2) + hit = DummyHit(2) + qs = DummyQS([obj]) + es_response = [hit] + + wrapper = ESQuerySetWrapper(qs, es_response) + result = list(wrapper) + + assert result[0].query_highlight == ""