File size: 16,448 Bytes
a4db4ad
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
# =============================================================================
# engine/jd_parser.py β€” Job Description Parser
# =============================================================================
# Parses raw Job Description text into a structured dict that feeds directly
# into the scoring engine (matcher.py).
#
# Handles JDs from three sources:
#   1. Plain text string (copy-pasted from Naukri / LinkedIn)
#   2. .txt file
#   3. .pdf file
#
# The output schema matches the job_descriptions table in database.py exactly.
# Every field that matcher.py reads is guaranteed to be present β€” either
# extracted or set to a safe default β€” so downstream code never KeyErrors.
#
# PUBLIC API (the only functions called from outside this file):
#
#   parse_jd_text(jd_text, jd_title)  β†’ dict
#       Takes raw JD string + a title, returns complete DB-ready dict.
#
#   load_jd_from_file(file_path, jd_title)  β†’ dict
#       Reads a .txt or .pdf file, calls parse_jd_text, returns the same dict.
#
#   load_all_jds_from_folder(folder_path)  β†’ list[dict]
#       Batch-loads every .txt and .pdf from a folder.
#
# INTERNAL FUNCTIONS (do not call directly):
#   _extract_required_skills(text)
#   _extract_preferred_skills(text)
#   _extract_required_experience(text)
#   _extract_education_requirement(text)
#   _extract_jd_title_from_text(text)
#   _clean_jd_text(text)
#   _section(text, keywords)
# =============================================================================

import os
import re
import sys
import json

import pdfplumber

sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import config

from engine.extractor import (
    _SKILLS_TAXONOMY,
    _SOFT_SKILLS,
    _DEGREE_KEYWORDS,
    _find_section as _resume_section,
)


# =============================================================================
# SECTION FINDER
# =============================================================================

def _section(text: str, keywords: list) -> str:
    """
    Extract a named section from JD text.

    JD section headers are typically written as:
        "Requirements:", "Required Skills:", "What We're Looking For:"
    This handles mixed capitalisation and trailing punctuation.

    Returns the section body as a string, or empty string if not found.
    """
    lines  = text.split("\n")
    result = []
    active = False

    # A new section starts when a short line ends with : or is ALL CAPS
    section_boundary = re.compile(
        r"^(?:[A-Z][A-Za-z\s&/\-]{1,50}[:\-]|[A-Z\s]{4,40})$"
    )

    for line in lines:
        stripped = line.strip()

        # Check if this line is a header matching our target keywords
        if any(kw.lower() in stripped.lower() for kw in keywords):
            active = True
            continue

        if active:
            # Stop at the next section boundary (unless it's the first line)
            if result and section_boundary.match(stripped) and len(stripped) > 3:
                break
            result.append(line)

    return "\n".join(result).strip()


# =============================================================================
# TEXT CLEANER
# =============================================================================

def _clean_jd_text(text: str) -> str:
    """
    Normalise raw JD text for consistent downstream parsing.

    - Normalise line endings
    - Collapse 3+ blank lines to 2
    - Remove zero-width / non-printable characters
    - Preserve all meaningful content
    """
    if not text:
        return ""

    # Remove non-printable characters
    text = re.sub(r"[^\x09\x0A\x0D\x20-\x7E\u00A0-\uFFFF]", " ", text)

    # Normalise line endings
    text = text.replace("\r\n", "\n").replace("\r", "\n")

    # Strip trailing whitespace per line
    lines = [l.rstrip() for l in text.split("\n")]

    # Collapse excessive blank lines
    cleaned, blank_count = [], 0
    for line in lines:
        if line.strip() == "":
            blank_count += 1
            if blank_count <= 2:
                cleaned.append("")
        else:
            blank_count = 0
            cleaned.append(line)

    return "\n".join(cleaned).strip()


# =============================================================================
# TITLE EXTRACTION
# =============================================================================

def _extract_jd_title_from_text(text: str) -> str:
    """
    Infer a job title from the JD text when no explicit title is supplied.

    Strategy:
    1. Look for "Job Title:", "Role:", "Position:" labels
    2. Fall back to the first non-empty line (most JDs start with the role)
    3. Return "Untitled JD" if nothing sensible is found
    """
    # Pattern: "Job Title: Senior Data Analyst"
    label_match = re.search(
        r"(?:job\s+title|role|position|designation)\s*[:\-]\s*(.+)",
        text[:500], re.IGNORECASE
    )
    if label_match:
        return label_match.group(1).strip().title()

    # Fall back to first meaningful line
    for line in text.split("\n"):
        line = line.strip()
        if (line and
                3 < len(line) < 80 and
                not re.match(r"^(about|we are|company|dear|hello)", line, re.I)):
            return line.title()

    return "Untitled JD"


# =============================================================================
# SKILLS EXTRACTION
# =============================================================================

