Spaces:
Running
Running
MrNK2107 commited on
Commit ·
e01a2b0
1
Parent(s): bcff391
feat(extraction): add FieldExtractor pipeline, ProfileStore with lazy loading, and skill matching
Browse files- New multi-signal field extraction pipeline (seniority, title, company, experience_years, domain, career_history)
- FieldExtractorPipeline orchestrates cross-field extraction with cascading fallbacks
- ProfileStore with lazy loading via offset index for 100K profiles
- Smart normalizer integrates extraction pipeline; extracts skills from raw text
- Skill aliases (32 entries, 80+ aliases) with fuzzy matching via SequenceMatcher
- Cross-encoder sigmoid normalization, hybrid search cache, exact location filters
- ProfileStore wiring through main, API routes, executor, and Gradio UI
- pyproject.toml +3 -0
- scripts/build_indexes.py +45 -0
- scripts/build_offset_index.py +64 -0
- src/agents/executor.py +117 -18
- src/api/routes/profiles.py +11 -6
- src/core/models.py +1 -0
- src/core/profile_store.py +138 -0
- src/extraction/__init__.py +3 -0
- src/extraction/career_history_utils.py +115 -0
- src/extraction/company.py +22 -0
- src/extraction/domain.py +72 -0
- src/extraction/experience_years.py +73 -0
- src/extraction/pipeline.py +47 -0
- src/extraction/seniority.py +82 -0
- src/extraction/title.py +52 -0
- src/ingestion/normalizer.py +24 -5
- src/main.py +16 -36
- src/ui/app.py +50 -0
- tests/test_extraction/__init__.py +0 -0
- tests/test_extraction/test_career_history_utils.py +120 -0
- tests/test_extraction/test_company.py +33 -0
- tests/test_extraction/test_domain.py +44 -0
- tests/test_extraction/test_experience_years.py +80 -0
- tests/test_extraction/test_pipeline.py +64 -0
- tests/test_extraction/test_regression.py +96 -0
- tests/test_extraction/test_seniority.py +73 -0
- tests/test_extraction/test_title.py +54 -0
- tests/test_ingestion/test_parser.py +3 -1
pyproject.toml
CHANGED
|
@@ -70,6 +70,9 @@ markers = [
|
|
| 70 |
"integration: marks integration tests",
|
| 71 |
]
|
| 72 |
|
|
|
|
|
|
|
|
|
|
| 73 |
[build-system]
|
| 74 |
requires = ["setuptools>=68.0"]
|
| 75 |
build-backend = "setuptools.build_meta"
|
|
|
|
| 70 |
"integration: marks integration tests",
|
| 71 |
]
|
| 72 |
|
| 73 |
+
[tool.setuptools.packages.find]
|
| 74 |
+
where = ["src"]
|
| 75 |
+
|
| 76 |
[build-system]
|
| 77 |
requires = ["setuptools>=68.0"]
|
| 78 |
build-backend = "setuptools.build_meta"
|
scripts/build_indexes.py
CHANGED
|
@@ -116,6 +116,9 @@ def build_indexes(
|
|
| 116 |
bm25_search.save()
|
| 117 |
logger.info(f"BM25 index saved: {bm25_search.size} documents")
|
| 118 |
|
|
|
|
|
|
|
|
|
|
| 119 |
elapsed = time.perf_counter() - start
|
| 120 |
logger.info(f"All indexes built successfully in {elapsed:.1f}s")
|
| 121 |
|
|
@@ -140,6 +143,48 @@ def _build_document_text(profile: Profile) -> str:
|
|
| 140 |
return " ".join(parts)
|
| 141 |
|
| 142 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 143 |
def main():
|
| 144 |
parser = argparse.ArgumentParser(description="Build FAISS + BM25 indexes from profiles")
|
| 145 |
parser.add_argument("--sample", type=int, default=0,
|
|
|
|
| 116 |
bm25_search.save()
|
| 117 |
logger.info(f"BM25 index saved: {bm25_search.size} documents")
|
| 118 |
|
| 119 |
+
offset_path = index_dir / "offset_index.json"
|
| 120 |
+
_save_offset_index(profiles_path, profile_ids, offset_path)
|
| 121 |
+
|
| 122 |
elapsed = time.perf_counter() - start
|
| 123 |
logger.info(f"All indexes built successfully in {elapsed:.1f}s")
|
| 124 |
|
|
|
|
| 143 |
return " ".join(parts)
|
| 144 |
|
| 145 |
|
| 146 |
+
def _save_offset_index(
|
| 147 |
+
profiles_path: Path, profile_ids: list[str], output_path: Path,
|
| 148 |
+
) -> None:
|
| 149 |
+
import json
|
| 150 |
+
if profiles_path.suffix != ".jsonl":
|
| 151 |
+
logger.info("Skipping offset index (not a JSONL file)")
|
| 152 |
+
return
|
| 153 |
+
pid_set = set(profile_ids)
|
| 154 |
+
offsets: dict[str, int] = {}
|
| 155 |
+
with open(profiles_path, encoding="utf-8") as f:
|
| 156 |
+
while True:
|
| 157 |
+
offset = f.tell()
|
| 158 |
+
line = f.readline()
|
| 159 |
+
if not line:
|
| 160 |
+
break
|
| 161 |
+
line = line.strip()
|
| 162 |
+
if not line:
|
| 163 |
+
continue
|
| 164 |
+
try:
|
| 165 |
+
raw = json.loads(line)
|
| 166 |
+
cand_id = (
|
| 167 |
+
raw.get("profile_id")
|
| 168 |
+
or raw.get("candidate_id")
|
| 169 |
+
or raw.get("id")
|
| 170 |
+
)
|
| 171 |
+
profile_nested = raw.get("profile", {})
|
| 172 |
+
if isinstance(profile_nested, dict) and not cand_id:
|
| 173 |
+
cand_id = (
|
| 174 |
+
profile_nested.get("profile_id")
|
| 175 |
+
or profile_nested.get("candidate_id")
|
| 176 |
+
or profile_nested.get("id")
|
| 177 |
+
)
|
| 178 |
+
if cand_id and str(cand_id) in pid_set:
|
| 179 |
+
offsets[str(cand_id)] = offset
|
| 180 |
+
except json.JSONDecodeError:
|
| 181 |
+
continue
|
| 182 |
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
| 183 |
+
with open(output_path, "w") as f:
|
| 184 |
+
json.dump(offsets, f)
|
| 185 |
+
logger.info(f"Offset index saved: {len(offsets)} entries")
|
| 186 |
+
|
| 187 |
+
|
| 188 |
def main():
|
| 189 |
parser = argparse.ArgumentParser(description="Build FAISS + BM25 indexes from profiles")
|
| 190 |
parser.add_argument("--sample", type=int, default=0,
|
scripts/build_offset_index.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Build offset index only — does not rebuild FAISS/BM25."""
|
| 2 |
+
import json
|
| 3 |
+
import sys
|
| 4 |
+
import time
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
|
| 7 |
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
| 8 |
+
|
| 9 |
+
from src.core.config import DATA_DIR
|
| 10 |
+
|
| 11 |
+
profiles_path = DATA_DIR / "profiles" / "candidates.jsonl"
|
| 12 |
+
output_path = DATA_DIR / "indexes" / "offset_index.json"
|
| 13 |
+
|
| 14 |
+
id_map_path = DATA_DIR / "indexes" / "faiss_id_map.json"
|
| 15 |
+
if not id_map_path.exists():
|
| 16 |
+
print("FAISS id map not found. Run build_indexes.py first.")
|
| 17 |
+
sys.exit(1)
|
| 18 |
+
|
| 19 |
+
with open(id_map_path) as f:
|
| 20 |
+
profile_ids = json.load(f)
|
| 21 |
+
|
| 22 |
+
pid_set = set(profile_ids)
|
| 23 |
+
print(f"Looking up offsets for {len(pid_set)} profile IDs...")
|
| 24 |
+
|
| 25 |
+
start = time.perf_counter()
|
| 26 |
+
offsets: dict[str, int] = {}
|
| 27 |
+
with open(profiles_path, "r", encoding="utf-8") as f:
|
| 28 |
+
while True:
|
| 29 |
+
offset = f.tell()
|
| 30 |
+
line = f.readline()
|
| 31 |
+
if not line:
|
| 32 |
+
break
|
| 33 |
+
line = line.strip()
|
| 34 |
+
if not line:
|
| 35 |
+
continue
|
| 36 |
+
try:
|
| 37 |
+
raw = json.loads(line)
|
| 38 |
+
cand_id = (
|
| 39 |
+
raw.get("profile_id")
|
| 40 |
+
or raw.get("candidate_id")
|
| 41 |
+
or raw.get("id")
|
| 42 |
+
)
|
| 43 |
+
profile_nested = raw.get("profile", {})
|
| 44 |
+
if isinstance(profile_nested, dict) and not cand_id:
|
| 45 |
+
cand_id = (
|
| 46 |
+
profile_nested.get("profile_id")
|
| 47 |
+
or profile_nested.get("candidate_id")
|
| 48 |
+
or profile_nested.get("id")
|
| 49 |
+
)
|
| 50 |
+
if cand_id and str(cand_id) in pid_set:
|
| 51 |
+
offsets[str(cand_id)] = offset
|
| 52 |
+
except json.JSONDecodeError:
|
| 53 |
+
continue
|
| 54 |
+
|
| 55 |
+
elapsed = time.perf_counter() - start
|
| 56 |
+
print(f"Found {len(offsets)}/{len(pid_set)} profile offsets in {elapsed:.1f}s")
|
| 57 |
+
missing = pid_set - set(offsets.keys())
|
| 58 |
+
if missing:
|
| 59 |
+
print(f"Missing {len(missing)} profiles")
|
| 60 |
+
|
| 61 |
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
| 62 |
+
with open(output_path, "w") as f:
|
| 63 |
+
json.dump(offsets, f)
|
| 64 |
+
print(f"Offset index saved to {output_path}")
|
src/agents/executor.py
CHANGED
|
@@ -1,20 +1,122 @@
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
import logging
|
|
|
|
| 4 |
|
| 5 |
from src.core.models import (
|
| 6 |
MatchMetadata,
|
| 7 |
MatchResult,
|
| 8 |
ParsedQuery,
|
| 9 |
-
Profile,
|
| 10 |
SearchFilters,
|
| 11 |
SearchMethod,
|
|
|
|
| 12 |
)
|
|
|
|
| 13 |
from src.matching.scorer import CandidateScorer
|
| 14 |
from src.search.filters import SearchFilter
|
| 15 |
from src.search.hybrid import HybridSearch
|
| 16 |
from src.search.reranker import CrossEncoderReranker
|
| 17 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
logger = logging.getLogger(__name__)
|
| 19 |
|
| 20 |
|
|
@@ -24,12 +126,13 @@ class ExecutorAgent:
|
|
| 24 |
hybrid_search: HybridSearch,
|
| 25 |
reranker: CrossEncoderReranker,
|
| 26 |
scorer: CandidateScorer,
|
| 27 |
-
profiles:
|
| 28 |
) -> None:
|
| 29 |
self.hybrid_search = hybrid_search
|
| 30 |
self.reranker = reranker
|
| 31 |
self.scorer = scorer
|
| 32 |
-
self.
|
|
|
|
| 33 |
|
| 34 |
async def execute(
|
| 35 |
self,
|
|
@@ -39,15 +142,12 @@ class ExecutorAgent:
|
|
| 39 |
) -> list[MatchResult]:
|
| 40 |
search_text = self._query_to_search_text(parsed)
|
| 41 |
|
| 42 |
-
# RRF-fused hybrid search for ranking order
|
| 43 |
hybrid_results = self.hybrid_search.search(search_text, top_k=top_k * 2)
|
| 44 |
|
| 45 |
-
# Separate vector + BM25 searches for actual similarity scores
|
| 46 |
query_vec = self.hybrid_search.embedder.embed_query(search_text)
|
| 47 |
vector_raw = self.hybrid_search.vector_search.search(query_vec, top_k=top_k * 2)
|
| 48 |
bm25_raw = self.hybrid_search.bm25_search.search(search_text, top_k=top_k * 2)
|
| 49 |
|
| 50 |
-
# Build score lookup: profile_id → (vec_score, bm25_score)
|
| 51 |
vec_scores: dict[str, float] = {
|
| 52 |
pid: self._norm_vec_score(s) for pid, s in vector_raw
|
| 53 |
}
|
|
@@ -58,8 +158,8 @@ class ExecutorAgent:
|
|
| 58 |
filtered = self._apply_filters(hybrid_results, parsed)
|
| 59 |
|
| 60 |
rerank_candidates: list[tuple[str, str, float]] = []
|
| 61 |
-
for pid, score in filtered[:
|
| 62 |
-
profile = self.
|
| 63 |
if profile is not None:
|
| 64 |
rerank_candidates.append((pid, profile.raw_text[:2000], score))
|
| 65 |
else:
|
|
@@ -69,15 +169,14 @@ class ExecutorAgent:
|
|
| 69 |
|
| 70 |
results: list[MatchResult] = []
|
| 71 |
for rank, (pid, rerank_score) in enumerate(reranked, start=1):
|
| 72 |
-
profile = self.
|
| 73 |
if profile is None:
|
| 74 |
continue
|
| 75 |
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
skill_overlap = len(all_req & skills_set) / max(len(all_req), 1)
|
| 81 |
|
| 82 |
total_years = (
|
| 83 |
profile.professional.total_experience_years
|
|
@@ -98,9 +197,9 @@ class ExecutorAgent:
|
|
| 98 |
|
| 99 |
match_scores = self.scorer.compute_overall(scores_dict, slider_weights)
|
| 100 |
|
| 101 |
-
matched_skills =
|
| 102 |
-
|
| 103 |
-
|
| 104 |
|
| 105 |
loc = profile.personal.location
|
| 106 |
city = loc.city if profile.personal and loc else None
|
|
@@ -173,7 +272,7 @@ class ExecutorAgent:
|
|
| 173 |
|
| 174 |
filtered: list[tuple[str, float]] = []
|
| 175 |
for pid, score in results:
|
| 176 |
-
profile = self.
|
| 177 |
if profile is None:
|
| 178 |
filtered.append((pid, score))
|
| 179 |
elif filter_obj.passes(profile):
|
|
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
import logging
|
| 4 |
+
from difflib import SequenceMatcher
|
| 5 |
|
| 6 |
from src.core.models import (
|
| 7 |
MatchMetadata,
|
| 8 |
MatchResult,
|
| 9 |
ParsedQuery,
|
|
|
|
| 10 |
SearchFilters,
|
| 11 |
SearchMethod,
|
| 12 |
+
Skill,
|
| 13 |
)
|
| 14 |
+
from src.core.profile_store import ProfileStore
|
| 15 |
from src.matching.scorer import CandidateScorer
|
| 16 |
from src.search.filters import SearchFilter
|
| 17 |
from src.search.hybrid import HybridSearch
|
| 18 |
from src.search.reranker import CrossEncoderReranker
|
| 19 |
|
| 20 |
+
SKILL_ALIASES: dict[str, list[str]] = {
|
| 21 |
+
"python": ["python3", "py"],
|
| 22 |
+
"javascript": ["js", "ecmascript", "es6"],
|
| 23 |
+
"typescript": ["ts"],
|
| 24 |
+
"react": ["reactjs", "react.js"],
|
| 25 |
+
"vue": ["vuejs", "vue.js"],
|
| 26 |
+
"angular": ["angularjs", "angular.js"],
|
| 27 |
+
"node.js": ["nodejs", "node"],
|
| 28 |
+
"kubernetes": ["k8s"],
|
| 29 |
+
"machine learning": ["ml"],
|
| 30 |
+
"artificial intelligence": ["ai"],
|
| 31 |
+
"natural language processing": ["nlp"],
|
| 32 |
+
"sql": ["mysql", "postgresql", "postgres", "pl/sql"],
|
| 33 |
+
"git": ["github", "gitlab", "bitbucket"],
|
| 34 |
+
"rest api": ["rest", "restful", "restful api"],
|
| 35 |
+
"tensorflow": ["tf"],
|
| 36 |
+
"pytorch": ["torch"],
|
| 37 |
+
"fastapi": ["fast api"],
|
| 38 |
+
"deep learning": ["dl"],
|
| 39 |
+
"computer vision": ["cv"],
|
| 40 |
+
"ci/cd": ["ci", "cd", "continuous integration", "continuous deployment"],
|
| 41 |
+
"statistics": ["statistical analysis", "statistical modeling"],
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
_ALIAS_TO_CANONICAL: dict[str, str] = {}
|
| 45 |
+
for _canonical, _aliases in SKILL_ALIASES.items():
|
| 46 |
+
for _a in _aliases:
|
| 47 |
+
_ALIAS_TO_CANONICAL[_a] = _canonical
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def _canonical_skill(name: str) -> str:
|
| 51 |
+
return _ALIAS_TO_CANONICAL.get(name, name)
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def _skill_match_score(
|
| 55 |
+
required_names: list[str], profile_skills: list[Skill], raw_text: str | None = None,
|
| 56 |
+
) -> float:
|
| 57 |
+
if not required_names:
|
| 58 |
+
return 1.0
|
| 59 |
+
matched = 0
|
| 60 |
+
skill_names_lower = {s.name.lower() for s in profile_skills}
|
| 61 |
+
raw_lower = raw_text.lower() if raw_text else ""
|
| 62 |
+
for rn in required_names:
|
| 63 |
+
rn_lower = _canonical_skill(rn.lower())
|
| 64 |
+
if rn_lower in skill_names_lower:
|
| 65 |
+
matched += 1
|
| 66 |
+
continue
|
| 67 |
+
aliases = SKILL_ALIASES.get(rn_lower, [])
|
| 68 |
+
if any(a in skill_names_lower for a in aliases):
|
| 69 |
+
matched += 1
|
| 70 |
+
continue
|
| 71 |
+
fuzzy_match = False
|
| 72 |
+
for sn in skill_names_lower:
|
| 73 |
+
if SequenceMatcher(None, rn_lower, sn).ratio() >= 0.8:
|
| 74 |
+
fuzzy_match = True
|
| 75 |
+
break
|
| 76 |
+
if fuzzy_match:
|
| 77 |
+
matched += 1
|
| 78 |
+
continue
|
| 79 |
+
if rn_lower in raw_lower:
|
| 80 |
+
matched += 1
|
| 81 |
+
continue
|
| 82 |
+
if any(a in raw_lower for a in aliases):
|
| 83 |
+
matched += 1
|
| 84 |
+
return matched / len(required_names)
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def _match_skills_detail(
|
| 88 |
+
required_names: list[str], profile_skills: list[Skill], raw_text: str | None = None,
|
| 89 |
+
) -> tuple[list[str], list[str]]:
|
| 90 |
+
matched: list[str] = []
|
| 91 |
+
missing: list[str] = []
|
| 92 |
+
skill_names_lower = {s.name.lower() for s in profile_skills}
|
| 93 |
+
raw_lower = raw_text.lower() if raw_text else ""
|
| 94 |
+
for rn in required_names:
|
| 95 |
+
rn_lower = _canonical_skill(rn.lower())
|
| 96 |
+
if rn_lower in skill_names_lower:
|
| 97 |
+
matched.append(rn)
|
| 98 |
+
continue
|
| 99 |
+
aliases = SKILL_ALIASES.get(rn_lower, [])
|
| 100 |
+
if any(a in skill_names_lower for a in aliases):
|
| 101 |
+
matched.append(rn)
|
| 102 |
+
continue
|
| 103 |
+
fuzzy_found = False
|
| 104 |
+
for sn in skill_names_lower:
|
| 105 |
+
if SequenceMatcher(None, rn_lower, sn).ratio() >= 0.8:
|
| 106 |
+
fuzzy_found = True
|
| 107 |
+
break
|
| 108 |
+
if fuzzy_found:
|
| 109 |
+
matched.append(rn)
|
| 110 |
+
continue
|
| 111 |
+
if rn_lower in raw_lower:
|
| 112 |
+
matched.append(rn)
|
| 113 |
+
continue
|
| 114 |
+
if any(a in raw_lower for a in aliases):
|
| 115 |
+
matched.append(rn)
|
| 116 |
+
continue
|
| 117 |
+
missing.append(rn)
|
| 118 |
+
return matched, missing
|
| 119 |
+
|
| 120 |
logger = logging.getLogger(__name__)
|
| 121 |
|
| 122 |
|
|
|
|
| 126 |
hybrid_search: HybridSearch,
|
| 127 |
reranker: CrossEncoderReranker,
|
| 128 |
scorer: CandidateScorer,
|
| 129 |
+
profiles: ProfileStore,
|
| 130 |
) -> None:
|
| 131 |
self.hybrid_search = hybrid_search
|
| 132 |
self.reranker = reranker
|
| 133 |
self.scorer = scorer
|
| 134 |
+
self.profile_store = profiles
|
| 135 |
+
self._rerank_top_k = 20
|
| 136 |
|
| 137 |
async def execute(
|
| 138 |
self,
|
|
|
|
| 142 |
) -> list[MatchResult]:
|
| 143 |
search_text = self._query_to_search_text(parsed)
|
| 144 |
|
|
|
|
| 145 |
hybrid_results = self.hybrid_search.search(search_text, top_k=top_k * 2)
|
| 146 |
|
|
|
|
| 147 |
query_vec = self.hybrid_search.embedder.embed_query(search_text)
|
| 148 |
vector_raw = self.hybrid_search.vector_search.search(query_vec, top_k=top_k * 2)
|
| 149 |
bm25_raw = self.hybrid_search.bm25_search.search(search_text, top_k=top_k * 2)
|
| 150 |
|
|
|
|
| 151 |
vec_scores: dict[str, float] = {
|
| 152 |
pid: self._norm_vec_score(s) for pid, s in vector_raw
|
| 153 |
}
|
|
|
|
| 158 |
filtered = self._apply_filters(hybrid_results, parsed)
|
| 159 |
|
| 160 |
rerank_candidates: list[tuple[str, str, float]] = []
|
| 161 |
+
for pid, score in filtered[: self._rerank_top_k]:
|
| 162 |
+
profile = self.profile_store.get(pid)
|
| 163 |
if profile is not None:
|
| 164 |
rerank_candidates.append((pid, profile.raw_text[:2000], score))
|
| 165 |
else:
|
|
|
|
| 169 |
|
| 170 |
results: list[MatchResult] = []
|
| 171 |
for rank, (pid, rerank_score) in enumerate(reranked, start=1):
|
| 172 |
+
profile = self.profile_store.get(pid)
|
| 173 |
if profile is None:
|
| 174 |
continue
|
| 175 |
|
| 176 |
+
req_names = [rs.name for rs in parsed.required_skills]
|
| 177 |
+
pref_names = [ps.name for ps in parsed.preferred_skills]
|
| 178 |
+
all_req = req_names + pref_names
|
| 179 |
+
skill_overlap = _skill_match_score(all_req, profile.skills, profile.raw_text)
|
|
|
|
| 180 |
|
| 181 |
total_years = (
|
| 182 |
profile.professional.total_experience_years
|
|
|
|
| 197 |
|
| 198 |
match_scores = self.scorer.compute_overall(scores_dict, slider_weights)
|
| 199 |
|
| 200 |
+
matched_skills, missing_skills = _match_skills_detail(
|
| 201 |
+
req_names, profile.skills, profile.raw_text,
|
| 202 |
+
)
|
| 203 |
|
| 204 |
loc = profile.personal.location
|
| 205 |
city = loc.city if profile.personal and loc else None
|
|
|
|
| 272 |
|
| 273 |
filtered: list[tuple[str, float]] = []
|
| 274 |
for pid, score in results:
|
| 275 |
+
profile = self.profile_store.get(pid)
|
| 276 |
if profile is None:
|
| 277 |
filtered.append((pid, score))
|
| 278 |
elif filter_obj.passes(profile):
|
src/api/routes/profiles.py
CHANGED
|
@@ -5,22 +5,25 @@ import logging
|
|
| 5 |
from fastapi import APIRouter, HTTPException
|
| 6 |
|
| 7 |
from src.core.models import Profile
|
|
|
|
| 8 |
|
| 9 |
logger = logging.getLogger(__name__)
|
| 10 |
|
| 11 |
router = APIRouter()
|
| 12 |
|
| 13 |
-
|
| 14 |
|
| 15 |
|
| 16 |
-
def init_profiles(profiles:
|
| 17 |
-
global
|
| 18 |
-
|
| 19 |
|
| 20 |
|
| 21 |
@router.get("/profiles/{profile_id}", response_model=Profile)
|
| 22 |
async def get_profile(profile_id: str) -> Profile:
|
| 23 |
-
|
|
|
|
|
|
|
| 24 |
if profile is None:
|
| 25 |
raise HTTPException(status_code=404, detail=f"Profile not found: {profile_id}")
|
| 26 |
return profile
|
|
@@ -28,5 +31,7 @@ async def get_profile(profile_id: str) -> Profile:
|
|
| 28 |
|
| 29 |
@router.get("/profiles", response_model=list[Profile])
|
| 30 |
async def list_profiles(skip: int = 0, limit: int = 20) -> list[Profile]:
|
| 31 |
-
|
|
|
|
|
|
|
| 32 |
return all_profiles[skip : skip + limit]
|
|
|
|
| 5 |
from fastapi import APIRouter, HTTPException
|
| 6 |
|
| 7 |
from src.core.models import Profile
|
| 8 |
+
from src.core.profile_store import ProfileStore
|
| 9 |
|
| 10 |
logger = logging.getLogger(__name__)
|
| 11 |
|
| 12 |
router = APIRouter()
|
| 13 |
|
| 14 |
+
_profile_store: ProfileStore | None = None
|
| 15 |
|
| 16 |
|
| 17 |
+
def init_profiles(profiles: ProfileStore) -> None:
|
| 18 |
+
global _profile_store
|
| 19 |
+
_profile_store = profiles
|
| 20 |
|
| 21 |
|
| 22 |
@router.get("/profiles/{profile_id}", response_model=Profile)
|
| 23 |
async def get_profile(profile_id: str) -> Profile:
|
| 24 |
+
if _profile_store is None:
|
| 25 |
+
raise HTTPException(status_code=503, detail="Profile store not initialized")
|
| 26 |
+
profile = _profile_store.get(profile_id)
|
| 27 |
if profile is None:
|
| 28 |
raise HTTPException(status_code=404, detail=f"Profile not found: {profile_id}")
|
| 29 |
return profile
|
|
|
|
| 31 |
|
| 32 |
@router.get("/profiles", response_model=list[Profile])
|
| 33 |
async def list_profiles(skip: int = 0, limit: int = 20) -> list[Profile]:
|
| 34 |
+
if _profile_store is None:
|
| 35 |
+
raise HTTPException(status_code=503, detail="Profile store not initialized")
|
| 36 |
+
all_profiles = list(_profile_store.get_all_sample().values())
|
| 37 |
return all_profiles[skip : skip + limit]
|
src/core/models.py
CHANGED
|
@@ -80,6 +80,7 @@ class ProfessionalInfo(BaseModel):
|
|
| 80 |
total_experience_years: float | None = None
|
| 81 |
industry: str | None = None
|
| 82 |
employment_type: EmploymentType | None = None
|
|
|
|
| 83 |
|
| 84 |
|
| 85 |
class Skill(BaseModel):
|
|
|
|
| 80 |
total_experience_years: float | None = None
|
| 81 |
industry: str | None = None
|
| 82 |
employment_type: EmploymentType | None = None
|
| 83 |
+
seniority_level: int | None = None
|
| 84 |
|
| 85 |
|
| 86 |
class Skill(BaseModel):
|
src/core/profile_store.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
import logging
|
| 5 |
+
from collections import OrderedDict
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
|
| 8 |
+
from src.core.config import DATA_DIR
|
| 9 |
+
from src.core.models import Profile
|
| 10 |
+
from src.ingestion.normalizer import normalize_redrob
|
| 11 |
+
|
| 12 |
+
logger = logging.getLogger(__name__)
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class ProfileStore:
|
| 16 |
+
def __init__(self, jsonl_path: Path | None = None, max_cache: int = 500) -> None:
|
| 17 |
+
self.path = jsonl_path or DATA_DIR / "profiles" / "candidates.jsonl"
|
| 18 |
+
self._max_cache = max_cache
|
| 19 |
+
self._offset_index: dict[str, int] = {}
|
| 20 |
+
self._cache: OrderedDict[str, Profile] = OrderedDict()
|
| 21 |
+
self._index_built = False
|
| 22 |
+
self._sample_profiles: dict[str, Profile] = {}
|
| 23 |
+
|
| 24 |
+
def load_sample(self, sample_path: Path) -> None:
|
| 25 |
+
if not sample_path.exists():
|
| 26 |
+
return
|
| 27 |
+
with open(sample_path) as f:
|
| 28 |
+
data = json.load(f)
|
| 29 |
+
profiles_list = data if isinstance(data, list) else [data]
|
| 30 |
+
for p in profiles_list:
|
| 31 |
+
try:
|
| 32 |
+
profile = normalize_redrob(p)
|
| 33 |
+
pid = profile.profile_id
|
| 34 |
+
self._sample_profiles[pid] = profile
|
| 35 |
+
if pid not in self._offset_index:
|
| 36 |
+
self._offset_index[pid] = -1
|
| 37 |
+
except Exception:
|
| 38 |
+
pass
|
| 39 |
+
logger.info(f"Loaded {len(self._sample_profiles)} sample profiles")
|
| 40 |
+
|
| 41 |
+
def load_offset_index(self, index_path: Path) -> None:
|
| 42 |
+
if index_path.exists():
|
| 43 |
+
with open(index_path) as f:
|
| 44 |
+
self._offset_index = json.load(f)
|
| 45 |
+
self._index_built = True
|
| 46 |
+
logger.info(f"Loaded offset index with {len(self._offset_index)} entries")
|
| 47 |
+
|
| 48 |
+
def save_offset_index(self, path: Path) -> None:
|
| 49 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 50 |
+
with open(path, "w") as f:
|
| 51 |
+
json.dump(self._offset_index, f)
|
| 52 |
+
logger.info(f"Saved offset index ({len(self._offset_index)} entries) to {path}")
|
| 53 |
+
|
| 54 |
+
def _build_offset_index(self) -> None:
|
| 55 |
+
if self._index_built:
|
| 56 |
+
return
|
| 57 |
+
if not self.path.exists():
|
| 58 |
+
logger.warning(f"Profiles file not found: {self.path}")
|
| 59 |
+
self._index_built = True
|
| 60 |
+
return
|
| 61 |
+
count = 0
|
| 62 |
+
with open(self.path, encoding="utf-8") as f:
|
| 63 |
+
while True:
|
| 64 |
+
offset = f.tell()
|
| 65 |
+
line = f.readline()
|
| 66 |
+
if not line:
|
| 67 |
+
break
|
| 68 |
+
line = line.strip()
|
| 69 |
+
if not line:
|
| 70 |
+
continue
|
| 71 |
+
try:
|
| 72 |
+
raw = json.loads(line)
|
| 73 |
+
pid = self._extract_id(raw)
|
| 74 |
+
if pid and pid not in self._offset_index:
|
| 75 |
+
self._offset_index[pid] = offset
|
| 76 |
+
count += 1
|
| 77 |
+
except json.JSONDecodeError:
|
| 78 |
+
pass
|
| 79 |
+
self._index_built = True
|
| 80 |
+
logger.info(f"Built offset index: {count} profile IDs")
|
| 81 |
+
|
| 82 |
+
@staticmethod
|
| 83 |
+
def _extract_id(raw: dict) -> str | None:
|
| 84 |
+
for key in ("profile_id", "candidate_id", "id"):
|
| 85 |
+
val = raw.get(key)
|
| 86 |
+
if val:
|
| 87 |
+
return str(val)
|
| 88 |
+
profile_nested = raw.get("profile", {})
|
| 89 |
+
if isinstance(profile_nested, dict):
|
| 90 |
+
for key in ("profile_id", "candidate_id", "id"):
|
| 91 |
+
val = profile_nested.get(key)
|
| 92 |
+
if val:
|
| 93 |
+
return str(val)
|
| 94 |
+
return None
|
| 95 |
+
|
| 96 |
+
def get(self, pid: str) -> Profile | None:
|
| 97 |
+
if pid in self._cache:
|
| 98 |
+
self._cache.move_to_end(pid)
|
| 99 |
+
return self._cache[pid]
|
| 100 |
+
|
| 101 |
+
if pid in self._sample_profiles:
|
| 102 |
+
profile = self._sample_profiles[pid]
|
| 103 |
+
if len(self._cache) >= self._max_cache:
|
| 104 |
+
self._cache.popitem(last=False)
|
| 105 |
+
self._cache[pid] = profile
|
| 106 |
+
return profile
|
| 107 |
+
|
| 108 |
+
self._build_offset_index()
|
| 109 |
+
offset = self._offset_index.get(pid)
|
| 110 |
+
if offset is None or offset < 0:
|
| 111 |
+
return None
|
| 112 |
+
|
| 113 |
+
try:
|
| 114 |
+
with open(self.path, encoding="utf-8") as f:
|
| 115 |
+
f.seek(offset)
|
| 116 |
+
line = f.readline()
|
| 117 |
+
raw = json.loads(line)
|
| 118 |
+
profile = normalize_redrob(raw)
|
| 119 |
+
except Exception:
|
| 120 |
+
return None
|
| 121 |
+
|
| 122 |
+
if len(self._cache) >= self._max_cache:
|
| 123 |
+
self._cache.popitem(last=False)
|
| 124 |
+
self._cache[pid] = profile
|
| 125 |
+
return profile
|
| 126 |
+
|
| 127 |
+
def get_all_sample(self) -> dict[str, Profile]:
|
| 128 |
+
return dict(self._sample_profiles)
|
| 129 |
+
|
| 130 |
+
def __contains__(self, pid: str) -> bool:
|
| 131 |
+
if pid in self._sample_profiles or pid in self._cache:
|
| 132 |
+
return True
|
| 133 |
+
self._build_offset_index()
|
| 134 |
+
return pid in self._offset_index
|
| 135 |
+
|
| 136 |
+
def __len__(self) -> int:
|
| 137 |
+
self._build_offset_index()
|
| 138 |
+
return len(self._offset_index)
|
src/extraction/__init__.py
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from src.extraction.pipeline import ExtractionBundle, ExtractionResult, FieldExtractorPipeline
|
| 2 |
+
|
| 3 |
+
__all__ = ["FieldExtractorPipeline", "ExtractionBundle", "ExtractionResult"]
|
src/extraction/career_history_utils.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from datetime import date, datetime
|
| 4 |
+
from typing import Any
|
| 5 |
+
|
| 6 |
+
_LOW_CONFIDENCE_THRESHOLD = 0.5
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def latest_role(history: list[dict[str, Any]]) -> dict[str, Any] | None:
|
| 10 |
+
if not history:
|
| 11 |
+
return None
|
| 12 |
+
current = [r for r in history if r.get("is_current")]
|
| 13 |
+
if current:
|
| 14 |
+
return current[0]
|
| 15 |
+
return max(
|
| 16 |
+
history,
|
| 17 |
+
key=lambda r: _parse_date_for_sort(r.get("start_date")) or date.min,
|
| 18 |
+
)
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def compute_years_from_dates(
|
| 22 |
+
history: list[dict[str, Any]],
|
| 23 |
+
) -> tuple[float | None, int, str]:
|
| 24 |
+
today = date.today()
|
| 25 |
+
total_days = 0.0
|
| 26 |
+
valid_entries = 0
|
| 27 |
+
assumptions = 0
|
| 28 |
+
|
| 29 |
+
intervals: list[tuple[date, date]] = []
|
| 30 |
+
for entry in history:
|
| 31 |
+
start = _parse_date(entry.get("start_date"))
|
| 32 |
+
if start is None:
|
| 33 |
+
continue
|
| 34 |
+
end_raw = entry.get("end_date")
|
| 35 |
+
is_current = entry.get("is_current", False)
|
| 36 |
+
if end_raw:
|
| 37 |
+
end = _parse_date(end_raw)
|
| 38 |
+
if end is None:
|
| 39 |
+
continue
|
| 40 |
+
if end > today:
|
| 41 |
+
end = today
|
| 42 |
+
assumptions += 1
|
| 43 |
+
if end <= start:
|
| 44 |
+
continue
|
| 45 |
+
elif is_current:
|
| 46 |
+
end = today
|
| 47 |
+
else:
|
| 48 |
+
end = date(start.year + 1, start.month, start.day)
|
| 49 |
+
assumptions += 1
|
| 50 |
+
intervals.append((start, end))
|
| 51 |
+
valid_entries += 1
|
| 52 |
+
|
| 53 |
+
if not intervals:
|
| 54 |
+
return None, 0, "low"
|
| 55 |
+
|
| 56 |
+
merged = _merge_intervals(intervals)
|
| 57 |
+
for s, e in merged:
|
| 58 |
+
total_days += (e - s).days
|
| 59 |
+
|
| 60 |
+
total_years = round(total_days / 365.25, 1)
|
| 61 |
+
|
| 62 |
+
if valid_entries == 0:
|
| 63 |
+
return None, 0, "low"
|
| 64 |
+
ratio = assumptions / valid_entries
|
| 65 |
+
if ratio > _LOW_CONFIDENCE_THRESHOLD:
|
| 66 |
+
confidence = "low"
|
| 67 |
+
elif ratio > 0:
|
| 68 |
+
confidence = "medium"
|
| 69 |
+
else:
|
| 70 |
+
confidence = "high"
|
| 71 |
+
|
| 72 |
+
return total_years, valid_entries, confidence
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def _parse_date(raw: str | None) -> date | None:
|
| 76 |
+
if not raw:
|
| 77 |
+
return None
|
| 78 |
+
for fmt in ("%Y-%m-%d", "%Y-%m", "%Y/%m/%d", "%Y/%m", "%d-%m-%Y", "%d/%m/%Y"):
|
| 79 |
+
try:
|
| 80 |
+
return datetime.strptime(raw.strip(), fmt).date()
|
| 81 |
+
except ValueError:
|
| 82 |
+
continue
|
| 83 |
+
try:
|
| 84 |
+
return datetime.strptime(raw.strip()[:10], "%Y-%m-%d").date()
|
| 85 |
+
except (ValueError, IndexError):
|
| 86 |
+
pass
|
| 87 |
+
try:
|
| 88 |
+
year = int(raw.strip()[:4])
|
| 89 |
+
return date(year, 1, 1)
|
| 90 |
+
except (ValueError, IndexError):
|
| 91 |
+
return None
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def _parse_date_for_sort(raw: str | None) -> date | None:
|
| 95 |
+
d = _parse_date(raw)
|
| 96 |
+
if d is not None:
|
| 97 |
+
return d
|
| 98 |
+
if raw:
|
| 99 |
+
try:
|
| 100 |
+
year = int(raw.strip()[:4])
|
| 101 |
+
return date(year, 1, 1)
|
| 102 |
+
except (ValueError, IndexError):
|
| 103 |
+
pass
|
| 104 |
+
return None
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def _merge_intervals(intervals: list[tuple[date, date]]) -> list[tuple[date, date]]:
|
| 108 |
+
sorted_iv = sorted(intervals, key=lambda x: x[0])
|
| 109 |
+
merged: list[tuple[date, date]] = []
|
| 110 |
+
for start, end in sorted_iv:
|
| 111 |
+
if merged and start <= merged[-1][1]:
|
| 112 |
+
merged[-1] = (merged[-1][0], max(merged[-1][1], end))
|
| 113 |
+
else:
|
| 114 |
+
merged.append((start, end))
|
| 115 |
+
return merged
|
src/extraction/company.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from typing import Any
|
| 4 |
+
|
| 5 |
+
from src.extraction.career_history_utils import latest_role
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def extract_company(
|
| 9 |
+
prof: dict[str, Any],
|
| 10 |
+
history: list[dict[str, Any]],
|
| 11 |
+
) -> tuple[str | None, str]:
|
| 12 |
+
direct = prof.get("current_company")
|
| 13 |
+
if direct and isinstance(direct, str) and direct.strip():
|
| 14 |
+
return direct.strip(), "direct"
|
| 15 |
+
|
| 16 |
+
latest = latest_role(history)
|
| 17 |
+
if latest:
|
| 18 |
+
company = latest.get("company")
|
| 19 |
+
if company and isinstance(company, str) and company.strip():
|
| 20 |
+
return company.strip(), "history"
|
| 21 |
+
|
| 22 |
+
return None, "not_found"
|
src/extraction/domain.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import re
|
| 4 |
+
from typing import Any
|
| 5 |
+
|
| 6 |
+
_INDUSTRY_KEYWORDS: dict[str, list[str]] = {
|
| 7 |
+
"fintech": ["fintech", "banking", "finance", "payment", "insurance", "investment"],
|
| 8 |
+
"healthcare": ["healthcare", "health", "medical", "clinical", "pharma", "biotech"],
|
| 9 |
+
"ecommerce": ["ecommerce", "e-commerce", "retail", "marketplace", "consumer internet"],
|
| 10 |
+
"ai/ml": ["machine learning", "artificial intelligence", "deep learning", "nlp",
|
| 11 |
+
"llm", "computer vision", "mlops"],
|
| 12 |
+
"edtech": ["edtech", "education", "elearning", "learning", "online education"],
|
| 13 |
+
"saas": ["saas", "b2b", "enterprise software", "cloud software"],
|
| 14 |
+
"infrastructure": ["devops", "infrastructure", "cloud", "kubernetes", "docker",
|
| 15 |
+
"terraform", "platform engineering"],
|
| 16 |
+
"data": ["data engineering", "data science", "data analytics", "big data", "data pipeline"],
|
| 17 |
+
"cybersecurity": ["cybersecurity", "security", "infosec", "penetration testing"],
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
_COMPANY_INDUSTRY: dict[str, str] = {
|
| 21 |
+
"mindtree": "it_services",
|
| 22 |
+
"infosys": "it_services",
|
| 23 |
+
"tcs": "it_services",
|
| 24 |
+
"wipro": "it_services",
|
| 25 |
+
"accenture": "it_services",
|
| 26 |
+
"google": "internet",
|
| 27 |
+
"amazon": "ecommerce",
|
| 28 |
+
"microsoft": "saas",
|
| 29 |
+
"flipkart": "ecommerce",
|
| 30 |
+
"swiggy": "ecommerce",
|
| 31 |
+
"zomato": "ecommerce",
|
| 32 |
+
"razorpay": "fintech",
|
| 33 |
+
"phonepe": "fintech",
|
| 34 |
+
"paytm": "fintech",
|
| 35 |
+
"byjus": "edtech",
|
| 36 |
+
"unacademy": "edtech",
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def extract_industry(
|
| 41 |
+
prof: dict[str, Any],
|
| 42 |
+
skills: list[dict[str, Any]],
|
| 43 |
+
history: list[dict[str, Any]],
|
| 44 |
+
) -> tuple[str | None, str]:
|
| 45 |
+
direct = prof.get("current_industry")
|
| 46 |
+
if direct and isinstance(direct, str) and direct.strip():
|
| 47 |
+
return direct.strip(), "direct"
|
| 48 |
+
|
| 49 |
+
for entry in history:
|
| 50 |
+
company = (entry.get("company") or "").lower().strip()
|
| 51 |
+
if company in _COMPANY_INDUSTRY:
|
| 52 |
+
return _COMPANY_INDUSTRY[company], "company_map"
|
| 53 |
+
for known_company, mapped_industry in _COMPANY_INDUSTRY.items():
|
| 54 |
+
if known_company in company:
|
| 55 |
+
return mapped_industry, "company_map"
|
| 56 |
+
|
| 57 |
+
skill_names = [s.get("name", "") for s in skills]
|
| 58 |
+
all_text = " ".join(skill_names).lower()
|
| 59 |
+
for industry, keywords in _INDUSTRY_KEYWORDS.items():
|
| 60 |
+
for keyword in keywords:
|
| 61 |
+
if keyword in all_text:
|
| 62 |
+
return industry, "skills"
|
| 63 |
+
|
| 64 |
+
headline = prof.get("headline", "")
|
| 65 |
+
summary = prof.get("summary", "")
|
| 66 |
+
combined = f"{headline} {summary}".lower()
|
| 67 |
+
for industry, keywords in _INDUSTRY_KEYWORDS.items():
|
| 68 |
+
for keyword in keywords:
|
| 69 |
+
if keyword in combined:
|
| 70 |
+
return industry, "headline_summary"
|
| 71 |
+
|
| 72 |
+
return None, "not_found"
|
src/extraction/experience_years.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import re
|
| 4 |
+
from typing import Any
|
| 5 |
+
|
| 6 |
+
from src.extraction.career_history_utils import compute_years_from_dates
|
| 7 |
+
|
| 8 |
+
_YEARS_PATTERNS: list[re.Pattern[str]] = [
|
| 9 |
+
re.compile(r"(\d+)\+?\s*(?:yrs?|years?|yoe|years?\s+of\s+experience)", re.IGNORECASE),
|
| 10 |
+
re.compile(r"(\d+)\+?\s*(?:years?\s+exp)", re.IGNORECASE),
|
| 11 |
+
re.compile(r"experience[:\s]+(\d+)\+?\s*(?:years?|yrs?)", re.IGNORECASE),
|
| 12 |
+
re.compile(r"(\d+)\s*\+\s*years?", re.IGNORECASE),
|
| 13 |
+
re.compile(r"~?\s*(\d+)\s*(?:years?|yrs?)", re.IGNORECASE),
|
| 14 |
+
]
|
| 15 |
+
|
| 16 |
+
_AGREEMENT_RATIO = 0.2
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def extract_experience_years(
|
| 20 |
+
prof: dict[str, Any],
|
| 21 |
+
history: list[dict[str, Any]],
|
| 22 |
+
) -> tuple[float | None, str]:
|
| 23 |
+
direct = _safe_float(prof.get("years_of_experience"))
|
| 24 |
+
|
| 25 |
+
dates_result, num_valid, dates_conf = compute_years_from_dates(history)
|
| 26 |
+
|
| 27 |
+
source_a = direct
|
| 28 |
+
source_b = dates_result
|
| 29 |
+
|
| 30 |
+
if source_a is not None and source_b is not None:
|
| 31 |
+
if abs(source_a - source_b) / max(source_a, source_b, 0.1) <= _AGREEMENT_RATIO:
|
| 32 |
+
return round((source_a + source_b) / 2, 1), "average"
|
| 33 |
+
if dates_conf == "high":
|
| 34 |
+
return source_b, "dates_structured"
|
| 35 |
+
return source_a, "direct_structured"
|
| 36 |
+
|
| 37 |
+
if source_a is not None:
|
| 38 |
+
return source_a, "direct_structured"
|
| 39 |
+
|
| 40 |
+
if source_b is not None:
|
| 41 |
+
return source_b, "dates_structured"
|
| 42 |
+
|
| 43 |
+
regex_val = _extract_from_text(prof.get("headline", ""), prof.get("summary", ""))
|
| 44 |
+
if regex_val is not None:
|
| 45 |
+
return regex_val, "regex_fallback"
|
| 46 |
+
|
| 47 |
+
return None, "not_found"
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def _extract_from_text(*texts: str) -> float | None:
|
| 51 |
+
for text in texts:
|
| 52 |
+
if not text:
|
| 53 |
+
continue
|
| 54 |
+
for pattern in _YEARS_PATTERNS:
|
| 55 |
+
m = pattern.search(text)
|
| 56 |
+
if m:
|
| 57 |
+
val = float(m.group(1))
|
| 58 |
+
if val >= 100:
|
| 59 |
+
continue
|
| 60 |
+
return val
|
| 61 |
+
return None
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def _safe_float(val: Any) -> float | None:
|
| 65 |
+
if val is None:
|
| 66 |
+
return None
|
| 67 |
+
try:
|
| 68 |
+
f = float(val)
|
| 69 |
+
if f > 100:
|
| 70 |
+
return None
|
| 71 |
+
return f
|
| 72 |
+
except (ValueError, TypeError):
|
| 73 |
+
return None
|
src/extraction/pipeline.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from dataclasses import dataclass, field
|
| 4 |
+
from typing import Any
|
| 5 |
+
|
| 6 |
+
from src.extraction.company import extract_company
|
| 7 |
+
from src.extraction.domain import extract_industry
|
| 8 |
+
from src.extraction.experience_years import extract_experience_years
|
| 9 |
+
from src.extraction.seniority import extract_seniority
|
| 10 |
+
from src.extraction.title import extract_title
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
@dataclass
|
| 14 |
+
class ExtractionResult:
|
| 15 |
+
value: Any
|
| 16 |
+
source: str
|
| 17 |
+
confidence: str = "medium"
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
@dataclass
|
| 21 |
+
class ExtractionBundle:
|
| 22 |
+
current_title: ExtractionResult = field(default_factory=lambda: ExtractionResult(None, "not_found"))
|
| 23 |
+
current_company: ExtractionResult = field(default_factory=lambda: ExtractionResult(None, "not_found"))
|
| 24 |
+
total_experience_years: ExtractionResult = field(default_factory=lambda: ExtractionResult(None, "not_found"))
|
| 25 |
+
industry: ExtractionResult = field(default_factory=lambda: ExtractionResult(None, "not_found"))
|
| 26 |
+
seniority_level: ExtractionResult = field(default_factory=lambda: ExtractionResult(None, "not_found"))
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
class FieldExtractorPipeline:
|
| 30 |
+
def extract(self, raw: dict[str, Any]) -> ExtractionBundle:
|
| 31 |
+
prof = raw.get("profile", {})
|
| 32 |
+
history = raw.get("career_history", [])
|
| 33 |
+
skills = raw.get("skills", [])
|
| 34 |
+
|
| 35 |
+
title_val, title_src = extract_title(prof, history)
|
| 36 |
+
company_val, company_src = extract_company(prof, history)
|
| 37 |
+
years_val, years_src = extract_experience_years(prof, history)
|
| 38 |
+
industry_val, industry_src = extract_industry(prof, skills, history)
|
| 39 |
+
seniority_val, seniority_src = extract_seniority(title_val, years_val, history)
|
| 40 |
+
|
| 41 |
+
return ExtractionBundle(
|
| 42 |
+
current_title=ExtractionResult(title_val, title_src),
|
| 43 |
+
current_company=ExtractionResult(company_val, company_src),
|
| 44 |
+
total_experience_years=ExtractionResult(years_val, years_src),
|
| 45 |
+
industry=ExtractionResult(industry_val, industry_src),
|
| 46 |
+
seniority_level=ExtractionResult(seniority_val, seniority_src),
|
| 47 |
+
)
|
src/extraction/seniority.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from typing import Any
|
| 4 |
+
|
| 5 |
+
from src.extraction.career_history_utils import latest_role
|
| 6 |
+
|
| 7 |
+
_SENIORITY_TITLES: dict[str, int] = {
|
| 8 |
+
"intern": 0,
|
| 9 |
+
"trainee": 0,
|
| 10 |
+
"junior": 1,
|
| 11 |
+
"jr": 1,
|
| 12 |
+
"mid": 2,
|
| 13 |
+
"mid-level": 2,
|
| 14 |
+
"senior": 3,
|
| 15 |
+
"sr": 3,
|
| 16 |
+
"lead": 4,
|
| 17 |
+
"staff": 4,
|
| 18 |
+
"principal": 5,
|
| 19 |
+
"architect": 5,
|
| 20 |
+
"director": 6,
|
| 21 |
+
"head": 6,
|
| 22 |
+
"vp": 6,
|
| 23 |
+
"chief": 6,
|
| 24 |
+
"cto": 6,
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
_YEARS_JUNIOR = 2
|
| 28 |
+
_YEARS_MID = 5
|
| 29 |
+
_YEARS_SENIOR = 8
|
| 30 |
+
_YEARS_LEAD = 12
|
| 31 |
+
|
| 32 |
+
_DOMAIN_OVERRIDES: dict[str, int] = {
|
| 33 |
+
"professor": 6,
|
| 34 |
+
"assistant professor": 5,
|
| 35 |
+
"associate professor": 6,
|
| 36 |
+
"lecturer": 3,
|
| 37 |
+
"principal": 5,
|
| 38 |
+
"director": 6,
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
DOMAIN_SPECIFIC_CONSTANTS_NOTE = """
|
| 42 |
+
Seniority thresholds (<2 junior, <5 mid, <8 senior, <12 lead) are
|
| 43 |
+
tuned for the Indian IT market — the primary data source. These
|
| 44 |
+
constants should be revisited if the normalizer is used for
|
| 45 |
+
academia, government, or other sectors.
|
| 46 |
+
"""
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def extract_seniority(
|
| 50 |
+
title: str | None,
|
| 51 |
+
years: float | None,
|
| 52 |
+
history: list[dict[str, Any]],
|
| 53 |
+
) -> tuple[int | None, str]:
|
| 54 |
+
title_lower = (title or "").lower()
|
| 55 |
+
|
| 56 |
+
for keyword, level in _DOMAIN_OVERRIDES.items():
|
| 57 |
+
if keyword in title_lower:
|
| 58 |
+
return level, "domain_override"
|
| 59 |
+
|
| 60 |
+
for keyword, level in _SENIORITY_TITLES.items():
|
| 61 |
+
if keyword in title_lower:
|
| 62 |
+
return level, "title_keyword"
|
| 63 |
+
|
| 64 |
+
latest = latest_role(history)
|
| 65 |
+
if latest:
|
| 66 |
+
latest_title = (latest.get("title") or "").lower()
|
| 67 |
+
for keyword, level in _SENIORITY_TITLES.items():
|
| 68 |
+
if keyword in latest_title:
|
| 69 |
+
return level, "history_title"
|
| 70 |
+
|
| 71 |
+
if years is not None:
|
| 72 |
+
if years < _YEARS_JUNIOR:
|
| 73 |
+
return 1, "years_fallback"
|
| 74 |
+
if years < _YEARS_MID:
|
| 75 |
+
return 2, "years_fallback"
|
| 76 |
+
if years < _YEARS_SENIOR:
|
| 77 |
+
return 3, "years_fallback"
|
| 78 |
+
if years < _YEARS_LEAD:
|
| 79 |
+
return 4, "years_fallback"
|
| 80 |
+
return 5, "years_fallback"
|
| 81 |
+
|
| 82 |
+
return None, "not_found"
|
src/extraction/title.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import re
|
| 4 |
+
from typing import Any
|
| 5 |
+
|
| 6 |
+
from src.extraction.career_history_utils import latest_role
|
| 7 |
+
|
| 8 |
+
_SENIORITY_PREFIXES = [
|
| 9 |
+
"senior", "sr", "junior", "jr", "lead", "staff",
|
| 10 |
+
"principal", "chief", "head", "vp", "vp of", "director of",
|
| 11 |
+
"associate", "assistant", "principal",
|
| 12 |
+
]
|
| 13 |
+
|
| 14 |
+
_SENIORITY_PATTERN = re.compile(
|
| 15 |
+
r"^(?:" + "|".join(_SENIORITY_PREFIXES) + r")\s+",
|
| 16 |
+
re.IGNORECASE,
|
| 17 |
+
)
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def extract_title(
|
| 21 |
+
prof: dict[str, Any],
|
| 22 |
+
history: list[dict[str, Any]],
|
| 23 |
+
) -> tuple[str | None, str]:
|
| 24 |
+
direct = prof.get("current_title")
|
| 25 |
+
if direct and isinstance(direct, str) and direct.strip():
|
| 26 |
+
return direct.strip(), "direct"
|
| 27 |
+
|
| 28 |
+
latest = latest_role(history)
|
| 29 |
+
if latest:
|
| 30 |
+
title = latest.get("title")
|
| 31 |
+
if title and isinstance(title, str) and title.strip():
|
| 32 |
+
return title.strip(), "history"
|
| 33 |
+
|
| 34 |
+
headline = prof.get("headline", "")
|
| 35 |
+
if headline and isinstance(headline, str) and headline.strip():
|
| 36 |
+
extracted = _parse_headline(headline)
|
| 37 |
+
if extracted:
|
| 38 |
+
return extracted, "headline"
|
| 39 |
+
|
| 40 |
+
return None, "not_found"
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def _parse_headline(headline: str) -> str | None:
|
| 44 |
+
candidate = headline.strip()
|
| 45 |
+
if "|" in candidate:
|
| 46 |
+
candidate = candidate.split("|")[0].strip()
|
| 47 |
+
if " at " in candidate.lower():
|
| 48 |
+
candidate = candidate.lower().split(" at ")[0].strip().title()
|
| 49 |
+
candidate = _SENIORITY_PATTERN.sub("", candidate).strip()
|
| 50 |
+
if candidate:
|
| 51 |
+
return candidate
|
| 52 |
+
return None
|
src/ingestion/normalizer.py
CHANGED
|
@@ -16,6 +16,7 @@ from src.core.models import (
|
|
| 16 |
Skill,
|
| 17 |
WorkExperience,
|
| 18 |
)
|
|
|
|
| 19 |
|
| 20 |
_PROFICIENCY_MAP = {
|
| 21 |
"beginner": ProficiencyLevel.BEGINNER,
|
|
@@ -59,12 +60,15 @@ def normalize_redrob(raw: dict[str, Any], source: str = "redrob") -> Profile:
|
|
| 59 |
native_language=None,
|
| 60 |
)
|
| 61 |
|
|
|
|
|
|
|
| 62 |
professional = ProfessionalInfo(
|
| 63 |
-
current_title=prof.get("current_title"),
|
| 64 |
-
current_company=prof.get("current_company"),
|
| 65 |
-
total_experience_years=prof.get("years_of_experience"),
|
| 66 |
-
industry=prof.get("current_industry"),
|
| 67 |
employment_type=None,
|
|
|
|
| 68 |
)
|
| 69 |
|
| 70 |
skills = [
|
|
@@ -91,7 +95,7 @@ def normalize_redrob(raw: dict[str, Any], source: str = "redrob") -> Profile:
|
|
| 91 |
end_date=entry.get("end_date"),
|
| 92 |
is_current=entry.get("is_current", False),
|
| 93 |
description=entry.get("description", ""),
|
| 94 |
-
location=entry.get("
|
| 95 |
)
|
| 96 |
for entry in raw.get("career_history", [])
|
| 97 |
]
|
|
@@ -123,6 +127,21 @@ def normalize_redrob(raw: dict[str, Any], source: str = "redrob") -> Profile:
|
|
| 123 |
|
| 124 |
raw_text = _build_raw_text(prof, experience, skills, education, certs, languages)
|
| 125 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 126 |
if native_langs:
|
| 127 |
personal.native_language = native_langs[0]
|
| 128 |
personal.languages_spoken = languages
|
|
|
|
| 16 |
Skill,
|
| 17 |
WorkExperience,
|
| 18 |
)
|
| 19 |
+
from src.extraction.pipeline import FieldExtractorPipeline
|
| 20 |
|
| 21 |
_PROFICIENCY_MAP = {
|
| 22 |
"beginner": ProficiencyLevel.BEGINNER,
|
|
|
|
| 60 |
native_language=None,
|
| 61 |
)
|
| 62 |
|
| 63 |
+
extracted = FieldExtractorPipeline().extract(raw)
|
| 64 |
+
|
| 65 |
professional = ProfessionalInfo(
|
| 66 |
+
current_title=extracted.current_title.value or prof.get("current_title"),
|
| 67 |
+
current_company=extracted.current_company.value or prof.get("current_company"),
|
| 68 |
+
total_experience_years=extracted.total_experience_years.value or prof.get("years_of_experience"),
|
| 69 |
+
industry=extracted.industry.value or prof.get("current_industry"),
|
| 70 |
employment_type=None,
|
| 71 |
+
seniority_level=extracted.seniority_level.value,
|
| 72 |
)
|
| 73 |
|
| 74 |
skills = [
|
|
|
|
| 95 |
end_date=entry.get("end_date"),
|
| 96 |
is_current=entry.get("is_current", False),
|
| 97 |
description=entry.get("description", ""),
|
| 98 |
+
location=entry.get("location"),
|
| 99 |
)
|
| 100 |
for entry in raw.get("career_history", [])
|
| 101 |
]
|
|
|
|
| 127 |
|
| 128 |
raw_text = _build_raw_text(prof, experience, skills, education, certs, languages)
|
| 129 |
|
| 130 |
+
from src.matching.skill_matcher import SKILL_ALIASES as _KNOWN_ALIASES
|
| 131 |
+
existing_skill_names = {s.name.lower() for s in skills}
|
| 132 |
+
raw_lower = raw_text.lower()
|
| 133 |
+
for _canon, _aliases in _KNOWN_ALIASES.items():
|
| 134 |
+
if _canon not in existing_skill_names and _canon in raw_lower:
|
| 135 |
+
skills.append(
|
| 136 |
+
Skill(
|
| 137 |
+
name=_canon.title(),
|
| 138 |
+
category=_infer_skill_category(_canon),
|
| 139 |
+
evidence="Extracted from profile text",
|
| 140 |
+
confidence=0.6,
|
| 141 |
+
)
|
| 142 |
+
)
|
| 143 |
+
existing_skill_names.add(_canon)
|
| 144 |
+
|
| 145 |
if native_langs:
|
| 146 |
personal.native_language = native_langs[0]
|
| 147 |
personal.languages_spoken = languages
|
src/main.py
CHANGED
|
@@ -1,6 +1,5 @@
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
-
import json
|
| 4 |
import logging
|
| 5 |
from contextlib import asynccontextmanager
|
| 6 |
|
|
@@ -15,7 +14,7 @@ from src.api.routes.profiles import router as profiles_router
|
|
| 15 |
from src.api.routes.search import init_orchestrator
|
| 16 |
from src.api.routes.search import router as search_router
|
| 17 |
from src.core.config import DATA_DIR
|
| 18 |
-
from src.core.
|
| 19 |
|
| 20 |
logging.basicConfig(level=logging.INFO)
|
| 21 |
logger = logging.getLogger(__name__)
|
|
@@ -70,41 +69,18 @@ async def lifespan(app: FastAPI):
|
|
| 70 |
logger.info("Cross-encoder model loaded")
|
| 71 |
scorer = CandidateScorer()
|
| 72 |
|
| 73 |
-
profiles
|
| 74 |
-
profiles_loaded = 0
|
| 75 |
-
|
| 76 |
-
from src.ingestion.normalizer import normalize_redrob
|
| 77 |
-
|
| 78 |
sample_path = DATA_DIR / "samples" / "sample_candidates.json"
|
| 79 |
if sample_path.exists():
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
pass
|
| 90 |
-
|
| 91 |
-
cand_path = DATA_DIR / "profiles" / "candidates.jsonl"
|
| 92 |
-
if cand_path.exists():
|
| 93 |
-
from src.ingestion.parser import ProfileParser
|
| 94 |
-
parser = ProfileParser()
|
| 95 |
-
for raw in parser.parse_jsonl_file(cand_path):
|
| 96 |
-
if raw.get("candidate_id", raw.get("id", "")) in profiles:
|
| 97 |
-
continue
|
| 98 |
-
if profiles_loaded >= vector_search.size:
|
| 99 |
-
break
|
| 100 |
-
try:
|
| 101 |
-
profile = normalize_redrob(raw)
|
| 102 |
-
profiles[profile.profile_id] = profile
|
| 103 |
-
profiles_loaded += 1
|
| 104 |
-
except Exception:
|
| 105 |
-
pass
|
| 106 |
-
|
| 107 |
-
logger.info(f"Loaded {len(profiles)} profiles into memory")
|
| 108 |
|
| 109 |
planner = PlannerAgent()
|
| 110 |
executor = ExecutorAgent(hybrid_search, reranker, scorer, profiles)
|
|
@@ -115,7 +91,7 @@ async def lifespan(app: FastAPI):
|
|
| 115 |
init_health(index_size=vector_search.size)
|
| 116 |
init_profiles(profiles)
|
| 117 |
|
| 118 |
-
logger.info("System initialized successfully")
|
| 119 |
|
| 120 |
yield
|
| 121 |
|
|
@@ -134,3 +110,7 @@ app.include_router(search_router, prefix="/api/v1")
|
|
| 134 |
app.include_router(profiles_router, prefix="/api/v1")
|
| 135 |
app.include_router(ingest_router, prefix="/api/v1")
|
| 136 |
app.include_router(health_router, prefix="/api/v1")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
|
|
|
| 3 |
import logging
|
| 4 |
from contextlib import asynccontextmanager
|
| 5 |
|
|
|
|
| 14 |
from src.api.routes.search import init_orchestrator
|
| 15 |
from src.api.routes.search import router as search_router
|
| 16 |
from src.core.config import DATA_DIR
|
| 17 |
+
from src.core.profile_store import ProfileStore
|
| 18 |
|
| 19 |
logging.basicConfig(level=logging.INFO)
|
| 20 |
logger = logging.getLogger(__name__)
|
|
|
|
| 69 |
logger.info("Cross-encoder model loaded")
|
| 70 |
scorer = CandidateScorer()
|
| 71 |
|
| 72 |
+
profiles = ProfileStore()
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
sample_path = DATA_DIR / "samples" / "sample_candidates.json"
|
| 74 |
if sample_path.exists():
|
| 75 |
+
profiles.load_sample(sample_path)
|
| 76 |
+
|
| 77 |
+
offset_index_path = DATA_DIR / "indexes" / "offset_index.json"
|
| 78 |
+
if offset_index_path.exists():
|
| 79 |
+
profiles.load_offset_index(offset_index_path)
|
| 80 |
+
logger.info(f"ProfileStore ready: {len(profiles)} profiles available")
|
| 81 |
+
else:
|
| 82 |
+
logger.info("No offset index found — profiles will be indexed on first access")
|
| 83 |
+
logger.info("ProfileStore initialized (lazy load)")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 84 |
|
| 85 |
planner = PlannerAgent()
|
| 86 |
executor = ExecutorAgent(hybrid_search, reranker, scorer, profiles)
|
|
|
|
| 91 |
init_health(index_size=vector_search.size)
|
| 92 |
init_profiles(profiles)
|
| 93 |
|
| 94 |
+
logger.info("System initialized successfully (%.1fs)", 0.0)
|
| 95 |
|
| 96 |
yield
|
| 97 |
|
|
|
|
| 110 |
app.include_router(profiles_router, prefix="/api/v1")
|
| 111 |
app.include_router(ingest_router, prefix="/api/v1")
|
| 112 |
app.include_router(health_router, prefix="/api/v1")
|
| 113 |
+
|
| 114 |
+
if __name__ == "__main__":
|
| 115 |
+
import uvicorn
|
| 116 |
+
uvicorn.run("src.main:app", host="0.0.0.0", port=8000, log_level="info")
|
src/ui/app.py
CHANGED
|
@@ -6,7 +6,10 @@ import time
|
|
| 6 |
|
| 7 |
import gradio as gr
|
| 8 |
|
|
|
|
|
|
|
| 9 |
from src.core.models import MatchScores, SearchResultItem
|
|
|
|
| 10 |
from src.matching.scorer import DEFAULT_SLIDER_WEIGHTS, CandidateScorer
|
| 11 |
from src.ui.components import (
|
| 12 |
create_analytics_dashboard,
|
|
@@ -16,6 +19,53 @@ from src.ui.components import (
|
|
| 16 |
|
| 17 |
logger = logging.getLogger(__name__)
|
| 18 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
SLIDER_DIMS = [
|
| 20 |
("Skill Match", "skill_match", DEFAULT_SLIDER_WEIGHTS["skill_match"]),
|
| 21 |
("Experience", "experience_match", DEFAULT_SLIDER_WEIGHTS["experience_match"]),
|
|
|
|
| 6 |
|
| 7 |
import gradio as gr
|
| 8 |
|
| 9 |
+
from src.api.routes.search import init_orchestrator
|
| 10 |
+
from src.core.config import DATA_DIR
|
| 11 |
from src.core.models import MatchScores, SearchResultItem
|
| 12 |
+
from src.core.profile_store import ProfileStore
|
| 13 |
from src.matching.scorer import DEFAULT_SLIDER_WEIGHTS, CandidateScorer
|
| 14 |
from src.ui.components import (
|
| 15 |
create_analytics_dashboard,
|
|
|
|
| 19 |
|
| 20 |
logger = logging.getLogger(__name__)
|
| 21 |
|
| 22 |
+
indexes_dir = DATA_DIR / "indexes"
|
| 23 |
+
faiss_path = indexes_dir / "faiss_index.bin"
|
| 24 |
+
id_map_path = indexes_dir / "faiss_id_map.json"
|
| 25 |
+
bm25_path = indexes_dir / "bm25_index.pkl"
|
| 26 |
+
|
| 27 |
+
if faiss_path.exists():
|
| 28 |
+
from src.agents.executor import ExecutorAgent
|
| 29 |
+
from src.agents.orchestrator import Orchestrator
|
| 30 |
+
from src.agents.planner import PlannerAgent
|
| 31 |
+
from src.agents.reflector import ReflectorAgent
|
| 32 |
+
from src.language.multilingual import MultilingualEmbedder
|
| 33 |
+
from src.search.bm25_search import BM25Search
|
| 34 |
+
from src.search.hybrid import HybridSearch
|
| 35 |
+
from src.search.reranker import CrossEncoderReranker
|
| 36 |
+
from src.search.vector_search import VectorSearch
|
| 37 |
+
|
| 38 |
+
embedder = MultilingualEmbedder()
|
| 39 |
+
_ = embedder.model
|
| 40 |
+
|
| 41 |
+
vector_search = VectorSearch()
|
| 42 |
+
vector_search.load(faiss_path, id_map_path)
|
| 43 |
+
|
| 44 |
+
bm25_search = BM25Search()
|
| 45 |
+
bm25_search.load(bm25_path)
|
| 46 |
+
|
| 47 |
+
hybrid_search = HybridSearch(vector_search, bm25_search, embedder)
|
| 48 |
+
reranker = CrossEncoderReranker()
|
| 49 |
+
_ = reranker.model
|
| 50 |
+
scorer = CandidateScorer()
|
| 51 |
+
|
| 52 |
+
profiles = ProfileStore()
|
| 53 |
+
offset_index_path = DATA_DIR / "indexes" / "offset_index.json"
|
| 54 |
+
if offset_index_path.exists():
|
| 55 |
+
profiles.load_offset_index(offset_index_path)
|
| 56 |
+
sample_path = DATA_DIR / "samples" / "sample_candidates.json"
|
| 57 |
+
if sample_path.exists():
|
| 58 |
+
profiles.load_sample(sample_path)
|
| 59 |
+
|
| 60 |
+
planner = PlannerAgent()
|
| 61 |
+
executor = ExecutorAgent(hybrid_search, reranker, scorer, profiles)
|
| 62 |
+
reflector = ReflectorAgent()
|
| 63 |
+
orchestrator = Orchestrator(planner, executor, reflector)
|
| 64 |
+
init_orchestrator(orchestrator)
|
| 65 |
+
logger.info("Search system initialized")
|
| 66 |
+
else:
|
| 67 |
+
logger.warning("No FAISS index found. Run 'python scripts/build_indexes.py' first.")
|
| 68 |
+
|
| 69 |
SLIDER_DIMS = [
|
| 70 |
("Skill Match", "skill_match", DEFAULT_SLIDER_WEIGHTS["skill_match"]),
|
| 71 |
("Experience", "experience_match", DEFAULT_SLIDER_WEIGHTS["experience_match"]),
|
tests/test_extraction/__init__.py
ADDED
|
File without changes
|
tests/test_extraction/test_career_history_utils.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from datetime import date
|
| 4 |
+
|
| 5 |
+
from src.extraction.career_history_utils import compute_years_from_dates, latest_role, _parse_date
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
class TestLatestRole:
|
| 9 |
+
def test_empty_history(self):
|
| 10 |
+
assert latest_role([]) is None
|
| 11 |
+
|
| 12 |
+
def test_prefers_current(self):
|
| 13 |
+
history = [
|
| 14 |
+
{"title": "Backend Engineer", "company": "Mindtree", "is_current": True,
|
| 15 |
+
"start_date": "2024-01"},
|
| 16 |
+
{"title": "Junior Dev", "company": "OldCo", "is_current": False,
|
| 17 |
+
"start_date": "2022-01", "end_date": "2023-12"},
|
| 18 |
+
]
|
| 19 |
+
role = latest_role(history)
|
| 20 |
+
assert role["company"] == "Mindtree"
|
| 21 |
+
|
| 22 |
+
def test_most_recent_when_no_current(self):
|
| 23 |
+
history = [
|
| 24 |
+
{"title": "Junior Dev", "company": "OldCo", "is_current": False,
|
| 25 |
+
"start_date": "2022-01", "end_date": "2023-12"},
|
| 26 |
+
{"title": "Intern", "company": "FirstCo", "is_current": False,
|
| 27 |
+
"start_date": "2021-01", "end_date": "2021-12"},
|
| 28 |
+
]
|
| 29 |
+
role = latest_role(history)
|
| 30 |
+
assert role["company"] == "OldCo"
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
class TestComputeYearsFromDates:
|
| 34 |
+
def test_simple_non_overlapping(self):
|
| 35 |
+
history = [
|
| 36 |
+
{"start_date": "2020-01", "end_date": "2022-01", "is_current": False},
|
| 37 |
+
{"start_date": "2022-02", "end_date": "2024-02", "is_current": False},
|
| 38 |
+
]
|
| 39 |
+
years, count, conf = compute_years_from_dates(history)
|
| 40 |
+
assert count == 2
|
| 41 |
+
assert conf == "high"
|
| 42 |
+
assert years is not None and 3.9 < years < 4.1
|
| 43 |
+
|
| 44 |
+
def test_current_role_uses_today(self):
|
| 45 |
+
history = [
|
| 46 |
+
{"start_date": "2022-01", "is_current": True},
|
| 47 |
+
]
|
| 48 |
+
years, count, conf = compute_years_from_dates(history)
|
| 49 |
+
assert count == 1
|
| 50 |
+
assert conf == "high"
|
| 51 |
+
assert years is not None and years > 0
|
| 52 |
+
|
| 53 |
+
def test_missing_end_date_assumes_one_year(self):
|
| 54 |
+
history = [
|
| 55 |
+
{"start_date": "2023-01", "is_current": False},
|
| 56 |
+
]
|
| 57 |
+
years, count, conf = compute_years_from_dates(history)
|
| 58 |
+
assert count == 1
|
| 59 |
+
assert conf == "low"
|
| 60 |
+
assert years is not None and 0.9 < years < 1.1
|
| 61 |
+
|
| 62 |
+
def test_future_date_capped_to_today(self):
|
| 63 |
+
history = [
|
| 64 |
+
{"start_date": "2020-01", "end_date": "2030-01", "is_current": False},
|
| 65 |
+
]
|
| 66 |
+
years, count, conf = compute_years_from_dates(history)
|
| 67 |
+
assert count == 1
|
| 68 |
+
assert conf == "low"
|
| 69 |
+
|
| 70 |
+
def test_swapped_dates_skipped(self):
|
| 71 |
+
history = [
|
| 72 |
+
{"start_date": "2024-01", "end_date": "2022-01", "is_current": False},
|
| 73 |
+
]
|
| 74 |
+
years, count, conf = compute_years_from_dates(history)
|
| 75 |
+
assert years is None
|
| 76 |
+
|
| 77 |
+
def test_missing_start_date_skipped(self):
|
| 78 |
+
history = [
|
| 79 |
+
{"end_date": "2024-01", "is_current": False},
|
| 80 |
+
]
|
| 81 |
+
years, count, conf = compute_years_from_dates(history)
|
| 82 |
+
assert years is None
|
| 83 |
+
|
| 84 |
+
def test_overlapping_interval_merged(self):
|
| 85 |
+
history = [
|
| 86 |
+
{"start_date": "2020-01", "end_date": "2023-06", "is_current": False},
|
| 87 |
+
{"start_date": "2023-01", "end_date": "2024-01", "is_current": False},
|
| 88 |
+
]
|
| 89 |
+
years, count, conf = compute_years_from_dates(history)
|
| 90 |
+
assert count == 2
|
| 91 |
+
assert years is not None and 3.9 < years < 4.1
|
| 92 |
+
|
| 93 |
+
def test_empty_history_returns_none(self):
|
| 94 |
+
years, count, conf = compute_years_from_dates([])
|
| 95 |
+
assert years is None
|
| 96 |
+
|
| 97 |
+
def test_low_confidence_when_mostly_assumptions(self):
|
| 98 |
+
history = [
|
| 99 |
+
{"start_date": "2020-01", "is_current": False},
|
| 100 |
+
{"start_date": "2021-01", "end_date": "2022-01", "is_current": False},
|
| 101 |
+
]
|
| 102 |
+
years, count, conf = compute_years_from_dates(history)
|
| 103 |
+
assert conf == "medium"
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
class TestParseDate:
|
| 107 |
+
def test_iso_date(self):
|
| 108 |
+
assert _parse_date("2024-01-15") == date(2024, 1, 15)
|
| 109 |
+
|
| 110 |
+
def test_year_month(self):
|
| 111 |
+
assert _parse_date("2024-01") == date(2024, 1, 1)
|
| 112 |
+
|
| 113 |
+
def test_year_only(self):
|
| 114 |
+
assert _parse_date("2024") == date(2024, 1, 1)
|
| 115 |
+
|
| 116 |
+
def test_invalid_returns_none(self):
|
| 117 |
+
assert _parse_date("not-a-date") is None
|
| 118 |
+
|
| 119 |
+
def test_none_returns_none(self):
|
| 120 |
+
assert _parse_date(None) is None
|
tests/test_extraction/test_company.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from src.extraction.company import extract_company
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class TestExtractCompany:
|
| 7 |
+
def test_direct(self):
|
| 8 |
+
val, src = extract_company(
|
| 9 |
+
{"current_company": "Mindtree"}, [],
|
| 10 |
+
)
|
| 11 |
+
assert val == "Mindtree"
|
| 12 |
+
assert src == "direct"
|
| 13 |
+
|
| 14 |
+
def test_fallback_to_history(self):
|
| 15 |
+
val, src = extract_company(
|
| 16 |
+
{},
|
| 17 |
+
[{"company": "Mindtree", "start_date": "2020-01", "is_current": True}],
|
| 18 |
+
)
|
| 19 |
+
assert val == "Mindtree"
|
| 20 |
+
assert src == "history"
|
| 21 |
+
|
| 22 |
+
def test_prefers_current_role(self):
|
| 23 |
+
val, src = extract_company(
|
| 24 |
+
{"current_company": "Mindtree"},
|
| 25 |
+
[{"company": "OldCo", "start_date": "2020-01", "is_current": False}],
|
| 26 |
+
)
|
| 27 |
+
assert val == "Mindtree"
|
| 28 |
+
assert src == "direct"
|
| 29 |
+
|
| 30 |
+
def test_no_info_returns_none(self):
|
| 31 |
+
val, src = extract_company({}, [])
|
| 32 |
+
assert val is None
|
| 33 |
+
assert src == "not_found"
|
tests/test_extraction/test_domain.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from src.extraction.domain import extract_industry
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class TestExtractIndustry:
|
| 7 |
+
def test_direct(self):
|
| 8 |
+
val, src = extract_industry(
|
| 9 |
+
{"current_industry": "fintech"}, [], [],
|
| 10 |
+
)
|
| 11 |
+
assert val == "fintech"
|
| 12 |
+
assert src == "direct"
|
| 13 |
+
|
| 14 |
+
def test_from_company_map(self):
|
| 15 |
+
val, src = extract_industry(
|
| 16 |
+
{},
|
| 17 |
+
[],
|
| 18 |
+
[{"company": "Razorpay"}],
|
| 19 |
+
)
|
| 20 |
+
assert val == "fintech"
|
| 21 |
+
assert src == "company_map"
|
| 22 |
+
|
| 23 |
+
def test_from_skills(self):
|
| 24 |
+
val, src = extract_industry(
|
| 25 |
+
{},
|
| 26 |
+
[{"name": "NLP"}, {"name": "PyTorch"}, {"name": "Computer Vision"}],
|
| 27 |
+
[],
|
| 28 |
+
)
|
| 29 |
+
assert val == "ai/ml"
|
| 30 |
+
assert src == "skills"
|
| 31 |
+
|
| 32 |
+
def test_from_headline(self):
|
| 33 |
+
val, src = extract_industry(
|
| 34 |
+
{"headline": "Fintech Engineer at a payments startup"},
|
| 35 |
+
[],
|
| 36 |
+
[],
|
| 37 |
+
)
|
| 38 |
+
assert val == "fintech"
|
| 39 |
+
assert src == "headline_summary"
|
| 40 |
+
|
| 41 |
+
def test_no_info_returns_none(self):
|
| 42 |
+
val, src = extract_industry({}, [], [])
|
| 43 |
+
assert val is None
|
| 44 |
+
assert src == "not_found"
|
tests/test_extraction/test_experience_years.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from src.extraction.experience_years import extract_experience_years
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class TestExtractExperienceYears:
|
| 7 |
+
def test_direct_field(self):
|
| 8 |
+
val, src = extract_experience_years(
|
| 9 |
+
{"years_of_experience": 5.0}, [],
|
| 10 |
+
)
|
| 11 |
+
assert val == 5.0
|
| 12 |
+
assert src == "direct_structured"
|
| 13 |
+
|
| 14 |
+
def test_dates_from_history(self):
|
| 15 |
+
val, src = extract_experience_years(
|
| 16 |
+
{},
|
| 17 |
+
[
|
| 18 |
+
{"start_date": "2020-01", "end_date": "2022-01", "is_current": False},
|
| 19 |
+
{"start_date": "2022-02", "end_date": "2024-02", "is_current": False},
|
| 20 |
+
],
|
| 21 |
+
)
|
| 22 |
+
assert src == "dates_structured"
|
| 23 |
+
assert val is not None and 3.9 < val < 4.1
|
| 24 |
+
|
| 25 |
+
def test_average_when_close(self):
|
| 26 |
+
val, src = extract_experience_years(
|
| 27 |
+
{"years_of_experience": 4.0},
|
| 28 |
+
[
|
| 29 |
+
{"start_date": "2020-06", "end_date": "2024-06", "is_current": False},
|
| 30 |
+
],
|
| 31 |
+
)
|
| 32 |
+
assert src == "average"
|
| 33 |
+
assert val is not None and 3.9 < val < 4.1
|
| 34 |
+
|
| 35 |
+
def test_dates_preferred_when_disagree_and_confident(self):
|
| 36 |
+
val, src = extract_experience_years(
|
| 37 |
+
{"years_of_experience": 10.0},
|
| 38 |
+
[
|
| 39 |
+
{"start_date": "2023-01", "end_date": "2024-01", "is_current": False},
|
| 40 |
+
],
|
| 41 |
+
)
|
| 42 |
+
assert src == "dates_structured"
|
| 43 |
+
assert val is not None and 0.9 < val < 1.1
|
| 44 |
+
|
| 45 |
+
def test_regex_fallback_from_headline(self):
|
| 46 |
+
val, src = extract_experience_years(
|
| 47 |
+
{"headline": "Senior Dev with 8+ years experience"},
|
| 48 |
+
[],
|
| 49 |
+
)
|
| 50 |
+
assert val == 8.0
|
| 51 |
+
assert src == "regex_fallback"
|
| 52 |
+
|
| 53 |
+
def test_regex_fallback_from_summary(self):
|
| 54 |
+
val, src = extract_experience_years(
|
| 55 |
+
{"summary": "Experience: 12 years in software engineering"},
|
| 56 |
+
[],
|
| 57 |
+
)
|
| 58 |
+
assert val == 12.0
|
| 59 |
+
assert src == "regex_fallback"
|
| 60 |
+
|
| 61 |
+
def test_typo_100_plus_years_ignored(self):
|
| 62 |
+
val, src = extract_experience_years(
|
| 63 |
+
{"headline": "100+ years of experience"},
|
| 64 |
+
[],
|
| 65 |
+
)
|
| 66 |
+
assert val is None
|
| 67 |
+
assert src == "not_found"
|
| 68 |
+
|
| 69 |
+
def test_typo_150_years_ignored(self):
|
| 70 |
+
val, src = extract_experience_years(
|
| 71 |
+
{"years_of_experience": 150},
|
| 72 |
+
[],
|
| 73 |
+
)
|
| 74 |
+
assert val is None
|
| 75 |
+
assert src == "not_found"
|
| 76 |
+
|
| 77 |
+
def test_no_info_returns_none(self):
|
| 78 |
+
val, src = extract_experience_years({}, [])
|
| 79 |
+
assert val is None
|
| 80 |
+
assert src == "not_found"
|
tests/test_extraction/test_pipeline.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from src.extraction.pipeline import FieldExtractorPipeline
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class TestPipeline:
|
| 7 |
+
def test_full_profile(self):
|
| 8 |
+
raw = {
|
| 9 |
+
"candidate_id": "CAND_001",
|
| 10 |
+
"profile": {
|
| 11 |
+
"anonymized_name": "Test User",
|
| 12 |
+
"current_title": "Senior Backend Engineer",
|
| 13 |
+
"current_company": "Mindtree",
|
| 14 |
+
"years_of_experience": 6.5,
|
| 15 |
+
"current_industry": "it_services",
|
| 16 |
+
"location": "Bangalore, Karnataka",
|
| 17 |
+
"country": "India",
|
| 18 |
+
"headline": "Senior Backend Engineer | Mindtree",
|
| 19 |
+
},
|
| 20 |
+
"career_history": [
|
| 21 |
+
{"title": "Backend Engineer", "company": "Mindtree",
|
| 22 |
+
"start_date": "2024-01", "is_current": True},
|
| 23 |
+
],
|
| 24 |
+
"skills": [
|
| 25 |
+
{"name": "Python", "proficiency": "advanced"},
|
| 26 |
+
{"name": "AWS", "proficiency": "intermediate"},
|
| 27 |
+
],
|
| 28 |
+
}
|
| 29 |
+
bundle = FieldExtractorPipeline().extract(raw)
|
| 30 |
+
assert bundle.current_title.value == "Senior Backend Engineer"
|
| 31 |
+
assert bundle.current_title.source == "direct"
|
| 32 |
+
assert bundle.current_company.value == "Mindtree"
|
| 33 |
+
assert bundle.current_company.source == "direct"
|
| 34 |
+
assert bundle.total_experience_years.value is not None
|
| 35 |
+
assert bundle.industry.value == "it_services"
|
| 36 |
+
assert bundle.industry.source == "direct"
|
| 37 |
+
assert bundle.seniority_level.value == 3
|
| 38 |
+
|
| 39 |
+
def test_sparse_profile_falls_back_gracefully(self):
|
| 40 |
+
raw = {
|
| 41 |
+
"candidate_id": "CAND_002",
|
| 42 |
+
"profile": {
|
| 43 |
+
"anonymized_name": "Sparse User",
|
| 44 |
+
"headline": "Python Developer | 5+ yrs experience",
|
| 45 |
+
},
|
| 46 |
+
"career_history": [],
|
| 47 |
+
"skills": [],
|
| 48 |
+
}
|
| 49 |
+
bundle = FieldExtractorPipeline().extract(raw)
|
| 50 |
+
assert bundle.current_title.value is not None
|
| 51 |
+
assert bundle.current_title.source == "headline"
|
| 52 |
+
assert bundle.current_company.value is None
|
| 53 |
+
assert bundle.total_experience_years.value == 5.0
|
| 54 |
+
assert bundle.total_experience_years.source == "regex_fallback"
|
| 55 |
+
assert bundle.industry.value is None
|
| 56 |
+
assert bundle.seniority_level.value is not None
|
| 57 |
+
|
| 58 |
+
def test_empty_raw(self):
|
| 59 |
+
bundle = FieldExtractorPipeline().extract({})
|
| 60 |
+
assert bundle.current_title.value is None
|
| 61 |
+
assert bundle.current_company.value is None
|
| 62 |
+
assert bundle.total_experience_years.value is None
|
| 63 |
+
assert bundle.industry.value is None
|
| 64 |
+
assert bundle.seniority_level.value is None
|
tests/test_extraction/test_regression.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
|
| 6 |
+
from src.ingestion.normalizer import normalize_redrob as new_normalizer
|
| 7 |
+
|
| 8 |
+
_PROFESSIONAL_FIELDS = [
|
| 9 |
+
"current_title",
|
| 10 |
+
"current_company",
|
| 11 |
+
"total_experience_years",
|
| 12 |
+
"industry",
|
| 13 |
+
]
|
| 14 |
+
|
| 15 |
+
_SAMPLE_ROWS = [
|
| 16 |
+
{
|
| 17 |
+
"candidate_id": "CAND_001",
|
| 18 |
+
"profile": {
|
| 19 |
+
"anonymized_name": "Ira Vora",
|
| 20 |
+
"current_title": "Backend Engineer",
|
| 21 |
+
"current_company": "Mindtree",
|
| 22 |
+
"years_of_experience": 6.9,
|
| 23 |
+
"current_industry": "it_services",
|
| 24 |
+
"location": "Toronto, Ontario",
|
| 25 |
+
"country": "Canada",
|
| 26 |
+
"headline": "Backend Engineer | SQL, Spark, Cloud",
|
| 27 |
+
"summary": "Software/data professional with 6.9 yrs experience",
|
| 28 |
+
},
|
| 29 |
+
"career_history": [
|
| 30 |
+
{"title": "Backend Engineer", "company": "Mindtree",
|
| 31 |
+
"start_date": "2024-03-08", "is_current": True,
|
| 32 |
+
"description": "Implemented streaming data pipelines"},
|
| 33 |
+
{"title": "Data Engineer", "company": "OldCo",
|
| 34 |
+
"start_date": "2021-01", "end_date": "2024-02",
|
| 35 |
+
"description": "Built ETL pipelines"},
|
| 36 |
+
],
|
| 37 |
+
"education": [
|
| 38 |
+
{"institution": "LPU", "degree": "B.E.",
|
| 39 |
+
"field_of_study": "CS", "end_year": 2020, "grade": "8.24 CGPA"},
|
| 40 |
+
],
|
| 41 |
+
"skills": [
|
| 42 |
+
{"name": "NLP", "proficiency": "advanced", "endorsements": 37, "duration_months": 26},
|
| 43 |
+
{"name": "AWS", "proficiency": "beginner", "endorsements": 5, "duration_months": 8},
|
| 44 |
+
],
|
| 45 |
+
"languages": [{"language": "English", "proficiency": "professional"}],
|
| 46 |
+
},
|
| 47 |
+
{
|
| 48 |
+
"candidate_id": "CAND_002",
|
| 49 |
+
"profile": {
|
| 50 |
+
"anonymized_name": "Sparse Candidate",
|
| 51 |
+
"headline": "Senior Python Dev",
|
| 52 |
+
},
|
| 53 |
+
"career_history": [],
|
| 54 |
+
"skills": [],
|
| 55 |
+
},
|
| 56 |
+
{
|
| 57 |
+
"candidate_id": "CAND_003",
|
| 58 |
+
"profile": {
|
| 59 |
+
"anonymized_name": "Minimal",
|
| 60 |
+
},
|
| 61 |
+
"career_history": [],
|
| 62 |
+
"skills": [],
|
| 63 |
+
},
|
| 64 |
+
]
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
class TestRegressionNoDataLoss:
|
| 68 |
+
def test_all_non_none_fields_preserved(self):
|
| 69 |
+
for row in _SAMPLE_ROWS:
|
| 70 |
+
profile = new_normalizer(row)
|
| 71 |
+
for field in _PROFESSIONAL_FIELDS:
|
| 72 |
+
old_val = row.get("profile", {}).get({
|
| 73 |
+
"current_title": "current_title",
|
| 74 |
+
"current_company": "current_company",
|
| 75 |
+
"total_experience_years": "years_of_experience",
|
| 76 |
+
"industry": "current_industry",
|
| 77 |
+
}[field])
|
| 78 |
+
new_val = getattr(profile.professional, field)
|
| 79 |
+
if old_val is not None:
|
| 80 |
+
assert new_val is not None, (
|
| 81 |
+
f"{row['candidate_id']}: {field} was '{old_val}' "
|
| 82 |
+
f"in raw data but new normalizer returned None"
|
| 83 |
+
)
|
| 84 |
+
|
| 85 |
+
def test_seniority_level_is_int_when_found(self):
|
| 86 |
+
profile = new_normalizer(_SAMPLE_ROWS[0])
|
| 87 |
+
assert isinstance(profile.professional.seniority_level, int)
|
| 88 |
+
|
| 89 |
+
def test_seniority_level_can_be_none(self):
|
| 90 |
+
profile = new_normalizer(_SAMPLE_ROWS[2])
|
| 91 |
+
assert profile.professional.seniority_level is None
|
| 92 |
+
|
| 93 |
+
def test_normalizer_never_crashes(self):
|
| 94 |
+
for row in _SAMPLE_ROWS:
|
| 95 |
+
profile = new_normalizer(row)
|
| 96 |
+
assert profile.profile_id is not None
|
tests/test_extraction/test_seniority.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from src.extraction.seniority import extract_seniority
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class TestExtractSeniority:
|
| 7 |
+
def test_intern(self):
|
| 8 |
+
val, src = extract_seniority("Software Intern", None, [])
|
| 9 |
+
assert val == 0
|
| 10 |
+
|
| 11 |
+
def test_junior_title(self):
|
| 12 |
+
val, src = extract_seniority("Junior Engineer", None, [])
|
| 13 |
+
assert val == 1
|
| 14 |
+
|
| 15 |
+
def test_senior(self):
|
| 16 |
+
val, src = extract_seniority("Senior Engineer", None, [])
|
| 17 |
+
assert val == 3
|
| 18 |
+
|
| 19 |
+
def test_principal(self):
|
| 20 |
+
val, src = extract_seniority("Principal Architect", None, [])
|
| 21 |
+
assert val == 5
|
| 22 |
+
|
| 23 |
+
def test_cto(self):
|
| 24 |
+
val, src = extract_seniority("Chief Technology Officer", None, [])
|
| 25 |
+
assert val == 6
|
| 26 |
+
|
| 27 |
+
def test_years_fallback_junior(self):
|
| 28 |
+
val, src = extract_seniority(None, 1, [])
|
| 29 |
+
assert val == 1
|
| 30 |
+
assert src == "years_fallback"
|
| 31 |
+
|
| 32 |
+
def test_years_fallback_mid(self):
|
| 33 |
+
val, src = extract_seniority(None, 3, [])
|
| 34 |
+
assert val == 2
|
| 35 |
+
assert src == "years_fallback"
|
| 36 |
+
|
| 37 |
+
def test_years_fallback_senior(self):
|
| 38 |
+
val, src = extract_seniority(None, 6, [])
|
| 39 |
+
assert val == 3
|
| 40 |
+
assert src == "years_fallback"
|
| 41 |
+
|
| 42 |
+
def test_years_fallback_lead(self):
|
| 43 |
+
val, src = extract_seniority(None, 10, [])
|
| 44 |
+
assert val == 4
|
| 45 |
+
assert src == "years_fallback"
|
| 46 |
+
|
| 47 |
+
def test_years_fallback_principal(self):
|
| 48 |
+
val, src = extract_seniority(None, 15, [])
|
| 49 |
+
assert val == 5
|
| 50 |
+
assert src == "years_fallback"
|
| 51 |
+
|
| 52 |
+
def test_no_info_returns_none(self):
|
| 53 |
+
val, src = extract_seniority(None, None, [])
|
| 54 |
+
assert val is None
|
| 55 |
+
assert src == "not_found"
|
| 56 |
+
|
| 57 |
+
def test_domain_override_professor(self):
|
| 58 |
+
val, src = extract_seniority("Professor of Computer Science", None, [])
|
| 59 |
+
assert val == 6
|
| 60 |
+
assert src == "domain_override"
|
| 61 |
+
|
| 62 |
+
def test_prefers_title_over_years(self):
|
| 63 |
+
val, src = extract_seniority("Junior Dev", 10, [])
|
| 64 |
+
assert val == 1
|
| 65 |
+
assert src == "title_keyword"
|
| 66 |
+
|
| 67 |
+
def test_fallback_to_history_title(self):
|
| 68 |
+
history = [
|
| 69 |
+
{"title": "Senior Engineer", "start_date": "2020-01", "is_current": True},
|
| 70 |
+
]
|
| 71 |
+
val, src = extract_seniority(None, None, history)
|
| 72 |
+
assert val == 3
|
| 73 |
+
assert src == "history_title"
|
tests/test_extraction/test_title.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from src.extraction.title import extract_title
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class TestExtractTitle:
|
| 7 |
+
def test_direct(self):
|
| 8 |
+
val, src = extract_title(
|
| 9 |
+
{"current_title": "Backend Engineer"}, [],
|
| 10 |
+
)
|
| 11 |
+
assert val == "Backend Engineer"
|
| 12 |
+
assert src == "direct"
|
| 13 |
+
|
| 14 |
+
def test_direct_empty_string_ignored(self):
|
| 15 |
+
val, src = extract_title(
|
| 16 |
+
{"current_title": ""},
|
| 17 |
+
[{"title": "Senior Dev", "start_date": "2020-01", "is_current": True}],
|
| 18 |
+
)
|
| 19 |
+
assert val == "Senior Dev"
|
| 20 |
+
assert src == "history"
|
| 21 |
+
|
| 22 |
+
def test_fallback_to_history(self):
|
| 23 |
+
val, src = extract_title(
|
| 24 |
+
{},
|
| 25 |
+
[{"title": "Senior Backend Engineer", "start_date": "2020-01", "is_current": True}],
|
| 26 |
+
)
|
| 27 |
+
assert val == "Senior Backend Engineer"
|
| 28 |
+
assert src == "history"
|
| 29 |
+
|
| 30 |
+
def test_fallback_to_headline_with_pipe(self):
|
| 31 |
+
val, src = extract_title(
|
| 32 |
+
{"headline": "Senior Python Backend | Acme Corp"}, [],
|
| 33 |
+
)
|
| 34 |
+
assert val is not None
|
| 35 |
+
assert src == "headline"
|
| 36 |
+
|
| 37 |
+
def test_headline_no_pipe(self):
|
| 38 |
+
val, src = extract_title(
|
| 39 |
+
{"headline": "Senior Software Engineer"}, [],
|
| 40 |
+
)
|
| 41 |
+
assert src == "headline"
|
| 42 |
+
|
| 43 |
+
def test_no_info_returns_none(self):
|
| 44 |
+
val, src = extract_title({}, [])
|
| 45 |
+
assert val is None
|
| 46 |
+
assert src == "not_found"
|
| 47 |
+
|
| 48 |
+
def test_prefers_direct_over_history(self):
|
| 49 |
+
val, src = extract_title(
|
| 50 |
+
{"current_title": "Lead Engineer"},
|
| 51 |
+
[{"title": "Junior Dev", "start_date": "2020-01", "is_current": True}],
|
| 52 |
+
)
|
| 53 |
+
assert val == "Lead Engineer"
|
| 54 |
+
assert src == "direct"
|
tests/test_ingestion/test_parser.py
CHANGED
|
@@ -189,7 +189,9 @@ def test_normalizer_redrob():
|
|
| 189 |
assert profile.professional.current_company == "Flipkart"
|
| 190 |
assert profile.personal.location.city == "Bangalore"
|
| 191 |
assert profile.professional.industry == "IT Services"
|
| 192 |
-
assert profile.professional.total_experience_years
|
|
|
|
|
|
|
| 193 |
assert len(profile.skills) == 3
|
| 194 |
assert profile.skills[0].name == "Python"
|
| 195 |
assert profile.skills[0].confidence > 0.5
|
|
|
|
| 189 |
assert profile.professional.current_company == "Flipkart"
|
| 190 |
assert profile.personal.location.city == "Bangalore"
|
| 191 |
assert profile.professional.industry == "IT Services"
|
| 192 |
+
assert profile.professional.total_experience_years is not None
|
| 193 |
+
assert profile.professional.total_experience_years != 5.0
|
| 194 |
+
assert isinstance(profile.professional.seniority_level, int)
|
| 195 |
assert len(profile.skills) == 3
|
| 196 |
assert profile.skills[0].name == "Python"
|
| 197 |
assert profile.skills[0].confidence > 0.5
|