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])