def _extract_required_skills(text: str) -> list:
    """
    Extract explicitly required skills from the JD.

    Two-pass approach:
    Pass 1 β€” look in the Requirements / Must-Have section for taxonomy matches.
    Pass 2 β€” scan the full JD text for taxonomy matches, lower confidence.

    Skills found in Pass 1 are tagged required; Pass 2 additions are treated
    as inferred (both lists are returned merged, deduped, sorted).

    Returns: list of skill strings with Title casing.
    """
    # Sections that signal required / mandatory skills
    required_section = _section(text, [
        "requirements", "required skills", "must have", "mandatory",
        "what you need", "qualifications", "technical skills",
        "skills required", "key skills", "essential",
    ])

    source = required_section if required_section else text

    found      = set()
    text_lower = source.lower()

    for skill in _SKILLS_TAXONOMY:
        pattern = r"\b" + re.escape(skill) + r"\b"
        if re.search(pattern, text_lower):
            found.add(skill.upper() if len(skill) <= 3 else skill.title())

    return sorted(found)


def _extract_preferred_skills(text: str, required: list) -> list:
    """
    Extract preferred / good-to-have skills that are NOT already in required.

    These come from sections like "Nice to Have", "Preferred", "Bonus Points".

    Returns: list of skill strings not in required.
    """
    preferred_section = _section(text, [
        "preferred", "nice to have", "good to have", "bonus",
        "desirable", "advantageous", "plus", "additional",
    ])

    if not preferred_section:
        return []

    required_lower = {s.lower() for s in required}
    found          = set()
    section_lower  = preferred_section.lower()

    for skill in _SKILLS_TAXONOMY:
        pattern = r"\b" + re.escape(skill) + r"\b"
        if re.search(pattern, section_lower):
            display = skill.upper() if len(skill) <= 3 else skill.title()
            if display.lower() not in required_lower:
                found.add(display)

    return sorted(found)


# =============================================================================
# EXPERIENCE EXTRACTION
# =============================================================================

def _extract_required_experience(text: str) -> float:
    """
    Extract the minimum years of experience required by the JD.

    Handles patterns like:
        "3+ years", "minimum 5 years", "2-4 years experience",
        "at least 3 years", "3 to 5 years"

    Returns the lower bound as a float (e.g. "3-5 years" β†’ 3.0).
    Returns 0.0 if no experience requirement is found.
    """
    patterns = [
        # "3+ years", "3 or more years"
        r"(\d+(?:\.\d+)?)\s*\+\s*years?",
        # "minimum 3 years", "at least 3 years"
        r"(?:minimum|min|at\s+least)\s+(\d+(?:\.\d+)?)\s*years?",
        # "3-5 years", "3 to 5 years" β€” take lower bound
        r"(\d+(?:\.\d+)?)\s*(?:–|-|to)\s*\d+\s*years?",
        # "3 years of experience"
        r"(\d+(?:\.\d+)?)\s*years?\s+(?:of\s+)?(?:experience|exp)",
        # "experience: 3 years"
        r"experience\s*[:\-]\s*(\d+(?:\.\d+)?)\s*years?",
    ]

    text_lower = text.lower()
    for pattern in patterns:
        match = re.search(pattern, text_lower)
        if match:
            try:
                return float(match.group(1))
            except (ValueError, IndexError):
                continue

    return 0.0


# =============================================================================
# EDUCATION EXTRACTION
# =============================================================================

def _extract_education_requirement(text: str) -> str:
    """
    Extract the minimum education qualification required by the JD.

    Returns a human-readable string like "B.Tech / M.Tech" or
    "MBA / PGDM" rather than a structured dict, since JDs express
    this in highly variable ways.

    Returns empty string if no requirement found.
    """
    edu_section = _section(text, [
        "education", "qualification", "academic", "degree",
    ])
    source = edu_section if edu_section else text[:1000]

    found = []
    source_lower = source.lower()

    for deg in sorted(_DEGREE_KEYWORDS, key=len, reverse=True):
        if re.search(r"\b" + re.escape(deg) + r"\b", source_lower):
            found.append(deg.upper() if len(deg) <= 5 else deg.title())

    # Deduplicate while preserving order
    seen, unique = set(), []
    for d in found:
        if d.lower() not in seen:
            seen.add(d.lower())
            unique.append(d)

    return " / ".join(unique[:4]) if unique else ""


# =============================================================================
# MASTER PARSER
# =============================================================================

