Skip to content

Commit 2a95384

Browse files
committed
Add FHNW Solr features: spellcheck, collate, highlighting, and frontend helpers
NOTE: This changeset extends the backend API to support advanced search features (spellcheck, collate, highlighting), and extends the frontend API to allow projects to implement custom UX on top of kitconcept.solr's capabilities. It also fixes a critical bug where filtering by multiple facet conditions failed to correctly disable voided conditions. When using the built-in default search page, both search and suggest work identically as before. Backend: - Add spellcheck with collate support for "did you mean" suggestions - Add highlighting with alternate field support - Add extra conditions filtering (content types, metadata fields) - Fix facet conditions, portal type, and content fields handling - Fix language handling and encoding issues - Fix local search path prefix matching - Update Solr configuration for spellcheck component Frontend: - Add getCollationMisspellings helper for "did you mean" feature - Add getSuggestions helper for extracting/sorting suggestions - Add navigation_with_excluded Redux action and reducer - Add SearchWidget customization to use SolrSearchWidget Tests: - Add comprehensive spellcheck/collate tests - Add highlighting endpoint tests - Add extra conditions tests - Add utility function tests
1 parent 24dede8 commit 2a95384

38 files changed

+2362
-66
lines changed

backend/news/61.bugfix

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Fix filtering by multiple facet conditions failing to correctly disable voided conditions.

backend/news/61.feature

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add spellcheck with collate support for "did you mean" suggestions, highlighting with alternate field support, and extra conditions filtering.

