Skip to content

Commit 370ddbb

Browse files
committed
💄 Clean up display of highlighted search results
1 parent 65120f4 commit 370ddbb

4 files changed

Lines changed: 227 additions & 4 deletions

File tree

‎froide/helper/search/queryset.py‎

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1+
import difflib
2+
import html
13
import logging
4+
import re
25

36
from django.utils.safestring import mark_safe
47

@@ -170,10 +173,62 @@ def __iter__(self):
170173
hit = self._es_map[obj.pk]
171174
# mark_safe should work because highlight_options
172175
# has been set with encoder="html"
173-
obj.query_highlight = mark_safe(" ".join(self._get_highlight(hit)))
176+
obj.query_highlight = mark_safe(
177+
html.unescape(" [...] ".join(self._get_highlight(hit)))
178+
)
174179
yield obj
175180

176181
def _get_highlight(self, hit):
177182
if hasattr(hit.meta, "highlight"):
183+
highlighted = set()
184+
highlight_count = 0
178185
for key in hit.meta.highlight:
179-
yield from hit.meta.highlight[key]
186+
for snippet in hit.meta.highlight[key]:
187+
for s in filter_highlight_snippet(snippet):
188+
if not has_similar_match(s, highlighted):
189+
highlight_count += 1
190+
yield s
191+
192+
if highlight_count == 5:
193+
return
194+
195+
highlighted.add(s)
196+
197+
198+
def filter_highlight_snippet(snippet):
199+
"""
200+
Split a highlight snippet into sections based on whitespace clusters
201+
and yield only those sections that contain <em> tags but are not fully
202+
enclosed by them.
203+
"""
204+
# Cluster of 2 or more whitespace characters
205+
whitespace_cluster = re.compile(r"\s{2,}")
206+
207+
sections = whitespace_cluster.split(snippet)
208+
209+
for s in sections:
210+
if "<em>" in s and not (s.startswith("<em>") and s.endswith("</em>")):
211+
yield s
212+
213+
214+
def has_similar_match(word, possibilities, cutoff=0.9):
215+
"""
216+
Return True if `word` is close to any string in `possibilities`
217+
with a similarity >= `cutoff`.
218+
219+
Implementation inspired by difflib.get_close_matches:
220+
https://github.com/python/cpython/blob/3.13/Lib/difflib.py#L=666
221+
"""
222+
s = difflib.SequenceMatcher(isjunk=lambda c: c in " \r\n\t")
223+
s.set_seq2(word)
224+
225+
for x in possibilities:
226+
s.set_seq1(x)
227+
if (
228+
s.real_quick_ratio() >= cutoff
229+
and s.quick_ratio() >= cutoff
230+
and s.ratio() >= cutoff
231+
):
232+
return True
233+
234+
return False

‎froide/helper/search/views.py‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,11 @@ def get_search(self):
6363
if not self.has_query:
6464
s = s.sort(self.default_sort)
6565
else:
66-
s = s.highlight_options(encoder="html").highlight("content")
66+
# Retrieve 10 fragments of highlighted text, to be reduced to 5 later on
67+
# due to possible duplicates.
68+
s = s.highlight_options(encoder="html", number_of_fragments=10).highlight(
69+
"content"
70+
)
6771
s = s.sort("_score")
6872
return s
6973

‎froide/helper/tests/test_search.py‎

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,48 @@
11
from unittest.mock import MagicMock
22

3+
from django.utils.safestring import SafeString
4+
5+
import pytest
6+
37
from froide.helper.search.filters import BaseSearchFilterSet
8+
from froide.helper.search.queryset import ESQuerySetWrapper
9+
from froide.helper.tests.testdata.search_highlights import search_highlight_tests
410

511

612
class DummyModel:
713
pass
814

915

1016
class DummyQS:
11-
def __init__(self):
17+
def __init__(self, objs=None):
18+
self._objs = objs
1219
self.model = DummyModel()
1320
self.query = None
1421

1522
def set_query(self, q):
1623
self.query = q
1724
return self
1825

26+
def __iter__(self):
27+
return iter(self._objs)
28+
29+
30+
class DummyHitMeta:
31+
def __init__(self, id, highlight=None):
32+
self.id = id
33+
self.highlight = highlight or {}
34+
35+
36+
class DummyHit:
37+
def __init__(self, id, highlight=None):
38+
self.meta = DummyHitMeta(id, highlight)
39+
40+
41+
class DummyObj:
42+
def __init__(self, pk):
43+
self.pk = pk
44+
self.query_highlight = None
45+
1946

