start project
This commit is contained in:
@@ -80,6 +80,18 @@ FILTER_FIELDS = {
|
||||
"companies": "company.keyword",
|
||||
}
|
||||
|
||||
#: Maps a facet (as returned to the frontend) to the request attribute that
|
||||
#: selects values *within that same facet*. Used to exclude a facet's own
|
||||
#: filter from its own aggregation - see ``_build_aggregations``.
|
||||
FACET_TO_FILTER_ATTR = {
|
||||
"skills": "skills",
|
||||
"job_title_role": "roles",
|
||||
"seniority": "seniority",
|
||||
"country": "countries",
|
||||
"industry": "industries",
|
||||
"company": "companies",
|
||||
}
|
||||
|
||||
|
||||
def build_query(request: SearchRequest) -> dict[str, Any]:
|
||||
"""Translate a search request into an Elasticsearch request body."""
|
||||
@@ -118,20 +130,26 @@ def build_query(request: SearchRequest) -> dict[str, Any]:
|
||||
else:
|
||||
must.append({"match_all": {}})
|
||||
|
||||
active_filters = _active_filters(request)
|
||||
range_filter = _range_filter(request)
|
||||
|
||||
body: dict[str, Any] = {
|
||||
"query": {
|
||||
"bool": {
|
||||
"must": must,
|
||||
"should": should,
|
||||
"filter": _build_filters(request),
|
||||
}
|
||||
},
|
||||
"query": {"bool": {"must": must, "should": should}},
|
||||
"from": request.offset,
|
||||
"size": request.page_size,
|
||||
"track_total_hits": True,
|
||||
"sort": _build_sort(request),
|
||||
}
|
||||
|
||||
# Filters live in ``post_filter``, not ``query.bool.filter``: post_filter
|
||||
# narrows the hits and the total count *after* aggregations are computed,
|
||||
# which is what lets a multi-select facet stay populated with its own
|
||||
# sibling values - see ``_build_aggregations`` for the other half of the
|
||||
# trick (each facet additionally excludes its own filter).
|
||||
all_filters = [*active_filters.values(), *([range_filter] if range_filter else [])]
|
||||
if all_filters:
|
||||
body["post_filter"] = {"bool": {"filter": all_filters}}
|
||||
|
||||
if request.q:
|
||||
body["highlight"] = {
|
||||
"pre_tags": ["<mark>"],
|
||||
@@ -140,33 +158,35 @@ def build_query(request: SearchRequest) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
if request.include_facets:
|
||||
body["aggs"] = _build_aggregations()
|
||||
body["aggs"] = _build_aggregations(active_filters, range_filter)
|
||||
|
||||
return body
|
||||
|
||||
|
||||
def _build_filters(request: SearchRequest) -> list[dict[str, Any]]:
|
||||
filters: list[dict[str, Any]] = []
|
||||
|
||||
def _active_filters(request: SearchRequest) -> dict[str, dict[str, Any]]:
|
||||
"""One ``terms`` clause per attribute that actually has a value selected."""
|
||||
filters: dict[str, dict[str, Any]] = {}
|
||||
for attribute, field_name in FILTER_FIELDS.items():
|
||||
values = getattr(request, attribute)
|
||||
if values:
|
||||
# ``terms`` is OR within one filter; separate filters are ANDed,
|
||||
# which matches how users read the UI: "python OR django" skills,
|
||||
# AND located in Germany.
|
||||
filters.append({"terms": {field_name: list(values)}})
|
||||
|
||||
if request.min_experience is not None or request.max_experience is not None:
|
||||
bounds: dict[str, int] = {}
|
||||
if request.min_experience is not None:
|
||||
bounds["gte"] = request.min_experience
|
||||
if request.max_experience is not None:
|
||||
bounds["lte"] = request.max_experience
|
||||
filters.append({"range": {"years_experience": bounds}})
|
||||
|
||||
filters[attribute] = {"terms": {field_name: list(values)}}
|
||||
return filters
|
||||
|
||||
|
||||
def _range_filter(request: SearchRequest) -> dict[str, Any] | None:
|
||||
if request.min_experience is None and request.max_experience is None:
|
||||
return None
|
||||
bounds: dict[str, int] = {}
|
||||
if request.min_experience is not None:
|
||||
bounds["gte"] = request.min_experience
|
||||
if request.max_experience is not None:
|
||||
bounds["lte"] = request.max_experience
|
||||
return {"range": {"years_experience": bounds}}
|
||||
|
||||
|
||||
def _build_sort(request: SearchRequest) -> list[Any]:
|
||||
if request.sort == SORT_NAME:
|
||||
return [{"full_name.keyword": "asc"}, {"id": "asc"}]
|
||||
@@ -177,12 +197,35 @@ def _build_sort(request: SearchRequest) -> list[Any]:
|
||||
return ["_score", {"id": "asc"}] # deterministic tie-break
|
||||
|
||||
|
||||
def _build_aggregations() -> dict[str, Any]:
|
||||
def _build_aggregations(
|
||||
active_filters: dict[str, dict[str, Any]], range_filter: dict[str, Any] | None
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
One ``terms`` aggregation per facet, each scoped by every *other* active
|
||||
filter - the standard multi-select faceting pattern. Without this, a
|
||||
facet's own filter (applied globally through ``post_filter``) would
|
||||
already have narrowed the document set before the aggregation runs, so
|
||||
ticking "Engineering" under Job family would make every other job family
|
||||
option vanish from that same list instead of just narrowing the results.
|
||||
The experience range applies to every facet unconditionally: it is not a
|
||||
facet with its own checkboxes, so it is never excluded.
|
||||
"""
|
||||
size = settings.SEARCH["FACET_SIZE"]
|
||||
return {
|
||||
name: {"terms": {"field": field_name, "size": size, "order": {"_count": "desc"}}}
|
||||
for name, field_name in FACET_FIELDS.items()
|
||||
}
|
||||
aggs: dict[str, Any] = {}
|
||||
for name, field_name in FACET_FIELDS.items():
|
||||
own_attribute = FACET_TO_FILTER_ATTR[name]
|
||||
other_filters = [
|
||||
clause for attribute, clause in active_filters.items() if attribute != own_attribute
|
||||
]
|
||||
if range_filter is not None:
|
||||
other_filters.append(range_filter)
|
||||
|
||||
terms_agg = {"terms": {"field": field_name, "size": size, "order": {"_count": "desc"}}}
|
||||
if other_filters:
|
||||
aggs[name] = {"filter": {"bool": {"filter": other_filters}}, "aggs": {name: terms_agg}}
|
||||
else:
|
||||
aggs[name] = terms_agg
|
||||
return aggs
|
||||
|
||||
|
||||
class ElasticsearchBackend(SearchBackend):
|
||||
@@ -269,11 +312,16 @@ class ElasticsearchBackend(SearchBackend):
|
||||
|
||||
@staticmethod
|
||||
def _to_facets(aggregations: dict[str, Any]) -> dict[str, list[FacetBucket]]:
|
||||
return {
|
||||
name: [
|
||||
facets: dict[str, list[FacetBucket]] = {}
|
||||
for name, payload in aggregations.items():
|
||||
# A facet scoped by other filters is wrapped in an outer `filter`
|
||||
# aggregation (see ``_build_aggregations``); unwrap it to reach the
|
||||
# `terms` buckets. When no other filter was active it is the bare
|
||||
# `terms` aggregation already.
|
||||
terms_payload = payload.get(name, payload)
|
||||
facets[name] = [
|
||||
FacetBucket(key=str(bucket["key"]), count=bucket["doc_count"])
|
||||
for bucket in payload.get("buckets", [])
|
||||
for bucket in terms_payload.get("buckets", [])
|
||||
if bucket["key"] != ""
|
||||
]
|
||||
for name, payload in aggregations.items()
|
||||
}
|
||||
return facets
|
||||
|
||||
Reference in New Issue
Block a user