backend/src/kitconcept/solr/profiles/default/registry/kitconcept.solr.interfaces.IKitconceptSolrSettings.xml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,11 @@
2626
"image_scales",
2727
"image_field"
2828
],
29+
"highlightingFields": [
30+
{"field": "content", "prop": "highlighting"},
31+
{"field": "Title", "prop": "highlighting_title"},
32+
{"field": "Description", "prop": "highlighting_description"}
33+
],
2934
"searchTabs": [
3035
{
3136
"label": "All",

backend/src/kitconcept/solr/services/solr.py

Lines changed: 73 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
from AccessControl.SecurityManagement import getSecurityManager
22
from collective.solr.interfaces import ISolrConnectionManager
33
from itertools import zip_longest
4+
from kitconcept.solr.services.solr_highlighting_utils import SolrHighlightingUtils
45
from kitconcept.solr.services.solr_utils import escape
6+
from kitconcept.solr.services.solr_utils_extra import SolrExtraConditions
57
from kitconcept.solr.services.solr_utils import FacetConditions
68
from kitconcept.solr.services.solr_utils import get_facet_fields_result
79
from kitconcept.solr.services.solr_utils import replace_colon
@@ -51,6 +53,7 @@ class SolrSearch(Service):
5153
context: DexterityContent
5254
request: HTTPRequest
5355
_facet_conditions: FacetConditions | None = None
56+
_extra_conditions: SolrExtraConditions | None = None
5457

5558
def _language_settings(self) -> tuple[bool, str]:
5659
lang = self.request.form.get("lang")
@@ -99,6 +102,14 @@ def facet_conditions(self) -> FacetConditions:
99102
self._facet_conditions = FacetConditions.from_encoded(raw_value)
100103
return self._facet_conditions
101104

105+
@property
106+
def extra_conditions(self) -> SolrExtraConditions:
107+
"""Get the extra conditions from the request."""
108+
if self._extra_conditions is None:
109+
raw_value = self.request.form.get("extra_conditions")
110+
self._extra_conditions = SolrExtraConditions.from_encoded(raw_value)
111+
return self._extra_conditions
112+
102113
def _facet_fields(
103114
self, solr_config: SolrConfig, group_select: int | None
104115
) -> list[dict]:
@@ -119,6 +130,9 @@ def _base_query(
119130
start,
120131
rows,
121132
sort,
133+
highlighting_utils: SolrHighlightingUtils,
134+
spellcheck: bool,
135+
collate: bool,
122136
) -> dict:
123137
"""Create a base query for the Solr search."""
124138
# Note that both escaping and lowercasing reserved words is essential
@@ -164,28 +178,16 @@ def _base_query(
164178
f"OR default:{term} OR body_text:{term} OR SearchableText:{term} "
165179
f"OR Subject:{term} OR searchwords:({term})^1000) -showinsearch:False"
166180
),
167-
"wt": "json",
168-
"hl": "true",
169-
"hl.fl": "content", # only used for highlighting, field is not indexed
181+
"hl": "true" if highlighting_utils.enabled else "false",
182+
"hl.fl": highlighting_utils.fields,
170183
"fq": [security_filter()],
171184
"fl": solr_config.field_list,
172-
"facet": "true",
173-
"facet.contains.ignoreCase": "true",
174185
"facet.field": [
175-
f"{'{!ex=conditionfilter}' if self.facet_conditions.solr else ''}"
176-
f"{info['name']}"
186+
f"{self.facet_conditions.ex_field_facet(info['name'])}{info['name']}"
177187
for info in facet_fields
178188
],
179-
# XXX TBD: apply extra_fq from the solr_params.py from the development branch
180-
# XXX targeted towards v3.
181-
#
182-
# Without this the caveat is that the followings are not applied:
183-
#
184-
# - path_prefix handling
185-
# - portal_type handling
186-
# - language filtering and is_multilingual handling - handled, but actually
187-
# not passed in from the service caller
188-
#
189+
"spellcheck": "on" if spellcheck else "off",
190+
"spellcheck.collate": "true" if collate else "false",
189191
}
190192
if start is not None:
191193
d["start"] = start
@@ -196,24 +198,15 @@ def _base_query(
196198

197199
if group_select is not None:
198200
d["fq"] = d["fq"] + [solr_config.select_condition(group_select)]
199-
prefix = (
200-
"{!ex=typefilter,conditionfilter}"
201-
if self.facet_conditions.solr
202-
else "{!ex=typefilter}"
201+
ex_all_facets = self.facet_conditions.ex_all_facets(
202+
extending=["typefilter"]
203203
)
204-
d["facet.query"] = [f"{prefix}{query}" for query in solr_config.filters]
204+
d["facet.query"] = [
205+
f"{ex_all_facets}{filter_condition}"
206+
for filter_condition in solr_config.filters
207+
]
205208
return d
206209

207-
def _apply_facet_conditions(self, query: dict, facet_fields: list[dict]) -> dict:
208-
"""Apply the facet conditions to the query."""
209-
fc = self.facet_conditions
210-
# Handle facet conditions
211-
if fc.solr:
212-
query["fq"] = query["fq"] + ["{!tag=conditionfilter}" + fc.solr]
213-
query.update(fc.contains_query)
214-
query.update(fc.more_query(facet_fields, multiplier=2))
215-
return query
216-
217210
def search_solr(self, query: dict) -> dict:
218211
"""Search Solr for the given query."""
219212
# Get the solr connection
@@ -232,6 +225,7 @@ def enhance_result(
232225
facet_fields,
233226
keep_full_solr_response: bool,
234227
group_select,
228+
highlighting_utils: SolrHighlightingUtils,
235229
) -> dict:
236230
"""Enhance the Solr search result."""
237231
# Add portal path to the result. This can be used by
@@ -253,9 +247,15 @@ def enhance_result(
253247
facet_fields,
254248
self.facet_conditions.more_dict(facet_fields, multiplier=1),
255249
)
250+
# Add highlighting information to the result
251+
highlighting_utils.enhance_items(
252+
result.get("response", {}).get("docs", []),
253+
result.get("highlighting", {}),
254+
)
256255
# Solr response is pruned of the unnecessary parts, unless explicitly requested.
257256
if not keep_full_solr_response:
258-
del result["facet_counts"]
257+
result.pop("facet_counts", None)
258+
result.pop("highlighting", None)
259259
# Embellish result with supplemental information for the front-end
260260
if group_select is not None:
261261
layouts = solr_config.select_layouts(group_select)
@@ -277,6 +277,8 @@ def reply(self):
277277
keep_full_solr_response = (
278278
form.get("keep_full_solr_response", "").lower() == "true"
279279
)
280+
spellcheck = form.get("spellcheck", "").lower() == "true"
281+
collate = form.get("collate", "").lower() == "true"
280282

281283
is_multilingual, lang = self._language_settings()
282284
portal = api.portal.get()
@@ -286,9 +288,19 @@ def reply(self):
286288

287289
group_select = self._group_select(solr_config)
288290
facet_fields = self._facet_fields(solr_config, group_select)
291+
highlighting_utils = SolrHighlightingUtils(solr_config)
289292

290293
d = self._base_query(
291-
solr_config, query, facet_fields, group_select, start, rows, sort
294+
solr_config,
295+
query,
296+
facet_fields,
297+
group_select,
298+
start,
299+
rows,
300+
sort,
301+
highlighting_utils,
302+
spellcheck,
303+
collate,
292304
)
293305

294306
if path_prefix:
@@ -336,15 +348,39 @@ def reply(self):
336348
if lang:
337349
d["fq"] = d["fq"] + ["Language:(" + escape(lang) + ")"]
338350

339-
d = self._apply_facet_conditions(d, facet_fields)
351+
d["fq"] = d["fq"] + self.facet_conditions.field_conditions_solr
352+
353+
# Apply extra conditions (date-range, string filters)
354+
d["fq"] = d["fq"] + self.extra_conditions.query_list()
355+
356+
d.update(self.facet_conditions.contains_query)
357+
d.update(self.facet_conditions.more_query(facet_fields, multiplier=2))
358+
359+
# Run search
360+
result = self.search_solr(query=d)
361+
362+
# Replace result with "Did you mean" response, if there are no results
363+
# and it's requested by the collate parameter
364+
if (
365+
result["response"]["numFound"] == 0
366+
and "spellcheck" in result
367+
and "collations" in result["spellcheck"]
368+
and len(result["spellcheck"]["collations"]) > 1
369+
and collate
370+
):
371+
collation = result["spellcheck"]["collations"][1]
372+
d["q"] = collation["collationQuery"]
373+
result = self.search_solr(query=d)
374+
result["collation_misspellings"] = collation["misspellingsAndCorrections"]
340375

341-
# Run search and enhance the result
376+
# Enhance the result
342377
result = self.enhance_result(
343-
self.search_solr(query=d),
378+
result,
344379
solr_config,
345380
portal_path,
346381
facet_fields,
347382
keep_full_solr_response,
348383
group_select,
384+
highlighting_utils,
349385
)
350386
return result
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
class SolrHighlightingUtils:
2+
fields: list
3+
enabled: bool
4+
propByField: dict
5+
6+
def __init__(self, solr_config):
7+
highlightingFields = solr_config.config.get("highlightingFields", [])
8+
self.fields = list(map(lambda field: field["field"], highlightingFields))
9+
self.enabled = len(self.fields) > 0
10+
self.propByField = dict(
11+
map(
12+
lambda field: (field["field"], field["prop"]),
13+
highlightingFields,
14+
)
15+
)
16+
17+
def enhance_items(self, items: list, highlighting: dict):
18+
if self.enabled:
19+
for item in items:
20+
for field, value in highlighting.get(item["UID"], {}).items():
21+
item[self.propByField[field]] = value

backend/src/kitconcept/solr/services/solr_utils.py

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from plone import api
22

33
import base64
4+
import binascii
45
import json
56
import logging
67
import re
@@ -120,7 +121,11 @@ def from_encoded(cls, raw: str | None):
120121
if raw is not None:
121122
try:
122123
config = json.loads(base64.b64decode(raw))
123-
except (UnicodeDecodeError, json.decoder.JSONDecodeError):
124+
except (
125+
UnicodeDecodeError,
126+
json.decoder.JSONDecodeError,
127+
binascii.Error,
128+
):
124129
logger.warning("Ignoring invalid base64 encoded string", exc_info=True)
125130
config = {}
126131
else:
@@ -155,6 +160,35 @@ def field_conditions(self):
155160
if self.field_condition(name, value)
156161
)
157162

163+
def field_conditions_with_name(self):
164+
return (
165+
(name, self.field_condition(name, value))
166+
for name, value in self.config.items()
167+
if self.field_condition(name, value)
168+
)
169+
170+
@property
171+
def field_conditions_solr(self):
172+
return [
173+
f"{{!tag=cf_{name}}}{condition}"
174+
for name, condition in self.field_conditions_with_name()
175+
]
176+
177+
def ex_all_facets(self, extending=None):
178+
if extending is None:
179+
extending = []
180+
tag_names = extending + [
181+
f"cf_{name}"
182+
for name, condition in self.field_conditions_with_name()
183+
if condition
184+
]
185+
joined_tag_names = ",".join(tag_names)
186+
return f"{{!ex={joined_tag_names}}}"
187+
188+
def ex_field_facet(self, field_name):
189+
active_fields = [name for name, _ in self.field_conditions_with_name()]
190+
return f"{{!ex=cf_{field_name}}}" if field_name in active_fields else ""
191+
158192
@property
159193
def solr(self):
160194
conditions = list(self.field_conditions())

0 commit comments

Comments
 (0)