feat: LinkedIn profile search engine (Django + React + Elasticsearch + ELK)
Full-stack implementation of the Cyberyan full-stack trial task. Backend (Django 5 / DRF) - Normalised PostgreSQL model (Profile, Company, School, Skill, Experience, Education) as the source of truth, with GIN + trigram indexes. - Self-healing CSV reader: the supplied export carries an injected watermark column, missing columns, rows split by broken quoting and a duplicated header. A signature-scored Needleman-Wunsch alignment reconstructs 284 clean records out of 336 physical rows; only 10 rows parse correctly without it. - Pluggable search layer (Strategy): Elasticsearch by default, PostgreSQL full-text search as a fallback selected by a single environment variable. - Keyword search with per-field boosts, fuzziness and phrase boosting, six faceted filters (OR within a filter, AND across filters), an experience range, four sort orders, highlighting and facet aggregations. - JWT auth with rotation + blacklist, three roles, PII masking, scoped rate limits, a single error envelope, OpenAPI schema and health probes. Frontend (React 18 / TypeScript / Vite) - Debounced search with type-ahead, faceted filter panel, result cards, profile drawer, URL-synced search state, automatic token refresh. - Highlight snippets rendered without dangerouslySetInnerHTML. Infrastructure - Hardened multi-stage images (non-root, read-only rootfs, healthchecks). - docker-compose for the full stack plus dev and production overlays. - ELK: structured JSON logs shipped asynchronously to Logstash, routed per event type, with a composable index template and a Kibana setup script. Quality - 61 backend tests (85% coverage), ruff/black/bandit/mypy, ESLint + tsc, GitHub Actions running lint, tests and both image builds. - Persian documentation: README plus architecture, search, API, data, security and operations guides. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GnFo6fNaHCTAkJbfcdkqtG
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
"""Backend factory - resolves ``SEARCH["BACKEND"]`` to a concrete strategy."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import cache
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
|
||||
from apps.search.backends.base import SearchBackend
|
||||
|
||||
__all__ = ["SearchBackend", "get_search_backend"]
|
||||
|
||||
|
||||
@cache
|
||||
def get_search_backend(name: str | None = None) -> SearchBackend:
|
||||
"""
|
||||
Return the configured backend.
|
||||
|
||||
Cached because both implementations hold a connection pool that should be
|
||||
created once per process, not once per request.
|
||||
"""
|
||||
backend = (name or settings.SEARCH["BACKEND"]).lower()
|
||||
|
||||
if backend == "elasticsearch":
|
||||
from apps.search.backends.elastic import ElasticsearchBackend
|
||||
|
||||
return ElasticsearchBackend()
|
||||
if backend == "postgres":
|
||||
from apps.search.backends.postgres import PostgresBackend
|
||||
|
||||
return PostgresBackend()
|
||||
|
||||
raise ImproperlyConfigured(
|
||||
f"Unknown SEARCH['BACKEND'] {backend!r}; expected 'elasticsearch' or 'postgres'."
|
||||
)
|
||||
@@ -0,0 +1,39 @@
|
||||
"""The contract every search backend implements."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
from apps.search.types import SearchRequest, SearchResult
|
||||
|
||||
|
||||
class SearchBackend(ABC):
|
||||
"""
|
||||
Strategy interface for keyword search.
|
||||
|
||||
Two implementations ship with the project:
|
||||
|
||||
* :class:`apps.search.backends.elastic.ElasticsearchBackend` - the default,
|
||||
used in every environment that runs the ELK stack.
|
||||
* :class:`apps.search.backends.postgres.PostgresBackend` - a full-text
|
||||
fallback that keeps the API (and the test suite) working without any
|
||||
external search service.
|
||||
|
||||
Keeping the surface this small is deliberate: the views, the serializers and
|
||||
the frontend are completely unaware of which engine answered the query.
|
||||
"""
|
||||
|
||||
#: Human readable identifier, surfaced in the API response and health check.
|
||||
name: str = "base"
|
||||
|
||||
@abstractmethod
|
||||
def search(self, request: SearchRequest) -> SearchResult:
|
||||
"""Execute a search and return ranked hits plus facet counts."""
|
||||
|
||||
@abstractmethod
|
||||
def suggest(self, prefix: str, limit: int = 8) -> list[str]:
|
||||
"""Return type-ahead completions for a partial name or skill."""
|
||||
|
||||
@abstractmethod
|
||||
def ping(self) -> bool:
|
||||
"""Raise or return ``False`` when the backend is not usable."""
|
||||
@@ -0,0 +1,279 @@
|
||||
"""
|
||||
Elasticsearch search backend.
|
||||
|
||||
The query builder is a pure function (:func:`build_query`) that turns a
|
||||
:class:`~apps.search.types.SearchRequest` into an Elasticsearch request body.
|
||||
Keeping it free of I/O means the ranking logic is unit-testable without a
|
||||
running cluster - see ``tests/test_query_builder.py``.
|
||||
|
||||
Ranking model
|
||||
-------------
|
||||
``must`` the keyword must match somewhere, with per-field boosts that encode
|
||||
"a name hit is worth more than a summary hit", plus ``fuzziness:
|
||||
AUTO`` so ``pyhton`` still finds ``python``.
|
||||
``should`` an exact phrase match on the strong fields adds a large bonus, which
|
||||
pushes "product manager" above documents that merely contain both
|
||||
words far apart.
|
||||
``filter`` structured constraints (skills, role, seniority, country, industry,
|
||||
employer, years of experience). They live in the filter context, so
|
||||
they are cached by Elasticsearch and contribute nothing to the score.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from django.conf import settings
|
||||
from elasticsearch import ApiError, Elasticsearch, TransportError
|
||||
|
||||
from apps.core.exceptions import SearchBackendError
|
||||
from apps.search.backends.base import SearchBackend
|
||||
from apps.search.indexing import alias_name, get_client
|
||||
from apps.search.types import (
|
||||
FACET_FIELDS,
|
||||
SORT_EXPERIENCE_ASC,
|
||||
SORT_EXPERIENCE_DESC,
|
||||
SORT_NAME,
|
||||
FacetBucket,
|
||||
SearchHit,
|
||||
SearchRequest,
|
||||
SearchResult,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
#: Field boosts for the free-text part of the query.
|
||||
SEARCH_FIELDS = [
|
||||
"full_name^6",
|
||||
"job_title^4",
|
||||
"skills^3.5",
|
||||
"company^2.5",
|
||||
"past_companies^1.5",
|
||||
"industry^1.5",
|
||||
"majors^1.5",
|
||||
"education",
|
||||
"location_name",
|
||||
"headline^2",
|
||||
"summary",
|
||||
"interests",
|
||||
]
|
||||
|
||||
#: Narrower field set for the phrase bonus - phrases in a summary are noise.
|
||||
PHRASE_FIELDS = ["full_name^8", "job_title^6", "skills^5", "company^4", "headline^3"]
|
||||
|
||||
HIGHLIGHT_FIELDS = {
|
||||
"full_name": {"number_of_fragments": 0},
|
||||
"job_title": {"number_of_fragments": 0},
|
||||
"skills": {"number_of_fragments": 3},
|
||||
"summary": {"fragment_size": 160, "number_of_fragments": 1},
|
||||
}
|
||||
|
||||
#: Maps an API filter to the indexed keyword field it constrains.
|
||||
FILTER_FIELDS = {
|
||||
"skills": "skills.keyword",
|
||||
"roles": "job_title_role",
|
||||
"seniority": "seniority_levels",
|
||||
"countries": "location_country",
|
||||
"industries": "industry.keyword",
|
||||
"companies": "company.keyword",
|
||||
}
|
||||
|
||||
|
||||
def build_query(request: SearchRequest) -> dict[str, Any]:
|
||||
"""Translate a search request into an Elasticsearch request body."""
|
||||
must: list[dict[str, Any]] = []
|
||||
should: list[dict[str, Any]] = []
|
||||
|
||||
if request.q:
|
||||
must.append(
|
||||
{
|
||||
"multi_match": {
|
||||
"query": request.q,
|
||||
"type": "best_fields",
|
||||
"fields": SEARCH_FIELDS,
|
||||
"operator": "or",
|
||||
# One-word queries must match; longer queries tolerate a
|
||||
# missing term so "senior python django" still returns
|
||||
# people who only list two of the three.
|
||||
"minimum_should_match": "2<70%",
|
||||
"fuzziness": "AUTO",
|
||||
"prefix_length": 1,
|
||||
"max_expansions": 30,
|
||||
}
|
||||
}
|
||||
)
|
||||
should.append(
|
||||
{
|
||||
"multi_match": {
|
||||
"query": request.q,
|
||||
"type": "phrase",
|
||||
"fields": PHRASE_FIELDS,
|
||||
"slop": 1,
|
||||
"boost": 4,
|
||||
}
|
||||
}
|
||||
)
|
||||
else:
|
||||
must.append({"match_all": {}})
|
||||
|
||||
body: dict[str, Any] = {
|
||||
"query": {
|
||||
"bool": {
|
||||
"must": must,
|
||||
"should": should,
|
||||
"filter": _build_filters(request),
|
||||
}
|
||||
},
|
||||
"from": request.offset,
|
||||
"size": request.page_size,
|
||||
"track_total_hits": True,
|
||||
"sort": _build_sort(request),
|
||||
}
|
||||
|
||||
if request.q:
|
||||
body["highlight"] = {
|
||||
"pre_tags": ["<mark>"],
|
||||
"post_tags": ["</mark>"],
|
||||
"fields": HIGHLIGHT_FIELDS,
|
||||
}
|
||||
|
||||
if request.include_facets:
|
||||
body["aggs"] = _build_aggregations()
|
||||
|
||||
return body
|
||||
|
||||
|
||||
def _build_filters(request: SearchRequest) -> list[dict[str, Any]]:
|
||||
filters: list[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}})
|
||||
|
||||
return filters
|
||||
|
||||
|
||||
def _build_sort(request: SearchRequest) -> list[Any]:
|
||||
if request.sort == SORT_NAME:
|
||||
return [{"full_name.keyword": "asc"}, {"id": "asc"}]
|
||||
if request.sort == SORT_EXPERIENCE_DESC:
|
||||
return [{"years_experience": {"order": "desc", "missing": "_last"}}, "_score"]
|
||||
if request.sort == SORT_EXPERIENCE_ASC:
|
||||
return [{"years_experience": {"order": "asc", "missing": "_last"}}, "_score"]
|
||||
return ["_score", {"id": "asc"}] # deterministic tie-break
|
||||
|
||||
|
||||
def _build_aggregations() -> dict[str, Any]:
|
||||
size = settings.SEARCH["FACET_SIZE"]
|
||||
return {
|
||||
name: {"terms": {"field": field_name, "size": size, "order": {"_count": "desc"}}}
|
||||
for name, field_name in FACET_FIELDS.items()
|
||||
}
|
||||
|
||||
|
||||
class ElasticsearchBackend(SearchBackend):
|
||||
name = "elasticsearch"
|
||||
|
||||
def __init__(self, client: Elasticsearch | None = None) -> None:
|
||||
self._client = client or get_client()
|
||||
self._index = alias_name()
|
||||
|
||||
# -- SearchBackend ------------------------------------------------------
|
||||
def search(self, request: SearchRequest) -> SearchResult:
|
||||
body = build_query(request)
|
||||
started = time.perf_counter()
|
||||
try:
|
||||
response = self._client.search(index=self._index, **body)
|
||||
except (ApiError, TransportError) as exc:
|
||||
logger.exception("search_backend_error", extra={"event": "search_backend_error"})
|
||||
raise SearchBackendError() from exc
|
||||
|
||||
took_ms = int((time.perf_counter() - started) * 1000)
|
||||
hits = [self._to_hit(raw) for raw in response["hits"]["hits"]]
|
||||
return SearchResult(
|
||||
total=response["hits"]["total"]["value"],
|
||||
hits=hits,
|
||||
facets=self._to_facets(response.get("aggregations", {})),
|
||||
took_ms=took_ms,
|
||||
backend=self.name,
|
||||
)
|
||||
|
||||
def suggest(self, prefix: str, limit: int = 8) -> list[str]:
|
||||
if not prefix:
|
||||
return []
|
||||
try:
|
||||
response = self._client.search(
|
||||
index=self._index,
|
||||
size=limit,
|
||||
query={
|
||||
"bool": {
|
||||
"should": [
|
||||
{"match": {"full_name.autocomplete": {"query": prefix, "boost": 2}}},
|
||||
{"match": {"skills.autocomplete": prefix}},
|
||||
],
|
||||
"minimum_should_match": 1,
|
||||
}
|
||||
},
|
||||
_source=["full_name", "job_title"],
|
||||
)
|
||||
except (ApiError, TransportError) as exc:
|
||||
raise SearchBackendError() from exc
|
||||
|
||||
seen: list[str] = []
|
||||
for raw in response["hits"]["hits"]:
|
||||
value = raw["_source"].get("full_name", "")
|
||||
if value and value not in seen:
|
||||
seen.append(value)
|
||||
return seen[:limit]
|
||||
|
||||
def ping(self) -> bool:
|
||||
if not self._client.ping():
|
||||
raise SearchBackendError("Elasticsearch did not answer a ping.")
|
||||
return True
|
||||
|
||||
# -- Mapping helpers ----------------------------------------------------
|
||||
@staticmethod
|
||||
def _to_hit(raw: dict[str, Any]) -> SearchHit:
|
||||
source = raw["_source"]
|
||||
return SearchHit(
|
||||
id=source["id"],
|
||||
score=raw.get("_score") or 0.0,
|
||||
full_name=source.get("full_name", ""),
|
||||
headline=source.get("headline", ""),
|
||||
job_title=source.get("job_title", ""),
|
||||
job_title_role=source.get("job_title_role", ""),
|
||||
seniority_levels=source.get("seniority_levels", []),
|
||||
company=source.get("company", ""),
|
||||
industry=source.get("industry", ""),
|
||||
location_name=source.get("location_name", ""),
|
||||
location_country=source.get("location_country", ""),
|
||||
years_experience=source.get("years_experience"),
|
||||
linkedin_url=source.get("linkedin_url", ""),
|
||||
skills=source.get("skills", [])[:12],
|
||||
highlight=raw.get("highlight", {}),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _to_facets(aggregations: dict[str, Any]) -> dict[str, list[FacetBucket]]:
|
||||
return {
|
||||
name: [
|
||||
FacetBucket(key=str(bucket["key"]), count=bucket["doc_count"])
|
||||
for bucket in payload.get("buckets", [])
|
||||
if bucket["key"] != ""
|
||||
]
|
||||
for name, payload in aggregations.items()
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
"""
|
||||
PostgreSQL full-text search backend.
|
||||
|
||||
Why it exists
|
||||
-------------
|
||||
Elasticsearch is the primary engine, but a search API that hard-fails without
|
||||
it is fragile: the test suite would need a cluster, a reviewer would need one to
|
||||
run the project, and an Elasticsearch outage would take the whole product down.
|
||||
Setting ``SEARCH_BACKEND=postgres`` switches to this implementation, which is
|
||||
built on the ``tsvector`` maintained by the importer plus a trigram similarity
|
||||
pass for typo tolerance.
|
||||
|
||||
It is intentionally *not* a re-implementation of Elasticsearch: ranking is
|
||||
coarser and there is no stemming beyond what the ``english`` text search
|
||||
configuration provides. For a 300 profile dataset the difference is barely
|
||||
visible, which is exactly why it is a good fallback.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import time
|
||||
from collections import Counter
|
||||
|
||||
from django.contrib.postgres.search import SearchQuery, SearchRank, TrigramSimilarity
|
||||
from django.db.models import Case, F, FloatField, Q, QuerySet, Value, When
|
||||
from django.db.models.functions import Greatest
|
||||
|
||||
from apps.profiles.models import Profile, Skill
|
||||
from apps.search.backends.base import SearchBackend
|
||||
from apps.search.types import (
|
||||
SORT_EXPERIENCE_ASC,
|
||||
SORT_EXPERIENCE_DESC,
|
||||
SORT_NAME,
|
||||
FacetBucket,
|
||||
SearchHit,
|
||||
SearchRequest,
|
||||
SearchResult,
|
||||
)
|
||||
|
||||
_TOKEN_RE = re.compile(r"[\w']+", re.UNICODE)
|
||||
_FACET_LIMIT = 25
|
||||
_MIN_TRIGRAM_SIMILARITY = 0.3
|
||||
|
||||
|
||||
class PostgresBackend(SearchBackend):
|
||||
name = "postgres"
|
||||
|
||||
def search(self, request: SearchRequest) -> SearchResult:
|
||||
started = time.perf_counter()
|
||||
queryset = self._apply_filters(Profile.objects.all(), request)
|
||||
queryset = self._apply_keyword(queryset, request.q)
|
||||
total = queryset.count()
|
||||
|
||||
page = (
|
||||
self._apply_sort(queryset, request)
|
||||
.select_related("current_company")
|
||||
.prefetch_related("profile_skills__skill")[
|
||||
request.offset : request.offset + request.page_size
|
||||
]
|
||||
)
|
||||
hits = [self._to_hit(profile, request.q) for profile in page]
|
||||
facets = self._facets(queryset) if request.include_facets else {}
|
||||
|
||||
return SearchResult(
|
||||
total=total,
|
||||
hits=hits,
|
||||
facets=facets,
|
||||
took_ms=int((time.perf_counter() - started) * 1000),
|
||||
backend=self.name,
|
||||
)
|
||||
|
||||
def suggest(self, prefix: str, limit: int = 8) -> list[str]:
|
||||
if not prefix:
|
||||
return []
|
||||
return list(
|
||||
Profile.objects.filter(
|
||||
Q(full_name__istartswith=prefix) | Q(full_name__icontains=f" {prefix}")
|
||||
)
|
||||
.order_by("full_name")
|
||||
.values_list("full_name", flat=True)[:limit]
|
||||
)
|
||||
|
||||
def ping(self) -> bool:
|
||||
Profile.objects.exists()
|
||||
return True
|
||||
|
||||
# -- Query construction -------------------------------------------------
|
||||
@staticmethod
|
||||
def _apply_filters(queryset: QuerySet[Profile], request: SearchRequest) -> QuerySet[Profile]:
|
||||
if request.skills:
|
||||
# Every selected skill widens the result set (OR), mirroring the
|
||||
# ``terms`` filter used by the Elasticsearch backend.
|
||||
queryset = queryset.filter(
|
||||
skills__slug__in=[Skill.make_slug(s) for s in request.skills]
|
||||
)
|
||||
if request.roles:
|
||||
queryset = queryset.filter(job_title_role__in=request.roles)
|
||||
if request.seniority:
|
||||
queryset = queryset.filter(seniority_levels__overlap=list(request.seniority))
|
||||
if request.countries:
|
||||
queryset = queryset.filter(location_country__in=request.countries)
|
||||
if request.industries:
|
||||
queryset = queryset.filter(industry__in=request.industries)
|
||||
if request.companies:
|
||||
queryset = queryset.filter(current_company__name__in=request.companies)
|
||||
if request.min_experience is not None:
|
||||
queryset = queryset.filter(years_experience__gte=request.min_experience)
|
||||
if request.max_experience is not None:
|
||||
queryset = queryset.filter(years_experience__lte=request.max_experience)
|
||||
return queryset.distinct()
|
||||
|
||||
@staticmethod
|
||||
def _apply_keyword(queryset: QuerySet[Profile], term: str) -> QuerySet[Profile]:
|
||||
if not term:
|
||||
return queryset.annotate(rank=Value(0.0, output_field=FloatField()))
|
||||
|
||||
# ``websearch`` accepts the syntax users already know from search
|
||||
# engines: quoted phrases, ``or``, and ``-excluded``.
|
||||
query = SearchQuery(term, search_type="websearch", config="english")
|
||||
return (
|
||||
queryset.annotate(
|
||||
text_rank=SearchRank(F("search_vector"), query),
|
||||
name_similarity=TrigramSimilarity("full_name", term),
|
||||
)
|
||||
.annotate(
|
||||
rank=Greatest(
|
||||
F("text_rank"),
|
||||
# A close name match is worth as much as a strong text hit,
|
||||
# which is what makes "jospeh" still find "joseph".
|
||||
Case(
|
||||
When(
|
||||
name_similarity__gte=_MIN_TRIGRAM_SIMILARITY, then=F("name_similarity")
|
||||
),
|
||||
default=Value(0.0),
|
||||
output_field=FloatField(),
|
||||
),
|
||||
output_field=FloatField(),
|
||||
)
|
||||
)
|
||||
.filter(Q(search_vector=query) | Q(name_similarity__gte=_MIN_TRIGRAM_SIMILARITY))
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _apply_sort(queryset: QuerySet[Profile], request: SearchRequest) -> QuerySet[Profile]:
|
||||
if request.sort == SORT_NAME:
|
||||
return queryset.order_by("full_name", "id")
|
||||
if request.sort == SORT_EXPERIENCE_DESC:
|
||||
return queryset.order_by(F("years_experience").desc(nulls_last=True), "id")
|
||||
if request.sort == SORT_EXPERIENCE_ASC:
|
||||
return queryset.order_by(F("years_experience").asc(nulls_last=True), "id")
|
||||
return queryset.order_by("-rank", "full_name", "id")
|
||||
|
||||
# -- Facets -------------------------------------------------------------
|
||||
def _facets(self, queryset: QuerySet[Profile]) -> dict[str, list[FacetBucket]]:
|
||||
ids = list(queryset.values_list("id", flat=True))
|
||||
if not ids:
|
||||
return {
|
||||
name: []
|
||||
for name in (
|
||||
"skills",
|
||||
"job_title_role",
|
||||
"seniority",
|
||||
"country",
|
||||
"industry",
|
||||
"company",
|
||||
)
|
||||
}
|
||||
|
||||
skills = Skill.objects.filter(profile_skills__profile_id__in=ids).values_list(
|
||||
"name", flat=True
|
||||
)
|
||||
rows = Profile.objects.filter(id__in=ids).values(
|
||||
"job_title_role", "location_country", "industry", "current_company__name",
|
||||
"seniority_levels",
|
||||
) # fmt: skip
|
||||
|
||||
counters: dict[str, Counter] = {
|
||||
"skills": Counter(skills),
|
||||
"job_title_role": Counter(),
|
||||
"seniority": Counter(),
|
||||
"country": Counter(),
|
||||
"industry": Counter(),
|
||||
"company": Counter(),
|
||||
}
|
||||
for row in rows:
|
||||
counters["job_title_role"][row["job_title_role"]] += 1
|
||||
counters["country"][row["location_country"]] += 1
|
||||
counters["industry"][row["industry"]] += 1
|
||||
counters["company"][row["current_company__name"] or ""] += 1
|
||||
for level in row["seniority_levels"] or []:
|
||||
counters["seniority"][level] += 1
|
||||
|
||||
return {
|
||||
name: [
|
||||
FacetBucket(key=key, count=count)
|
||||
for key, count in counter.most_common(_FACET_LIMIT)
|
||||
if key
|
||||
]
|
||||
for name, counter in counters.items()
|
||||
}
|
||||
|
||||
# -- Result mapping -----------------------------------------------------
|
||||
@classmethod
|
||||
def _to_hit(cls, profile: Profile, term: str) -> SearchHit:
|
||||
skills = [link.skill.name for link in profile.profile_skills.all()[:12]]
|
||||
return SearchHit(
|
||||
id=profile.id,
|
||||
score=round(float(getattr(profile, "rank", 0.0) or 0.0), 4),
|
||||
full_name=profile.full_name,
|
||||
headline=profile.headline,
|
||||
job_title=profile.job_title,
|
||||
job_title_role=profile.job_title_role,
|
||||
seniority_levels=profile.seniority_levels,
|
||||
company=profile.current_company.name if profile.current_company_id else "",
|
||||
industry=profile.industry,
|
||||
location_name=profile.location_name,
|
||||
location_country=profile.location_country,
|
||||
years_experience=profile.years_experience,
|
||||
linkedin_url=profile.linkedin_url,
|
||||
skills=skills,
|
||||
highlight=cls._highlight(term, profile, skills),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _highlight(term: str, profile: Profile, skills: list[str]) -> dict[str, list[str]]:
|
||||
"""
|
||||
Approximate Elasticsearch's highlighter.
|
||||
|
||||
PostgreSQL offers ``ts_headline`` but it needs an extra round trip per
|
||||
row; for the fields shown on a result card a simple token wrap gives the
|
||||
same visual result at no query cost.
|
||||
"""
|
||||
tokens = [token for token in _TOKEN_RE.findall(term.lower()) if len(token) > 1]
|
||||
if not tokens:
|
||||
return {}
|
||||
pattern = re.compile("|".join(re.escape(token) for token in tokens), re.IGNORECASE)
|
||||
|
||||
highlight: dict[str, list[str]] = {}
|
||||
for field_name, value in (
|
||||
("full_name", profile.full_name),
|
||||
("job_title", profile.job_title),
|
||||
):
|
||||
if value and pattern.search(value):
|
||||
highlight[field_name] = [pattern.sub(lambda m: f"<mark>{m.group(0)}</mark>", value)]
|
||||
matched_skills = [skill for skill in skills if pattern.search(skill)]
|
||||
if matched_skills:
|
||||
highlight["skills"] = [
|
||||
pattern.sub(lambda m: f"<mark>{m.group(0)}</mark>", skill)
|
||||
for skill in matched_skills[:3]
|
||||
]
|
||||
return highlight
|
||||
Reference in New Issue
Block a user