redrob-ranker / src /core /query_parser.py
MrNK2107
Fix remote candidate search returning 0 results by mapping preferred_work_mode
3cae53e
Raw
History Blame Contribute Delete
9.52 kB
from __future__ import annotations
import re
from src.core.constants import INDIAN_CITIES
from src.core.models import (
ExperienceRequirements,
LocationRequirements,
ParsedQuery,
PreferredSkill,
QueryFilters,
RequiredSkill,
SkillImportance,
)
from src.matching.skill_matcher import SKILL_ALIASES
_SINGLE_WORD_SKILLS: set[str] = set()
_MULTI_WORD_SKILLS: set[str] = set()
_MULTI_WORD_MAP: dict[str, str] = {}
for skill in SKILL_ALIASES:
norm = skill.strip().lower()
word_count = len(norm.split())
if word_count == 1:
_SINGLE_WORD_SKILLS.add(norm)
else:
_MULTI_WORD_SKILLS.add(norm)
_MULTI_WORD_MAP[norm] = skill
for alias in SKILL_ALIASES[skill]:
alias_norm = alias.strip().lower()
alias_words = len(alias_norm.split())
if alias_words == 1:
_SINGLE_WORD_SKILLS.add(alias_norm)
else:
_MULTI_WORD_SKILLS.add(alias_norm)
_MULTI_WORD_MAP[alias_norm] = skill
_EXTRA_SKILLS: set[str] = {
"python", "java", "javascript", "typescript", "golang", "go", "rust",
"c++", "c#", "ruby", "php", "scala", "kotlin", "swift", "dart",
"html", "css", "sass", "less", "webpack", "vite", "babel",
"node.js", "node", "express", "nestjs", "graphql", "grpc",
"react", "vue", "angular", "svelte", "next.js", "nuxt", "gatsby",
"tailwind", "bootstrap", "material ui", "shadcn", "chakra",
"postgresql", "postgres", "mysql", "mongodb", "redis", "elasticsearch",
"cassandra", "dynamodb", "couchbase", "mariadb", "sqlite",
"aws", "gcp", "azure", "cloud", "terraform", "pulumi",
"docker", "kubernetes", "k8s", "helm", "istio", "envoy",
"ci/cd", "jenkins", "github actions", "gitlab ci", "circleci",
"argocd", "prometheus", "grafana", "datadog", "new relic",
"kafka", "rabbitmq", "pulsar", "nats",
"flask", "django", "fastapi", "spring boot", "spring",
"pytorch", "tensorflow", "keras", "jax", "scikit-learn",
"pandas", "numpy", "scipy", "matplotlib", "seaborn", "plotly",
"spark", "hadoop", "airflow", "prefect", "dbt", "snowflake",
"bigquery", "redshift", "databricks", "lakehouse",
"machine learning", "deep learning", "reinforcement learning",
"natural language processing", "nlp", "computer vision",
"llm", "large language model", "rag", "langchain",
"gen ai", "generative ai", "diffusion", "transformer",
"selenium", "cypress", "playwright", "puppeteer",
"pytest", "jest", "mocha", "chai", "vitest",
"rest api", "restful", "rest", "graphql",
"microservices", "distributed systems", "system design",
"android", "ios", "flutter", "react native", "kotlin",
"mlops", "devops", "data engineering", "data science",
"cybersecurity", "penetration testing", "security",
"tableau", "power bi", "powerbi", "looker", "excel",
"agile", "scrum", "jira", "confluence",
"sap", "oracle", "salesforce", "servicenow",
"numpy", "pandas", "scipy", "matplotlib", "seaborn", "plotly",
"hibernate", "jdbc", "jpa", "struts", "jee",
"asp.net", ".net", "c#", "dotnet",
"blockchain", "solidity", "web3", "smart contract",
"api", "soap", "json", "xml", "protobuf",
}
_SINGLE_WORD_SKILLS.update(s for s in _EXTRA_SKILLS if len(s.split()) == 1)
_MULTI_WORD_SKILLS.update(s for s in _EXTRA_SKILLS if len(s.split()) > 1)
for s in _EXTRA_SKILLS:
norm = s.strip().lower()
if len(norm.split()) > 1 and norm not in _MULTI_WORD_MAP:
_MULTI_WORD_MAP[norm] = norm
_MULTI_WORD_SKILLS_SORTED = sorted(_MULTI_WORD_SKILLS, key=lambda x: -len(x.split()))
_SENIORITY_LEVELS = {"junior": "junior", "jr": "junior", "mid": "mid",
"senior": "senior", "sr": "senior", "lead": "lead",
"principal": "principal", "staff": "staff",
"head": "head", "vp": "vp", "director": "director",
"c-level": "c-level", "cto": "c-level", "ceo": "c-level",
"chief": "c-level", "architect": "architect",
"manager": "manager", "head of": "head"}
_NON_SKILL_WORDS: frozenset = frozenset({
"find", "search", "look", "looking", "need", "wanted", "hiring",
"and", "the", "a", "an", "in", "at", "on", "to", "of", "is", "are",
"for", "with", "from", "by", "as", "or", "but", "not", "be",
"i", "we", "you", "he", "she", "it", "they", "me", "my", "our",
"senior", "junior", "lead", "head", "principal", "staff",
"developer", "engineer", "manager", "architect", "analyst",
"experience", "role", "position", "job", "opening", "hire",
"dev", "sr", "jr", "intern", "fresher",
"year", "years", "yr", "yrs", "exp", "experience",
"building", "designing", "managing", "leading", "working",
"team", "teams", "people", "product", "projects", "company",
"good", "strong", "hands-on", "handson", "expert",
"preferred", "required", "must", "should", "ability",
"proven", "track", "record", "etc", "including",
"platform", "service", "system", "application",
"engineering", "technology", "technologies",
})
_EXPERIENCE_RE = re.compile(
r'(?P<min>\d+)\+?\s*(?:years?|yrs?|yr)\s*(?:of\s*)?(?:experience)?',
re.IGNORECASE,
)
def detect_multi_word_skills(text: str) -> list[str]:
lower = text.lower()
found: list[str] = []
start = 0
while start < len(lower):
matched = 0
for phrase in _MULTI_WORD_SKILLS_SORTED:
n = len(phrase)
segment = lower[start:start + len(phrase)]
if segment == phrase:
pre_ok = start == 0 or not lower[start - 1].isalnum()
post_ok = (start + len(phrase) >= len(lower)
or not lower[start + len(phrase)].isalnum())
if pre_ok and post_ok:
found.append(_MULTI_WORD_MAP.get(phrase, phrase))
start += len(phrase)
matched = len(phrase)
break
if matched == 0:
start += 1
return found
def parse_query(text: str) -> ParsedQuery:
original = text.strip()
lower = original.lower()
multi_skills = detect_multi_word_skills(lower)
remaining = lower
for phrase in sorted(multi_skills, key=len, reverse=True):
remaining = remaining.replace(phrase.lower(), "", 1)
remaining = re.sub(r'\s+', ' ', remaining).strip()
seniority: str | None = None
for label, level in _SENIORITY_LEVELS.items():
if label in remaining.split():
seniority = level
break
if label == "head of" and "head of" in remaining:
seniority = "head"
break
exp_min: int | None = None
exp_max: int | None = None
exp_match = _EXPERIENCE_RE.search(remaining)
if exp_match:
exp_min = int(exp_match.group("min"))
range_match = re.search(
rf'{exp_match.group("min")}\s*-\s*(\d+)\s*(?:years?|yrs?)',
remaining,
)
if range_match:
exp_max = int(range_match.group(1))
city: str | None = None
for c in sorted(INDIAN_CITIES, key=len, reverse=True):
if c.lower() in remaining:
city = c
remaining = remaining.replace(c.lower(), "")
break
words = remaining.split()
skill_words: list[str] = []
for w in words:
w_clean = w.strip(",.!?;:'\"()[]{}")
if len(w_clean) <= 1:
continue
if w_clean in _NON_SKILL_WORDS:
continue
if w_clean in _SINGLE_WORD_SKILLS:
skill_words.append(w_clean)
seen: set[str] = set()
all_skills: list[str] = []
for s in multi_skills + skill_words:
s_norm = s.strip().lower()
if s_norm not in seen:
seen.add(s_norm)
all_skills.append(s)
required_skills = [RequiredSkill(name=s, importance=SkillImportance.REQUIRED)
for s in all_skills[:8]]
preferred_skills = [PreferredSkill(name=s, importance=SkillImportance.PREFERRED)
for s in all_skills[8:]]
if not all_skills:
required_skills = [RequiredSkill(name=original,
importance=SkillImportance.REQUIRED)]
exp_req = ExperienceRequirements(
min_years=float(exp_min) if exp_min else None,
max_years=float(exp_max) if exp_max else None,
)
loc = LocationRequirements(city=city or "", remote_ok="remote" in lower)
return ParsedQuery(
required_skills=required_skills,
preferred_skills=preferred_skills,
experience=exp_req,
location=loc,
filters=QueryFilters(),
original_query=original,
)
def expand_with_aliases(text: str) -> list[str]:
lower = text.lower()
expansions: list[str] = [text]
detected_multi = detect_multi_word_skills(lower)
detected_multi_norm = set(s.lower() for s in detected_multi)
words = lower.split()
for i, w in enumerate(words):
if w in _NON_SKILL_WORDS:
continue
for skill, aliases in SKILL_ALIASES.items():
all_forms = [skill] + aliases
if w in all_forms or skill == w or w in aliases:
for form in all_forms:
if form != w and form not in detected_multi_norm:
alt_words = list(words)
alt_words[i] = form
expansions.append(" ".join(alt_words))
break
return expansions