Spaces:
Sleeping
Sleeping
File size: 10,479 Bytes
1aebf33 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 | """Candidate canonicalisation.
Streams the 100k JSONL, normalises each record into a compact dict, and
optionally writes a parquet cache for fast reloads.
"""
from __future__ import annotations
import json
import re
from dataclasses import dataclass, field, asdict
from pathlib import Path
from typing import Dict, Iterator, List, Optional, Tuple
import pandas as pd
from . import config
# ---------------------------------------------------------------------------
# Canonical record
# ---------------------------------------------------------------------------
@dataclass
class CanonicalCandidate:
candidate_id: str
name: str
headline: str
summary: str
location: str
country: str
yoe: float
current_title: str
current_company: str
current_company_size: str
current_industry: str
career: List[Dict] = field(default_factory=list)
edu: List[Dict] = field(default_factory=list)
skills: List[Dict] = field(default_factory=list)
certs: List[Dict] = field(default_factory=list)
languages: List[Dict] = field(default_factory=list)
signals: Dict = field(default_factory=dict)
# Derived
text_corpus: str = ""
career_total_months: int = 0
career_overlap_months: int = 0
career_industries: List[str] = field(default_factory=list)
career_titles: List[str] = field(default_factory=list)
career_companies: List[str] = field(default_factory=list)
skill_names: List[str] = field(default_factory=list)
skill_durations_max: Dict[str, int] = field(default_factory=dict)
has_ai_skill: int = 0
# ---------------------------------------------------------------------------
# Text tokenisation (BM25 corpus)
# ---------------------------------------------------------------------------
_TOKEN_RE = re.compile(r"[a-z0-9+#\.]+")
# Pre-compile lowercasing noop; use str.translate for speed
_TR_TABLE = str.maketrans("", "", ".,;:!?'\"()[]{}<>")
_SKILL_HINT_RE = re.compile(
r"\b("
r"pytorch|tensorflow|transformers?|hugging|llm|rag|retrieval|recommend"
r"|ranking|ranker|search|embedding|vector|faiss|pinecone|weaviate|qdrant"
r"|milvus|opensearch|elasticsearch|bm25|xgboost|lightgbm|catboost"
r"|learning[\s-]?to[\s-]?rank|lambdarank|lora|qlora|peft|fine[\s-]?tun"
r"|sentence[\s-]?transformer|bge|e5|mlops|kubeflow|mlflow|triton|onnx"
r"|nlp|computer vision|cv|numpy|pandas|spark|airflow|kafka|databricks"
r"|snowflake|distributed|production|deploy|inference|eval|ndcg|mrr|map"
r"|a/b|ab test|hybrid|recsys|recommender|search relevance|rank model"
r"|promotion velocity"
r")\b",
re.IGNORECASE,
)
MAX_TEXT_CHARS = 4000 # BM25 memory control
def _tokenize(text: str) -> List[str]:
"""Unigram tokenization only. Bigrams removed because they reward
keyword-stuffing honeypot profiles that pack AI buzzwords densely."""
if not text:
return []
text = text.lower().translate(_TR_TABLE)
return _TOKEN_RE.findall(text)
def _build_text_corpus(c: Dict) -> str:
parts: List[str] = []
if c.get("headline"):
parts.append(c["headline"])
if c.get("summary"):
parts.append(c["summary"])
for ch in c.get("career_history", []):
if ch.get("title"):
parts.append(ch["title"])
if ch.get("company"):
parts.append(ch["company"])
if ch.get("description"):
parts.append(ch["description"])
for s in c.get("skills", []):
parts.append(s.get("name", ""))
for cert in c.get("certifications", []):
parts.append(cert.get("name", ""))
for lang in c.get("languages", []):
parts.append(lang.get("language", ""))
for edu in c.get("education", []):
parts.append(edu.get("degree", ""))
parts.append(edu.get("field_of_study", ""))
text = " \n ".join([p for p in parts if p])
return text[:MAX_TEXT_CHARS]
# ---------------------------------------------------------------------------
# Career timeline integrity
# ---------------------------------------------------------------------------
def _months_overlap(a_start: str, a_end: Optional[str], b_start: str, b_end: Optional[str]) -> int:
"""Approx months overlap of two (start,end) date pairs in YYYY-MM-DD."""
if not a_start or not b_start:
return 0
try:
a1 = _parse_date(a_start)
a2 = _parse_date(a_end) if a_end else _TODAY
b1 = _parse_date(b_start)
b2 = _parse_date(b_end) if b_end else _TODAY
except Exception:
return 0
if a1 > a2 or b1 > b2:
return 0
start = a1 if a1 > b1 else b1
end = a2 if a2 < b2 else b2
if end < start:
return 0
return max(0, int((end - start) // 30))
# Pre-parse today's date once.
_TODAY_DT = __import__("datetime").datetime.now()
_TODAY = _TODAY_DT.year * 12 + _TODAY_DT.month
def _parse_date(s: str) -> int:
"""Return YYYY*12+MM for a YYYY-MM-DD string. Very fast path."""
if not s or len(s) < 7:
return 0
try:
y = int(s[0:4])
m = int(s[5:7])
if not (1970 <= y <= 2099 and 1 <= m <= 12):
return 0
return y * 12 + m
except Exception:
return 0
def _career_timeline(career: List[Dict]) -> Tuple[int, int, int, int]:
"""Return (sum_months, overlap_months, has_overlap, is_impossible)."""
total = 0
overlap = 0
n_impossible = 0
n = len(career)
spans = []
for ch in career:
dur = int(ch.get("duration_months") or 0)
if dur < 0 or dur > 600:
n_impossible += 1
total += dur
a1 = _parse_date(ch.get("start_date", ""))
a2 = _parse_date(ch.get("end_date")) if ch.get("end_date") else _TODAY
is_cur = bool(ch.get("is_current"))
spans.append((a1, a2, is_cur))
# pairwise overlap of roles where at least one is not "current"
for i in range(n):
a1, a2, ac = spans[i]
if a1 == 0 or a2 == 0:
continue
for j in range(i + 1, n):
b1, b2, bc = spans[j]
if b1 == 0 or b2 == 0:
continue
if ac and bc:
continue
start = a1 if a1 > b1 else b1
end = a2 if a2 < b2 else b2
if end > start and (end - start) >= 6:
overlap += (end - start)
return total, overlap, int(overlap >= 6), n_impossible
# ---------------------------------------------------------------------------
# Canonicalisation
# ---------------------------------------------------------------------------
def canonicalise(c: Dict) -> CanonicalCandidate:
p = c.get("profile", {}) or {}
career = c.get("career_history", []) or []
skills = c.get("skills", []) or []
sig = c.get("redrob_signals", {}) or {}
cc = CanonicalCandidate(
candidate_id=c.get("candidate_id", ""),
name=p.get("anonymized_name", ""),
headline=p.get("headline", ""),
summary=p.get("summary", ""),
location=p.get("location", ""),
country=p.get("country", ""),
yoe=float(p.get("years_of_experience") or 0.0),
current_title=p.get("current_title", ""),
current_company=p.get("current_company", ""),
current_company_size=p.get("current_company_size", ""),
current_industry=p.get("current_industry", ""),
career=career,
edu=c.get("education", []) or [],
skills=skills,
certs=c.get("certifications", []) or [],
languages=c.get("languages", []) or [],
signals=sig,
)
# Text corpus
cc.text_corpus = _build_text_corpus(c)
# Career timeline metrics
total, overlap, _has_overlap, _imp = _career_timeline(career)
cc.career_total_months = total
cc.career_overlap_months = overlap
cc.career_industries = list({(ch.get("industry") or "").strip() for ch in career if ch.get("industry")})
cc.career_titles = [(ch.get("title") or "").strip() for ch in career if ch.get("title")]
cc.career_companies = [(ch.get("company") or "").strip() for ch in career if ch.get("company")]
# Skills
cc.skill_names = [s.get("name", "").strip() for s in skills if s.get("name")]
for s in skills:
n = (s.get("name") or "").strip()
d = int(s.get("duration_months") or 0)
if n:
cc.skill_durations_max[n] = max(cc.skill_durations_max.get(n, 0), d)
# has_ai_skill flag
skills_lower = {n.lower() for n in cc.skill_names}
if skills_lower & {t.lower() for t in config.STRUCTURED_GATE_TERMS}:
cc.has_ai_skill = 1
if _SKILL_HINT_RE.search(cc.text_corpus):
cc.has_ai_skill = 1
return cc
# ---------------------------------------------------------------------------
# Streaming loader
# ---------------------------------------------------------------------------
def iter_canonical(path: Optional[Path] = None) -> Iterator[CanonicalCandidate]:
"""Yield canonical records from a JSONL file.
Reads line-by-line. The 100k file is ~487MB; line-based parsing with
json.loads is ~4x faster than ijson on this file because ijson chokes
on a small fraction of records with non-standard whitespace, and the
total memory is fine since we only hold one record at a time.
"""
p = Path(path) if path else config.DEFAULT_CANDIDATES_JSONL
if not p.exists():
raise FileNotFoundError(f"candidates file not found: {p}")
with open(p, "r", encoding="utf-8", errors="replace") as f:
for line in f:
line = line.strip()
if not line:
continue
try:
yield canonicalise(json.loads(line))
except json.JSONDecodeError:
# skip malformed records
continue
def load_or_build_parquet(path: Optional[Path] = None) -> pd.DataFrame:
"""Return a pandas DataFrame of canonical candidates, building the
parquet cache on first call."""
p = Path(path) if path else config.DEFAULT_CANDIDATES_JSONL
if config.CANDIDATES_PARQUET.exists():
return pd.read_parquet(config.CANDIDATES_PARQUET)
rows: List[Dict] = []
for cc in iter_canonical(p):
rows.append(asdict(cc))
df = pd.DataFrame(rows)
df.to_parquet(config.CANDIDATES_PARQUET, index=False)
return df
if __name__ == "__main__":
df = load_or_build_parquet()
print(f"loaded {len(df):,} candidates -> {config.CANDIDATES_PARQUET}")
print("columns:", list(df.columns)[:20])
|