Skip to content

Commit c31d30f

Browse files
committed
🔧 Clean up display of highlighted search results
1 parent 3a1ca74 commit c31d30f

3 files changed

Lines changed: 226 additions & 3 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 yields 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: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,10 @@ 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+
s = s.highlight_options(encoder="html", number_of_fragments=10).highlight(
68+
"content"
69+
)
6770
s = s.sort("_score")
6871
return s
6972

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
from django.utils.safestring import SafeString
2+
3+
import pytest
4+
5+
from froide.helper.search.queryset import ESQuerySetWrapper
6+
7+
8+
class DummyHitMeta:
9+
def __init__(self, id, highlight=None):
10+
self.id = id
11+
self.highlight = highlight or {}
12+
13+
14+
class DummyHit:
15+
def __init__(self, id, highlight=None):
16+
self.meta = DummyHitMeta(id, highlight)
17+
18+
19+
class DummyObj:
20+
def __init__(self, pk):
21+
self.pk = pk
22+
self.query_highlight = None
23+
24+
25+
class DummyQS:
26+
def __init__(self, objs):
27+
self._objs = objs
28+
29+
def __iter__(self):
30+
return iter(self._objs)
31+
32+
33+
# List of test cases to be used in the parameterized test below.
34+
# Each test case is a tuple of (highlight_list, query_highlight) where highlight_list is the list
35+
# of highlighted strings from Elasticsearch and expected_query_highlight is the expected post-processed
36+
# string that will be shown to the user.
37+
test_cases = [
38+
(
39+
[
40+
"Unterlagen zum &amp;quot;<em>Gender</em>-Verbot&amp;quot;\n\nAlle Unterlagen (interne und externe Korrespondenz, Vermerke",
41+
", Dienstanweisungen etc.) im Zusammenhang mit dem sogenannten &amp;quot;<em>Gender</em>-Verbot&amp;quot; an sächsischen",
42+
"Schulen\n\nAnfrage erfolgreich \n\n\n\n\n \n Unterlagen zum &amp;quot;<em>Gender</em>-Verbot&amp;quot; [#284078]\n Antrag",
43+
"externe Korrespondenz, Vermerke, Dienstanweisungen etc.) im Zusammenhang mit dem sogenannten &amp;quot;<em>Gender</em>-Verbot",
44+
],
45+
(
46+
"Unterlagen zum &quot;<em>Gender</em>-Verbot&quot; [...] "
47+
", Dienstanweisungen etc.) im Zusammenhang mit dem sogenannten &quot;<em>Gender</em>-Verbot&quot; an sächsischen [...] "
48+
# "Unterlagen zum &quot;<em>Gender</em>-Verbot&quot; [#284078] [...] "
49+
"externe Korrespondenz, Vermerke, Dienstanweisungen etc.) im Zusammenhang mit dem sogenannten &quot;<em>Gender</em>-Verbot"
50+
),
51+
),
52+
(
53+
[
54+
"Genderverbot\n\nDie Regelung (Schreiben, Erlass, Weisung) des BMF zur internen Sprachregelung in Bezug aufs <em>Gendern</em>",
55+
"zu:\n\nDie Regelung (Schreiben, Erlass, Weisung) des BMF zur internen Sprachregelung in Bezug aufs <em>Gendern</em>",
56+
],
57+
"Die Regelung (Schreiben, Erlass, Weisung) des BMF zur internen Sprachregelung in Bezug aufs <em>Gendern</em>",
58+
),
59+
(
60+
[
61+
"SIS II [#279515] # IFG-780&#x2F;005 II#1095\n Der Bundesbeauftragte für den Datenschutz\nund die <em>Informationsfreiheit</em>",
62+
"SIS II [#279515] # IFG-780&#x2F;005 II#1095\n Der Bundesbeauftragte für den Datenschutz\nund die <em>Informationsfreiheit</em>",
63+
"SIS II [#279515] # IFG-780&#x2F;005 II#1095\n Der Bundesbeauftragte für den Datenschutz und die <em>Informationsfreiheit</em>",
64+
"SIS II [#279515] # IFG-780&#x2F;005 II#1095\n Der Bundesbeauftragte für den Datenschutz und die <em>Informationsfreiheit</em>",
65+
"SIS II [#279515] # IFG-780&#x2F;005 II#1095\n Der Bundesbeauftragte für den Datenschutz und die <em>Informationsfreiheit</em>",
66+
"SIS II [#279515] # IFG-780&#x2F;005 II#1095\n Der Bundesbeauftragte für den Datenschutz und die <em>Informationsfreiheit</em>",
67+
"melek-bazgan-bfdi-12-12-2023.pdf\n \n \n\n\nDie Bundesbeauftragte für den Datenschutz und die <em>Informationsfreiheit</em>",
68+
"Beauftragte für Datenschutz und <em>Informationsfreiheit</em>\n\n\n Datenschutz\n\n <em>Informationsfreiheit</em>",
69+
],
70+
(
71+
"Der Bundesbeauftragte für den Datenschutz\nund die <em>Informationsfreiheit</em> [...] "
72+
# "Die Bundesbeauftragte für den Datenschutz und die <em>Informationsfreiheit</em> [...] "
73+
"Beauftragte für Datenschutz und <em>Informationsfreiheit</em>"
74+
# "<em>Informationsfreiheit</em>"
75+
),
76+
),
77+
(
78+
[
79+
":&#x2F;&#x2F;fragdenstaat.de&#x2F;hilfe&#x2F;fuer-behoerden&#x2F;\n\n \n \n\n \n Ihre Beschwerde im Bereich <em>Informationsfreiheit</em>",
80+
"Der Landesbeauftragte für den Datenschutz\nund die <em>Informationsfreiheit</em> Rheinland-Pfalz\n\nInternet",
81+
"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>",
82+
"Sie darauf hinweisen, dass die Anrufung des Landesbeauftragten für den Datenschutz und die <em>Informationsfreiheit</em>",
83+
"Slfdiprn0220071607220.pdf\n \n \n\n \n Ihre Beschwerde im Bereich <em>Informationsfreiheit</em>",
84+
"Der Landesbeauftragte für den Datenschutz\nund die <em>Informationsfreiheit</em> Rheinland-Pfalz\n\nInternet",
85+
"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>",
86+
"Mit freundlichen Grüßen\n \n \n\n \n AW: Ihre Beschwerde im Bereich <em>Informationsfreiheit</em> [#186145",
87+
"Ihr Antrag auf Informationszugang\n Der Landesbeauftragte für den Datenschutz\nund die <em>Informationsfreiheit</em>",
88+
"Der Widerspruch ist bei dem Landesbeauftragten für den Datenschutz und die <em>Informationsfreiheit</em> Rheinland-Pfalz",
89+
],
90+
(
91+
"Ihre Beschwerde im Bereich <em>Informationsfreiheit</em> [...] "
92+
"Der Landesbeauftragte für den Datenschutz\nund die <em>Informationsfreiheit</em> Rheinland-Pfalz [...] "
93+
"Sie darauf hinweisen, dass die Anrufung des Landesbeauftragten für den Datenschutz und die <em>Informationsfreiheit</em> [...] "
94+
"AW: Ihre Beschwerde im Bereich <em>Informationsfreiheit</em> [#186145 [...] "
95+
"Der Widerspruch ist bei dem Landesbeauftragten für den Datenschutz und die <em>Informationsfreiheit</em> Rheinland-Pfalz"
96+
),
97+
),
98+
(
99+
[
100+
"]\n HmbTG Antrag auf Übersendung der beim Hamburgischen Beauftragten für Datenschutz und <em>Informationsfreiheit</em>",
101+
"Die Prüfung auf Übersendung der beim Hamburgischen Beauftragten für Datenschutz und <em>Informationsfreiheit</em>",
102+
"Ihrer Mail vom 02.02.2017 auf Zugang zu der dem Hamburgischen Beauftragten für Datenschutz und <em>Informationsfreiheit</em>",
103+
"Hintergrund war Ihr Antrag auf Zugang zu der dem Harnburgischen Beauftragten für Datenschutz und <em>Informationsfreiheit</em>",
104+
"Monats nach Bekanntgabe Widerspruch bei dem Harnburgischen Beauftragten für Datenschutz und <em>Informationsfreiheit</em>",
105+
"Möglichkeit, Widerspruch zu erheben - den Harnburgischen Beauftragten für Datenschutz und <em>Informationsfreiheit</em>",
106+
"hmbfdi-eao.pdf\n \n \n\n\nDer Hamburgische Beauftragte für Datenschutz und <em>Informationsfreiheit</em>",
107+
"Landesbeauftragte für Datenschutz und <em>Informationsfreiheit</em>\n\n\n Inneres\n\n Datenschutz\n\n <em>Informationsfreiheit</em>",
108+
],
109+
(
110+
"HmbTG Antrag auf Übersendung der beim Hamburgischen Beauftragten für Datenschutz und <em>Informationsfreiheit</em> [...] "
111+
# "Die Prüfung auf Übersendung der beim Hamburgischen Beauftragten für Datenschutz und <em>Informationsfreiheit</em> [...] "
112+
"Ihrer Mail vom 02.02.2017 auf Zugang zu der dem Hamburgischen Beauftragten für Datenschutz und <em>Informationsfreiheit</em> [...] "
113+
"Hintergrund war Ihr Antrag auf Zugang zu der dem Harnburgischen Beauftragten für Datenschutz und <em>Informationsfreiheit</em> [...] "
114+
"Monats nach Bekanntgabe Widerspruch bei dem Harnburgischen Beauftragten für Datenschutz und <em>Informationsfreiheit</em> [...] "
115+
"Möglichkeit, Widerspruch zu erheben - den Harnburgischen Beauftragten für Datenschutz und <em>Informationsfreiheit</em>"
116+
# "Der Hamburgische Beauftragte für Datenschutz und <em>Informationsfreiheit</em> [...] "
117+
# "Landesbeauftragte für Datenschutz und <em>Informationsfreiheit</em>"
118+
),
119+
),
120+
(
121+
[
122+
"<em>Schriftverkehr</em> zwischen BMI und AA in Bezug auf Schreiben an Seenotrettungsorganisationen\n\nSämtlichen",
123+
"<em>Schriftverkehr</em> zwischen dem BMI und dem AA in Bezug auf das Schreiben des MinDir Weinbrenneran Seenotrettungsorganisationen",
124+
"Information nicht vorhanden \n\n\n\n\n \n <em>Schriftverkehr</em> zwischen BMI und AA in Bezug auf Schreiben an",
125+
"&#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>",
126+
"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>",
127+
"geehrter Herr Semsrott,\n\n\xa0\n\nin Erledigung Ihres IFG- Antrages teile ich Ihnen mit, dass kein\n<em>Schriftverkehr</em>",
128+
],
129+
(
130+
"<em>Schriftverkehr</em> zwischen BMI und AA in Bezug auf Schreiben an Seenotrettungsorganisationen [...] "
131+
"<em>Schriftverkehr</em> zwischen dem BMI und dem AA in Bezug auf das Schreiben des MinDir Weinbrenneran Seenotrettungsorganisationen [...] "
132+
"<em>Schriftverkehr</em> zwischen BMI und AA in Bezug auf Schreiben an [...] "
133+
"Sämtlichen <em>Schriftverkehr</em> [...] "
134+
"in Erledigung Ihres IFG- Antrages teile ich Ihnen mit, dass kein\n<em>Schriftverkehr</em>"
135+
),
136+
),
137+
]
138+
139+
140+
@pytest.mark.parametrize(
141+
"highlight_list, query_highlight", test_cases, ids=[x[1][:20] for x in test_cases]
142+
)
143+
def test_es_queryset_wrapper_iter_highlight(highlight_list, query_highlight):
144+
obj = DummyObj(1)
145+
hit = DummyHit(1, {"field": highlight_list})
146+
qs = DummyQS([obj])
147+
es_response = [hit]
148+
149+
wrapper = ESQuerySetWrapper(qs, es_response)
150+
result = list(wrapper)
151+
152+
assert isinstance(result[0].query_highlight, SafeString)
153+
assert result[0].query_highlight == query_highlight
154+
155+
156+
def test_es_queryset_wrapper_iter_no_highlight():
157+
obj = DummyObj(2)
158+
hit = DummyHit(2)
159+
qs = DummyQS([obj])
160+
es_response = [hit]
161+
162+
wrapper = ESQuerySetWrapper(qs, es_response)
163+
result = list(wrapper)
164+
165+
assert result[0].query_highlight == ""

0 commit comments

Comments
 (0)