File size: 10,921 Bytes
9a13e79
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
"""

Field-level normalization for candidate data.



Responsibilities:

  - Normalize emails to lowercase.

  - Normalize phone numbers to E.164 format.

  - Normalize dates to YYYY-MM.

  - Map raw skill strings to canonical skill names.

  - Apply consistent trimming and casing rules where applicable.



Normalization is deterministic: the same raw input always yields the same output.

"""

from __future__ import annotations

import copy
import re
from typing import Any

from .utils import clean_string

# ---------------------------------------------------------------------------
# Skill canonicalization β€” keys are compared case-insensitively after strip.
# ---------------------------------------------------------------------------

_SKILL_CANONICAL: dict[str, str] = {
    "cpp": "C++",
    "c plus plus": "C++",
    "js": "JavaScript",
    "py": "Python",
    "postgres": "PostgreSQL",
}

# Month abbreviations and full names β†’ zero-padded month number.
_MONTH_TO_NUM: dict[str, str] = {
    "jan": "01",
    "january": "01",
    "feb": "02",
    "february": "02",
    "mar": "03",
    "march": "03",
    "apr": "04",
    "april": "04",
    "may": "05",
    "jun": "06",
    "june": "06",
    "jul": "07",
    "july": "07",
    "aug": "08",
    "august": "08",
    "sep": "09",
    "sept": "09",
    "september": "09",
    "oct": "10",
    "october": "10",
    "nov": "11",
    "november": "11",
    "dec": "12",
    "december": "12",
}

# Already YYYY-MM or YYYY-M (single-digit month).
_ISO_YEAR_MONTH = re.compile(r"^(\d{4})-(\d{1,2})$")

# e.g. "Apr 2024", "August 2017"
_MONTH_NAME_YEAR = re.compile(
    r"^([A-Za-z]+)\s+(\d{4})$",
    re.IGNORECASE,
)

# Split "Apr 2024 - Present" or "Aug 2017 - May 2021"
_DATE_RANGE_SPLIT = re.compile(r"\s*-\s*")


def normalize_email(email: str) -> str:
    """

    Normalize an email address: trim whitespace and lowercase.



    Args:

        email: Raw email string.



    Returns:

        Normalized email, or empty string if input is blank.

    """
    if not email:
        return ""
    return email.strip().lower()


def normalize_phone(phone: str) -> str:
    """

    Normalize an Indian phone number to E.164 format (+91XXXXXXXXXX).



    Handles inputs with or without country code, spaces, and dashes.

    Non-Indian numbers that already start with '+' are digit-stripped and

    re-prefixed; unrecognizable input is returned trimmed unchanged.



    Args:

        phone: Raw phone string.



    Returns:

        E.164 phone string (e.g. ``+919876543210``), or trimmed original.

    """
    if not phone:
        return ""

    stripped = phone.strip()
    digits = re.sub(r"\D", "", stripped)

    # 10-digit Indian mobile without country code.
    if len(digits) == 10:
        return f"+91{digits}"

    # 12-digit number with leading 91 country code.
    if len(digits) == 12 and digits.startswith("91"):
        return f"+{digits}"

    # Already E.164-like with '+' prefix β€” keep digits only after '+'.
    if stripped.startswith("+"):
        return f"+{digits}" if digits else stripped

    return stripped


def normalize_skill(skill: str) -> str:
    """

    Map a single raw skill label to its canonical form.



    Unknown skills are returned trimmed with original casing preserved.



    Args:

        skill: Raw skill string.



    Returns:

        Canonical skill name.

    """
    if not skill:
        return ""

    trimmed = skill.strip()
    canonical = _SKILL_CANONICAL.get(trimmed.lower())
    return canonical if canonical is not None else trimmed


def normalize_skills(skills: list[str]) -> list[str]:
    """

    Canonicalize a skill list and remove duplicates while preserving order.



    Args:

        skills: List of raw skill strings.



    Returns:

        Deduplicated list of canonical skill names.

    """
    seen: set[str] = set()
    result: list[str] = []

    for skill in skills:
        canonical = normalize_skill(skill)
        if not canonical:
            continue
        # Case-sensitive dedup after canonicalization (C++ vs c++ resolved by map).
        if canonical in seen:
            continue
        seen.add(canonical)
        result.append(canonical)

    return result


def normalize_name(name: str) -> str:
    """

    Normalize a person name: trim, collapse whitespace, title case.



    Args:

        name: Raw full name.



    Returns:

        Normalized name string.

    """
    if not name:
        return ""
    return clean_string(name).title()


def normalize_date(date_str: str | None) -> str | None:
    """

    Normalize a single date string to YYYY-MM when possible.



    Supports ISO ``YYYY-MM``, ``Mon YYYY``, and full month names.

    Returns ``None`` for blank/``Present``/unparseable sentinel values.



    Args:

        date_str: Raw date string.



    Returns:

        ``YYYY-MM`` string, ``None`` for open-ended/present, or trimmed original.

    """
    if date_str is None:
        return None

    trimmed = date_str.strip()
    if not trimmed:
        return None

    if trimmed.lower() in {"present", "current", "now"}:
        return None

    iso_match = _ISO_YEAR_MONTH.match(trimmed)
    if iso_match:
        year, month = iso_match.groups()
        return f"{year}-{int(month):02d}"

    month_year_match = _MONTH_NAME_YEAR.match(trimmed)
    if month_year_match:
        month_token, year = month_year_match.groups()
        month_num = _MONTH_TO_NUM.get(month_token.lower())
        if month_num:
            return f"{year}-{month_num}"

    return trimmed


