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 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: dict[str, Profile],
28
  ) -> None:
29
  self.hybrid_search = hybrid_search
30
  self.reranker = reranker
31
  self.scorer = scorer
32
- self.profiles = profiles
 
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[:50]:
62
- profile = self.profiles.get(pid)
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.profiles.get(pid)
73
  if profile is None:
74
  continue
75
 
76
- skills_set = set(s.name.lower() for s in profile.skills)
77
- req_set = set(rs.name.lower() for rs in parsed.required_skills)
78
- pref_set = set(ps.name.lower() for ps in parsed.preferred_skills)
79
- all_req = req_set | pref_set
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 = [s.name for s in profile.skills]
102
- req_names = [rs.name for rs in parsed.required_skills]
103
- missing_skills = [n for n in req_names if n not in matched_skills]
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.profiles.get(pid)
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
- _profiles_store: dict[str, Profile] = {}
14
 
15
 
16
- def init_profiles(profiles: dict[str, Profile]) -> None:
17
- global _profiles_store
18
- _profiles_store = profiles
19
 
20
 
21
  @router.get("/profiles/{profile_id}", response_model=Profile)
22
  async def get_profile(profile_id: str) -> Profile:
23
- profile = _profiles_store.get(profile_id)
 
 
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
- all_profiles = list(_profiles_store.values())
 
 
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("industry"),
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.models import Profile
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: dict[str, Profile] = {}
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
- with open(sample_path) as f:
81
- data = json.load(f)
82
- profiles_list = data if isinstance(data, list) else [data]
83
- for p in profiles_list:
84
- try:
85
- profile = normalize_redrob(p)
86
- profiles[profile.profile_id] = profile
87
- profiles_loaded += 1
88
- except Exception:
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 == 5.0
 
 
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