File size: 13,781 Bytes
c7fb8cf
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""
rules_engine.py
---------------
The heart of the system. Applies deterministic rule-based scoring to rank candidates.

SCORING WEIGHTS:
  40%  Skills Match
  20%  Experience
  15%  Education
  10%  Certifications
  10%  Projects
   5%  Bonus (location, language, hobbies)

Each component produces a score 0-100, then weighted sum gives the final score.
An explanation (selected/rejected, strengths, weaknesses) is generated for every candidate.
"""

from dataclasses import dataclass, field
from typing import List, Dict, Any


# ---------------------------------------------------------------------------
# Thresholds and constants
# ---------------------------------------------------------------------------

EDUCATION_SCORE_MAP = {
    5: 100,  # PhD
    4: 90,   # Master's
    3: 75,   # Bachelor's
    2: 50,   # Associate / Diploma
    1: 30,   # Certificate
    0: 0,    # None detected
}

WEIGHTS = {
    "skills":      0.40,
    "experience":  0.20,
    "education":   0.15,
    "certs":       0.10,
    "projects":    0.10,
    "bonus":       0.05,
}

# Minimum score to be considered "selected"
SELECTION_THRESHOLD = 50.0


# ---------------------------------------------------------------------------
# Data class for a scored candidate
# ---------------------------------------------------------------------------

@dataclass
class CandidateResult:
    rank: int
    name: str
    filename: str
    final_score: float
    match_percent: float
    status: str           # "Selected" or "Rejected"

    # Component scores (0-100 each)
    skills_score: float
    experience_score: float
    education_score: float
    certs_score: float
    projects_score: float
    bonus_score: float
    tfidf_similarity: float

    # Detail fields
    matched_skills: List[str]
    missing_skills: List[str]
    extra_skills: List[str]
    experience_years: float
    education_degree: str
    education_field: str
    certifications: List[str]
    project_count: int
    languages: List[str]
    location: str

    # Human-readable explanation
    strengths: List[str]
    weaknesses: List[str]
    summary: str


# ---------------------------------------------------------------------------
# Scoring functions
# ---------------------------------------------------------------------------

def _score_skills(skills_data: dict) -> float:
    """
    Skills score = (matched / required) * 100.
    Bonus up to +10 for extra skills, penalized if < 50% matched.
    """
    required = skills_data["required_count"]
    if required == 0:
        return 100.0  # No requirements β†’ full marks

    matched = skills_data["match_count"]
    base_score = (matched / required) * 100

    # Penalty: if fewer than 50% matched, apply a steeper deduction
    if matched / required < 0.5:
        base_score *= 0.8

    # Bonus for extra relevant skills (max +5)
    extra_bonus = min(len(skills_data.get("extra", [])) * 1.0, 5.0)
    return min(base_score + extra_bonus, 100.0)


def _score_experience(resume_years: float, required_years: float) -> float:
    """
    Experience score:
    - Perfect match or over-qualified: 100
    - Within 1 year: 80
    - Within 2 years: 60
    - Less than half required: 30
    - 0 years: 0
    """
    if required_years <= 0:
        return 80.0  # No requirement specified, give benefit of doubt

    ratio = resume_years / required_years if required_years > 0 else 1.0

    if ratio >= 1.0:
        return 100.0
    elif ratio >= 0.8:
        return 85.0
    elif ratio >= 0.6:
        return 65.0
    elif ratio >= 0.4:
        return 45.0
    elif ratio > 0.0:
        return 25.0
    else:
        return 0.0


def _score_education(edu_data: dict, required_edu: str) -> float:
    """
    Education score based on degree level detected.
    If required education is specified, it's used to set the target level.
    """
    level = edu_data.get("level", 0)
    base_score = EDUCATION_SCORE_MAP.get(level, 0)

    required_lower = required_edu.lower() if required_edu else ""

    # Determine required level
    required_level = 0
    if any(k in required_lower for k in ["phd", "doctorate"]):
        required_level = 5
    elif any(k in required_lower for k in ["master", "msc", "mba"]):
        required_level = 4
    elif any(k in required_lower for k in ["bachelor", "bsc", "btech", "b.tech"]):
        required_level = 3
    elif "diploma" in required_lower:
        required_level = 2

    if required_level > 0:
        if level >= required_level:
            return 100.0
        elif level == required_level - 1:
            return 65.0
        else:
            return 30.0

    return float(base_score)


def _score_certifications(certs_found: list, required_certs: list) -> float:
    """
    Certification score based on how many required certs are found.
    Each found cert contributes 25 points (max 100).
    Bonus for extra certs found.
    """
    if not required_certs:
        # No requirement β€” award points if candidate has any
        return min(len(certs_found) * 15.0, 50.0)

    required_lower = [c.lower() for c in required_certs]
    found_lower = [c.lower() for c in certs_found]

    matched = sum(1 for rc in required_lower if any(rc in fc or fc in rc for fc in found_lower))
    base_score = (matched / len(required_lower)) * 100
    return min(base_score, 100.0)


def _score_projects(project_count: int) -> float:
    """
    Projects score based on count of detected projects:
    0 β†’ 0, 1 β†’ 40, 2 β†’ 60, 3 β†’ 80, 4+ β†’ 100
    """
    if project_count == 0:
        return 0.0
    elif project_count == 1:
        return 40.0
    elif project_count == 2:
        return 60.0
    elif project_count == 3:
        return 80.0
    else:
        return 100.0


def _score_bonus(
    location: str,
    languages: list,
    preferred_location: str,
    preferred_languages: list,
) -> float:
    """
    Bonus score for location and language match.
    Max 100 (which contributes 5% to final).
    """
    score = 0.0
    if preferred_location and location != "Not specified":
        if preferred_location.lower() in location.lower():
            score += 60.0

    if preferred_languages:
        lang_lower = [l.lower() for l in languages]
        for pl in preferred_languages:
            if pl.lower() in lang_lower:
                score += 20.0

    return min(score, 100.0)


# ---------------------------------------------------------------------------
# Explanation generator
# ---------------------------------------------------------------------------

def _generate_explanation(candidate: dict, scores: dict, threshold: float) -> tuple:
    """
    Builds lists of strengths, weaknesses, and a plain-English summary.
    Returns (strengths: list, weaknesses: list, summary: str).
    """
    strengths = []
    weaknesses = []

    # Skills
    matched = candidate["skills"]["matched"]
    missing = candidate["skills"]["missing"]
    if matched:
        strengths.append(f"Matched {len(matched)} required skill(s): {', '.join(matched[:5])}")
    if missing:
        weaknesses.append(f"Missing {len(missing)} skill(s): {', '.join(missing[:5])}")

    # Experience
    exp_years = candidate["experience_years"]
    req_years = scores.get("required_years", 0)
    if exp_years >= req_years and req_years > 0:
        strengths.append(f"{exp_years:.1f} years of experience meets the requirement ({req_years:.0f}+ years)")
    elif exp_years < req_years:
        weaknesses.append(f"Only {exp_years:.1f} year(s) experience; {req_years:.0f}+ required")

    # Education
    edu = candidate["education"]
    if edu["level"] >= 3:
        strengths.append(f"Holds a {edu['degree']} in {edu['field']}")
    elif edu["level"] > 0:
        weaknesses.append(f"Education level ({edu['degree']}) may be below requirement")
    else:
        weaknesses.append("No recognized educational qualification detected")

    # Certifications
    certs = candidate["certifications"]
    if certs:
        strengths.append(f"Has {len(certs)} certification(s): {', '.join(certs[:3])}")
    else:
        weaknesses.append("No industry certifications found")

    # Projects
    projects = candidate["project_count"]
    if projects >= 3:
        strengths.append(f"Strong project portfolio ({projects} projects detected)")
    elif projects >= 1:
        strengths.append(f"{projects} project(s) found in resume")
    else:
        weaknesses.append("No project experience detected")

    # TF-IDF Semantic similarity
    sim = scores.get("tfidf", 0)
    if sim >= 0.3:
        strengths.append(f"High semantic relevance to job description ({sim*100:.0f}%)")
    elif sim < 0.15:
        weaknesses.append("Resume content has low semantic alignment with job description")

    final_score = scores.get("final", 0)
    status = "Selected" if final_score >= threshold else "Rejected"

    if status == "Selected":
        summary = (
            f"Candidate is SELECTED with a final score of {final_score:.1f}/100. "
            f"Strong fit based on {len(strengths)} positive factor(s)."
        )
    else:
        summary = (
            f"Candidate is REJECTED (score {final_score:.1f}/100 below threshold {threshold:.0f}). "
            f"Key gaps: {'; '.join(weaknesses[:2]) if weaknesses else 'overall low relevance'}."
        )

    return strengths, weaknesses, summary


# ---------------------------------------------------------------------------
# Main engine entry point
# ---------------------------------------------------------------------------

def score_and_rank(
    candidates_data: List[Dict[str, Any]],
    job_requirements: Dict[str, Any],
    tfidf_scores: List[float],
    top_n: int = 10,
) -> List[CandidateResult]:
    """
    Score all candidates, sort by final score descending, assign ranks.

    Parameters
    ----------
    candidates_data : list of dicts from nlp_layer.extract_all()
    job_requirements : dict with keys:
        required_skills, experience_years, education, certifications,
        preferred_location, preferred_languages, top_n
    tfidf_scores : TF-IDF similarity scores (parallel list to candidates_data)
    top_n : how many candidates to return

    Returns
    -------
    List of CandidateResult objects, ranked
    """
    req_skills = job_requirements.get("required_skills", [])
    req_exp = float(job_requirements.get("experience_years", 0) or 0)
    req_edu = job_requirements.get("education", "")
    req_certs = [c.strip() for c in job_requirements.get("certifications", "").split(",") if c.strip()]
    pref_location = job_requirements.get("preferred_location", "")
    pref_languages = [l.strip() for l in job_requirements.get("preferred_languages", "").split(",") if l.strip()]
    threshold = SELECTION_THRESHOLD

    scored_list = []

    for i, candidate in enumerate(candidates_data):
        tfidf = tfidf_scores[i] if i < len(tfidf_scores) else 0.0

        # Compute component scores
        s_skills = _score_skills(candidate["skills"])
        s_exp = _score_experience(candidate["experience_years"], req_exp)
        s_edu = _score_education(candidate["education"], req_edu)
        s_certs = _score_certifications(candidate["certifications"], req_certs)
        s_proj = _score_projects(candidate["project_count"])
        s_bonus = _score_bonus(
            candidate["location"],
            candidate["languages"],
            pref_location,
            pref_languages,
        )

        # Weighted final score (each component is 0–100)
        final_score = (
            s_skills * WEIGHTS["skills"]
            + s_exp * WEIGHTS["experience"]
            + s_edu * WEIGHTS["education"]
            + s_certs * WEIGHTS["certs"]
            + s_proj * WEIGHTS["projects"]
            + s_bonus * WEIGHTS["bonus"]
        )

        # Match percent β‰ˆ final score (semantically similar concept for HR)
        match_percent = round(final_score, 1)

        scores_context = {
            "final": final_score,
            "required_years": req_exp,
            "tfidf": tfidf,
        }

        strengths, weaknesses, summary = _generate_explanation(
            candidate, scores_context, threshold
        )

        result = CandidateResult(
            rank=0,  # Assigned after sorting
            name=candidate["name"],
            filename=candidate.get("filename", "resume.pdf"),
            final_score=round(final_score, 2),
            match_percent=match_percent,
            status="Selected" if final_score >= threshold else "Rejected",
            skills_score=round(s_skills, 2),
            experience_score=round(s_exp, 2),
            education_score=round(s_edu, 2),
            certs_score=round(s_certs, 2),
            projects_score=round(s_proj, 2),
            bonus_score=round(s_bonus, 2),
            tfidf_similarity=round(tfidf * 100, 2),
            matched_skills=candidate["skills"]["matched"],
            missing_skills=candidate["skills"]["missing"],
            extra_skills=candidate["skills"]["extra"],
            experience_years=candidate["experience_years"],
            education_degree=candidate["education"]["degree"],
            education_field=candidate["education"]["field"],
            certifications=candidate["certifications"],
            project_count=candidate["project_count"],
            languages=candidate["languages"],
            location=candidate["location"],
            strengths=strengths,
            weaknesses=weaknesses,
            summary=summary,
        )
        scored_list.append(result)

    # Sort by final score descending
    scored_list.sort(key=lambda r: r.final_score, reverse=True)

    # Assign ranks
    for idx, result in enumerate(scored_list):
        result.rank = idx + 1

    # Return top N
    return scored_list[:top_n]