def normalize_dates(date_str: str) -> str:
    """

    Normalize a date or date-range string.



    Range separators (`` - ``) split the string; each part is normalized

    individually and rejoined. ``Present`` is preserved as-is.



    Examples:

        ``Apr 2024 - Present`` β†’ ``2024-04 - Present``

        ``Aug 2017 - May 2021`` β†’ ``2017-08 - 2021-05``



    Args:

        date_str: Raw date or range string.



    Returns:

        Normalized date/range string.

    """
    if not date_str:
        return ""

    trimmed = date_str.strip()
    parts = _DATE_RANGE_SPLIT.split(trimmed, maxsplit=1)

    if len(parts) == 1:
        normalized = normalize_date(parts[0])
        return normalized if normalized is not None else parts[0].strip()

    start_raw, end_raw = parts[0].strip(), parts[1].strip()

    if end_raw.lower() in {"present", "current", "now"}:
        start_norm = normalize_date(start_raw)
        start_out = start_norm if start_norm is not None else start_raw
        return f"{start_out} - Present"

    start_norm = normalize_date(start_raw)
    end_norm = normalize_date(end_raw)
    start_out = start_norm if start_norm is not None else start_raw
    end_out = end_norm if end_norm is not None else end_raw
    return f"{start_out} - {end_out}"


def _parse_date_range(date_range: str) -> tuple[str | None, str | None]:
    """

    Split a date range into normalized start_date and end_date (YYYY-MM).



    ``end_date`` is ``None`` when the range ends with Present/current.

    """
    if not date_range:
        return None, None

    parts = _DATE_RANGE_SPLIT.split(date_range.strip(), maxsplit=1)
    start = normalize_date(parts[0].strip())

    if len(parts) == 1:
        return start, None

    end_raw = parts[1].strip()
    if end_raw.lower() in {"present", "current", "now"}:
        return start, None

    return start, normalize_date(end_raw)


def normalize_candidate(raw: dict[str, Any]) -> dict[str, Any]:
    """

    Apply all normalization rules to a parsed candidate record.



    Works for both ATS JSON and resume PDF parser output shapes.

    Returns a deep copy β€” the input dict is never mutated.



    Args:

        raw: Parsed candidate dict from the parser layer.



    Returns:

        New dict with normalized field values.

    """
    candidate = copy.deepcopy(raw)

    if "full_name" in candidate and isinstance(candidate["full_name"], str):
        candidate["full_name"] = normalize_name(candidate["full_name"])

    if "email" in candidate and isinstance(candidate["email"], str):
        candidate["email"] = normalize_email(candidate["email"])

    if "phone" in candidate and isinstance(candidate["phone"], str):
        candidate["phone"] = normalize_phone(candidate["phone"])

    if "location" in candidate and isinstance(candidate["location"], str):
        candidate["location"] = clean_string(candidate["location"])

    if "skills" in candidate and isinstance(candidate["skills"], list):
        candidate["skills"] = normalize_skills(candidate["skills"])

    if "experience" in candidate and isinstance(candidate["experience"], list):
        candidate["experience"] = [
            _normalize_experience_entry(entry) for entry in candidate["experience"]
        ]

    if "education" in candidate and isinstance(candidate["education"], list):
        candidate["education"] = [
            _normalize_education_entry(entry) for entry in candidate["education"]
        ]

    return candidate


def _normalize_experience_entry(entry: dict[str, Any]) -> dict[str, Any]:
    """Normalize dates inside a single experience entry."""
    normalized = copy.deepcopy(entry)

    # ATS shape: explicit start_date / end_date fields.
    if "start_date" in normalized:
        normalized["start_date"] = normalize_date(normalized.get("start_date"))
    if "end_date" in normalized:
        normalized["end_date"] = normalize_date(normalized.get("end_date"))

    # Resume shape: combined date_range string.
    if "date_range" in normalized and isinstance(normalized["date_range"], str):
        raw_range = normalized["date_range"]
        normalized["date_range"] = normalize_dates(raw_range)
        start, end = _parse_date_range(raw_range)
        normalized["start_date"] = start
        normalized["end_date"] = end

    return normalized


def _normalize_education_entry(entry: dict[str, Any]) -> dict[str, Any]:
    """Normalize dates inside a single education entry."""
    normalized = copy.deepcopy(entry)

    if "start_date" in normalized:
        normalized["start_date"] = normalize_date(normalized.get("start_date"))
    if "end_date" in normalized:
        normalized["end_date"] = normalize_date(normalized.get("end_date"))

    if "date_range" in normalized and isinstance(normalized["date_range"], str):
        raw_range = normalized["date_range"]
        normalized["date_range"] = normalize_dates(raw_range)
        start, end = _parse_date_range(raw_range)
        normalized["start_date"] = start
        normalized["end_date"] = end

    return normalized