2047
class TestBaseSearchFilterSetQueryPreprocessing:
2148
def test_auto_query_without_query_value(self):
@@ -53,3 +80,35 @@ def test_auto_query_with_custom_query_preprocessor(self, monkeypatch):
5380

5481
assert result.query.query == "processed query"
5582
mock_preprocessor.prepare_query.assert_called_once_with("original query")
83+
84+
85+
@pytest.mark.parametrize(
86+
"highlight_list, query_highlight",
87+
search_highlight_tests,
88+
ids=[x[1][:20] for x in search_highlight_tests],
89+
)
90+
def test_es_queryset_wrapper_iter_highlight(highlight_list, query_highlight):
91+
"""Test that highlights in search results are processed correctly."""
92+
obj = DummyObj(1)
93+
hit = DummyHit(1, {"field": highlight_list})
94+
qs = DummyQS([obj])
95+
es_response = [hit]
96+
97+
wrapper = ESQuerySetWrapper(qs, es_response)
98+
result = list(wrapper)
99+
100+
assert isinstance(result[0].query_highlight, SafeString)
101+
assert result[0].query_highlight == query_highlight
102+
103+
104+
def test_es_queryset_wrapper_iter_no_highlight():
105+
"""Test that absence of highlights results in empty string."""
106+
obj = DummyObj(2)
107+
hit = DummyHit(2)
108+
qs = DummyQS([obj])
109+
es_response = [hit]
110+
111+
wrapper = ESQuerySetWrapper(qs, es_response)
112+
result = list(wrapper)
113+
114+
assert result[0].query_highlight == ""
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
# List of test cases to be used in parameterized tests.
2+
# Each test case is a tuple of (highlight_list, query_highlight) where highlight_list is the list
3+
# of highlighted strings from Elasticsearch and expected_query_highlight is the expected post-processed
4+
# string that will be shown to the user.
5+
search_highlight_tests = [
6+
(
7+
[
8+
"Unterlagen zum &amp;quot;<em>Gender</em>-Verbot&amp;quot;\n\nAlle Unterlagen (interne und externe Korrespondenz, Vermerke",
9+
", Dienstanweisungen etc.) im Zusammenhang mit dem sogenannten &amp;quot;<em>Gender</em>-Verbot&amp;quot; an sächsischen",
10+
"Schulen\n\nAnfrage erfolgreich \n\n\n\n\n \n Unterlagen zum &amp;quot;<em>Gender</em>-Verbot&amp;quot; [#284078]\n Antrag",
11+
"externe Korrespondenz, Vermerke, Dienstanweisungen etc.) im Zusammenhang mit dem sogenannten &amp;quot;<em>Gender</em>-Verbot",
12+
],
13+
(
14+
"Unterlagen zum &quot;<em>Gender</em>-Verbot&quot; [...] "
15+
", Dienstanweisungen etc.) im Zusammenhang mit dem sogenannten &quot;<em>Gender</em>-Verbot&quot; an sächsischen [...] "
16+
# "Unterlagen zum &quot;<em>Gender</em>-Verbot&quot; [#284078] [...] "
17+
"externe Korrespondenz, Vermerke, Dienstanweisungen etc.) im Zusammenhang mit dem sogenannten &quot;<em>Gender</em>-Verbot"
18+
),
19+
),
20+
(
21+
[
22+
"Genderverbot\n\nDie Regelung (Schreiben, Erlass, Weisung) des BMF zur internen Sprachregelung in Bezug aufs <em>Gendern</em>",
23+
"zu:\n\nDie Regelung (Schreiben, Erlass, Weisung) des BMF zur internen Sprachregelung in Bezug aufs <em>Gendern</em>",
24+
],
25+
"Die Regelung (Schreiben, Erlass, Weisung) des BMF zur internen Sprachregelung in Bezug aufs <em>Gendern</em>",
26+
),
27+
(
28+
[
29+
"SIS II [#279515] # IFG-780&#x2F;005 II#1095\n Der Bundesbeauftragte für den Datenschutz\nund die <em>Informationsfreiheit</em>",
30+
"SIS II [#279515] # IFG-780&#x2F;005 II#1095\n Der Bundesbeauftragte für den Datenschutz\nund die <em>Informationsfreiheit</em>",
31+
"SIS II [#279515] # IFG-780&#x2F;005 II#1095\n Der Bundesbeauftragte für den Datenschutz und die <em>Informationsfreiheit</em>",
32+
"SIS II [#279515] # IFG-780&#x2F;005 II#1095\n Der Bundesbeauftragte für den Datenschutz und die <em>Informationsfreiheit</em>",
33+
"SIS II [#279515] # IFG-780&#x2F;005 II#1095\n Der Bundesbeauftragte für den Datenschutz und die <em>Informationsfreiheit</em>",
34+
"SIS II [#279515] # IFG-780&#x2F;005 II#1095\n Der Bundesbeauftragte für den Datenschutz und die <em>Informationsfreiheit</em>",
35+
"melek-bazgan-bfdi-12-12-2023.pdf\n \n \n\n\nDie Bundesbeauftragte für den Datenschutz und die <em>Informationsfreiheit</em>",
36+
"Beauftragte für Datenschutz und <em>Informationsfreiheit</em>\n\n\n Datenschutz\n\n <em>Informationsfreiheit</em>",
37+
],
38+
(
39+
"Der Bundesbeauftragte für den Datenschutz\nund die <em>Informationsfreiheit</em> [...] "
40+
# "Die Bundesbeauftragte für den Datenschutz und die <em>Informationsfreiheit</em> [...] "
41+
"Beauftragte für Datenschutz und <em>Informationsfreiheit</em>"
42+
# "<em>Informationsfreiheit</em>"
43+
),
44+
),
45+
(
46+
[
47+
":&#x2F;&#x2F;fragdenstaat.de&#x2F;hilfe&#x2F;fuer-behoerden&#x2F;\n\n \n \n\n \n Ihre Beschwerde im Bereich <em>Informationsfreiheit</em>",
48+
"Der Landesbeauftragte für den Datenschutz\nund die <em>Informationsfreiheit</em> Rheinland-Pfalz\n\nInternet",
49+
"Zeichen:\tfragdenstaat.de # 186145\n\n\n&amp;lt;&amp;lt;E-Mail-Adresse&amp;gt;&amp;gt;\n\n\nIhre Beschwerde im Bereich <em>Informationsfreiheit</em>",
50+
"Sie darauf hinweisen, dass die Anrufung des Landesbeauftragten für den Datenschutz und die <em>Informationsfreiheit</em>",
51+
"Slfdiprn0220071607220.pdf\n \n \n\n \n Ihre Beschwerde im Bereich <em>Informationsfreiheit</em>",
52+
"Der Landesbeauftragte für den Datenschutz\nund die <em>Informationsfreiheit</em> Rheinland-Pfalz\n\nInternet",
53+
"Zeichen:\tfragdenstaat.de # 186145\n\n\n&amp;lt;&amp;lt;E-Mail-Adresse&amp;gt;&amp;gt;\n\n\nIhre Beschwerde im Bereich <em>Informationsfreiheit</em>",
54+
"Mit freundlichen Grüßen\n \n \n\n \n AW: Ihre Beschwerde im Bereich <em>Informationsfreiheit</em> [#186145",
55+
"Ihr Antrag auf Informationszugang\n Der Landesbeauftragte für den Datenschutz\nund die <em>Informationsfreiheit</em>",
56+
"Der Widerspruch ist bei dem Landesbeauftragten für den Datenschutz und die <em>Informationsfreiheit</em> Rheinland-Pfalz",
57+
],
58+
(
59+
"Ihre Beschwerde im Bereich <em>Informationsfreiheit</em> [...] "
60+
"Der Landesbeauftragte für den Datenschutz\nund die <em>Informationsfreiheit</em> Rheinland-Pfalz [...] "
61+
"Sie darauf hinweisen, dass die Anrufung des Landesbeauftragten für den Datenschutz und die <em>Informationsfreiheit</em> [...] "
62+
"AW: Ihre Beschwerde im Bereich <em>Informationsfreiheit</em> [#186145 [...] "
63+
"Der Widerspruch ist bei dem Landesbeauftragten für den Datenschutz und die <em>Informationsfreiheit</em> Rheinland-Pfalz"
64+
),
65+
),
66+
(
67+
[
68+
"]\n HmbTG Antrag auf Übersendung der beim Hamburgischen Beauftragten für Datenschutz und <em>Informationsfreiheit</em>",
69+
"Die Prüfung auf Übersendung der beim Hamburgischen Beauftragten für Datenschutz und <em>Informationsfreiheit</em>",
70+
"Ihrer Mail vom 02.02.2017 auf Zugang zu der dem Hamburgischen Beauftragten für Datenschutz und <em>Informationsfreiheit</em>",
71+
"Hintergrund war Ihr Antrag auf Zugang zu der dem Harnburgischen Beauftragten für Datenschutz und <em>Informationsfreiheit</em>",
72+
"Monats nach Bekanntgabe Widerspruch bei dem Harnburgischen Beauftragten für Datenschutz und <em>Informationsfreiheit</em>",
73+
"Möglichkeit, Widerspruch zu erheben - den Harnburgischen Beauftragten für Datenschutz und <em>Informationsfreiheit</em>",
74+
"hmbfdi-eao.pdf\n \n \n\n\nDer Hamburgische Beauftragte für Datenschutz und <em>Informationsfreiheit</em>",
75+
"Landesbeauftragte für Datenschutz und <em>Informationsfreiheit</em>\n\n\n Inneres\n\n Datenschutz\n\n <em>Informationsfreiheit</em>",
76+
],
77+
(
78+
"HmbTG Antrag auf Übersendung der beim Hamburgischen Beauftragten für Datenschutz und <em>Informationsfreiheit</em> [...] "
79+
# "Die Prüfung auf Übersendung der beim Hamburgischen Beauftragten für Datenschutz und <em>Informationsfreiheit</em> [...] "
80+
"Ihrer Mail vom 02.02.2017 auf Zugang zu der dem Hamburgischen Beauftragten für Datenschutz und <em>Informationsfreiheit</em> [...] "
81+
"Hintergrund war Ihr Antrag auf Zugang zu der dem Harnburgischen Beauftragten für Datenschutz und <em>Informationsfreiheit</em> [...] "
82+
"Monats nach Bekanntgabe Widerspruch bei dem Harnburgischen Beauftragten für Datenschutz und <em>Informationsfreiheit</em> [...] "
83+
"Möglichkeit, Widerspruch zu erheben - den Harnburgischen Beauftragten für Datenschutz und <em>Informationsfreiheit</em>"
84+
# "Der Hamburgische Beauftragte für Datenschutz und <em>Informationsfreiheit</em> [...] "
85+
# "Landesbeauftragte für Datenschutz und <em>Informationsfreiheit</em>"
86+
),
87+
),
88+
(
89+
[
90+
"<em>Schriftverkehr</em> zwischen BMI und AA in Bezug auf Schreiben an Seenotrettungsorganisationen\n\nSämtlichen",
91+
"<em>Schriftverkehr</em> zwischen dem BMI und dem AA in Bezug auf das Schreiben des MinDir Weinbrenneran Seenotrettungsorganisationen",
92+
"Information nicht vorhanden \n\n\n\n\n \n <em>Schriftverkehr</em> zwischen BMI und AA in Bezug auf Schreiben an",
93+
"&#x2F;VIG\r\n\r\nSehr geehrte&amp;lt;&amp;lt; Anrede &amp;gt;&amp;gt;\n\r\nbitte senden Sie mir Folgendes zu:\n\nSämtlichen <em>Schriftverkehr</em>",
94+
"notwendig wäre, besuchen Sie:\nhttps:&#x2F;&#x2F;fragdenstaat.de&#x2F;hilfe&#x2F;fuer-behoerden&#x2F;\n\n \n \n\n \n <em>Schriftverkehr</em>",
95+
"geehrter Herr Semsrott,\n\n\xa0\n\nin Erledigung Ihres IFG- Antrages teile ich Ihnen mit, dass kein\n<em>Schriftverkehr</em>",
96+
],
97+
(
98+
"<em>Schriftverkehr</em> zwischen BMI und AA in Bezug auf Schreiben an Seenotrettungsorganisationen [...] "
99+
"<em>Schriftverkehr</em> zwischen dem BMI und dem AA in Bezug auf das Schreiben des MinDir Weinbrenneran Seenotrettungsorganisationen [...] "
100+
"<em>Schriftverkehr</em> zwischen BMI und AA in Bezug auf Schreiben an [...] "
101+
"Sämtlichen <em>Schriftverkehr</em> [...] "
102+
"in Erledigung Ihres IFG- Antrages teile ich Ihnen mit, dass kein\n<em>Schriftverkehr</em>"
103+
),
104+
),
105+
]

0 commit comments

Comments
 (0)