def parse_jd_text(jd_text: str, jd_title: str = "") -> dict:
    """
    Parse raw Job Description text into a structured, DB-ready dict.

    This is the primary public function. Call this with any JD text β€”
    whether pasted from Naukri, LinkedIn, or loaded from a file β€”
    and receive a complete dict matching the job_descriptions table schema.

    All fields are always present. Extraction failures produce safe defaults
    (empty list, 0.0, empty string) so matcher.py never encounters KeyErrors.

    Args:
        jd_text  : Raw JD string. Can be noisy β€” cleaning is applied internally.
        jd_title : Human-readable job title. If empty, inferred from jd_text.

    Returns:
        {
            "jd_title"              : str,
            "jd_text"               : str,   # cleaned
            "required_skills"       : str,   # JSON list
            "preferred_skills"      : str,   # JSON list
            "required_experience"   : float,
            "education_requirement" : str,
        }

    Example:
        jd = open("data/jds/data_analyst.txt").read()
        record = parse_jd_text(jd, "Senior Data Analyst")
        jd_id  = insert_jd(record)
    """
    if not jd_text or not jd_text.strip():
        raise ValueError(
            "jd_text is empty. Provide the full text of the job description."
        )

    # Step 1 β€” Clean
    cleaned = _clean_jd_text(jd_text)

    # Step 2 β€” Title
    title = jd_title.strip() if jd_title.strip() else _extract_jd_title_from_text(cleaned)

    # Step 3 β€” Skills
    required_skills  = _extract_required_skills(cleaned)
    preferred_skills = _extract_preferred_skills(cleaned, required_skills)

    # Step 4 β€” Experience
    required_experience = _extract_required_experience(cleaned)

    # Step 5 β€” Education
    education_requirement = _extract_education_requirement(cleaned)

    return {
        "jd_title"              : title,
        "jd_text"               : cleaned,
        "required_skills"       : json.dumps(required_skills),
        "preferred_skills"      : json.dumps(preferred_skills),
        "required_experience"   : required_experience,
        "education_requirement" : education_requirement,
    }


# =============================================================================
# FILE LOADERS
# =============================================================================

def load_jd_from_file(file_path: str, jd_title: str = "") -> dict:
    """
    Load a JD from a .txt or .pdf file and parse it.

    Args:
        file_path : Absolute or relative path to the JD file.
        jd_title  : Optional title override. If empty, inferred from file
                    content or derived from the filename.

    Returns:
        Parsed JD dict β€” same schema as parse_jd_text().

    Raises:
        FileNotFoundError : If the file does not exist.
        ValueError        : If the file format is not .txt or .pdf.
    """
    ext = os.path.splitext(file_path)[1].lower()

    if ext not in (".txt", ".pdf", ".docx"):
        raise ValueError(
            f"Unsupported JD file format: '{ext}'\n"
            f"Supported formats: .txt, .pdf, .docx"
        )

    if not os.path.exists(file_path):
        raise FileNotFoundError(
            f"JD file not found: {file_path}\n"
            f"Place JD files in: {config.JD_DIR}"
        )

    # Read raw text
    if ext == ".txt":
        with open(file_path, "r", encoding="utf-8", errors="replace") as f:
            raw_text = f.read()

    elif ext == ".docx":
        try:
            from docx import Document as _DocxDocument
            doc      = _DocxDocument(file_path)
            raw_text = "\n".join(p.text for p in doc.paragraphs if p.text.strip())
        except ImportError:
            raise ImportError(
                "python-docx is required to read .docx JD files.\n"
                "Install it with: pip install python-docx"
            )

    else:  # .pdf
        pages = []
        with pdfplumber.open(file_path) as pdf:
            for page in pdf.pages:
                text = page.extract_text()
                if text:
                    pages.append(text)
        raw_text = "\n\n".join(pages)

    # Derive title from filename if not provided and not in content
    if not jd_title:
        import re as _re
        stem = os.path.splitext(os.path.basename(file_path))[0]
        stem = stem.replace("_", " ").replace("-", " ")
        # Remove trailing generic suffixes like "JD", "jd", "job description"
        stem = _re.sub(r'\s+[Jj][Dd]$', '', stem).strip()
        stem = _re.sub(r'\s+[Jj]ob\s+[Dd]escription$', '', stem, flags=_re.IGNORECASE).strip()
        jd_title = stem.title()

    return parse_jd_text(raw_text, jd_title)


def load_all_jds_from_folder(folder_path: str) -> list:
    """
    Batch-load and parse every .txt and .pdf JD from a folder.

    Files that fail to parse are included in the results with an
    "error" key so the pipeline continues without crashing.

    Args:
        folder_path : Path to folder containing JD files.

    Returns:
        List of dicts. Each dict is either a fully parsed JD record
        or an error record:
        {
            "file_name" : "data_analyst_jd.txt",
            "error"     : "FileNotFoundError: ...",
        }
    """
    if not os.path.exists(folder_path):
        raise FileNotFoundError(
            f"JD folder not found: {folder_path}\n"
            f"Create it and add .txt or .pdf JD files."
        )

    supported = sorted([
        f for f in os.listdir(folder_path)
        if os.path.splitext(f)[1].lower() in (".txt", ".pdf", ".docx")
        and not f.startswith(".")
    ])

    if not supported:
        return []

    results = []
    for fname in supported:
        fpath = os.path.join(folder_path, fname)
        try:
            record = load_jd_from_file(fpath)
            record["file_name"] = fname
            results.append(record)
        except Exception as e:
            results.append({
                "file_name": fname,
                "error"    : f"{type(e).__name__}: {e}",
            })

    return results