muhammadtabishkha Claude Opus 4.8 commited on
Commit
46df6a4
·
1 Parent(s): fcbd6f0

Resume analysis upgrade: structured CV↔JD analysis (sub-scores, matched/missing skills, experience, summary, verdict), .docx parsing, strong skills-based fallback; store full cv_analysis

Browse files
requirements.txt CHANGED
@@ -22,6 +22,7 @@ pymongo[srv]==4.6.1
22
  dnspython>=2.3.0
23
 
24
  PyPDF2==3.0.1
 
25
 
26
  # ─── Auth (JWT) ──────────────────────────────────────────────
27
  PyJWT==2.10.1
 
22
  dnspython>=2.3.0
23
 
24
  PyPDF2==3.0.1
25
+ python-docx==1.1.2
26
 
27
  # ─── Auth (JWT) ──────────────────────────────────────────────
28
  PyJWT==2.10.1
routes/applications.py CHANGED
@@ -5,7 +5,7 @@ import uuid
5
  import os
6
  import logging
7
  from services.application_manager import create_application, get_applications_by_job, get_applications_by_user, update_status, get_application_by_id
8
- from services.ai_engine import score_cv_against_job
9
  from services.job_manager import get_job
10
  from services.email_service import send_status_email
11
  from services.reminders import run_reminder_check
@@ -46,49 +46,58 @@ async def apply(
46
  with open(resume_path, "wb") as f:
47
  f.write(content)
48
 
49
- # Try to extract text from resume
50
- if resume.filename.endswith('.txt'):
 
51
  cv_text = content.decode('utf-8', errors='ignore')
52
- elif resume.filename.endswith('.pdf'):
53
  try:
54
  import PyPDF2
55
  with open(resume_path, 'rb') as f:
56
  reader = PyPDF2.PdfReader(f)
57
  cv_text = ' '.join([page.extract_text() or '' for page in reader.pages])
58
- except ImportError:
59
- logger.warning("PyPDF2 not installed, cannot extract PDF text")
60
- cv_text = ""
61
  except Exception as e:
62
  logger.error(f"PDF extraction error: {e}")
63
  cv_text = ""
 
 
 
 
 
 
 
 
 
 
 
 
64
  else:
65
- # For doc/docx, simple placeholder
66
  cv_text = f"Resume uploaded: {resume.filename}"
67
-
68
  except Exception as e:
69
  logger.error(f"Resume processing error: {e}")
70
  resume_path = f"resume_{uuid.uuid4()}_{resume.filename}"
71
-
72
  # Get job details for scoring
73
  job = get_job(job_id)
74
  job_description = job.get('description', '') if job else ''
75
  job_title = job.get('title', '') if job else ''
76
-
77
- # Calculate CV match score (0-100)
78
  cv_score = 0
 
79
  if cv_text and job_description:
80
- cv_score = score_cv_against_job(cv_text, job_description, job_title)
81
- logger.info(f"CV Score for {candidate_name}: {cv_score}%")
 
82
  else:
83
  logger.warning(f"No CV text extracted for {candidate_name}, skipping scoring")
84
-
85
- # Auto-decision: If score >= 50, auto shortlist
86
  auto_status = "shortlisted" if cv_score >= 50 else "pending"
87
-
88
  if auto_status == "shortlisted":
89
- logger.info(f"✅ Auto-shortlisted {candidate_name} for {job_title} with score {cv_score}%")
90
-
91
- # Create application with CV score and auto status
92
  app = create_application({
93
  "job_id": job_id,
94
  "user_id": user_id,
@@ -96,13 +105,13 @@ async def apply(
96
  "candidate_email": candidate_email,
97
  "cover_letter": cover_letter,
98
  "resume_path": resume_path,
99
- "cv_text": (cv_text or "")[:8000], # durable in Mongo -> used for interview personalisation + CV view
100
  "cv_score": cv_score,
 
101
  "status": auto_status,
102
  "applied_at": datetime.now().isoformat()
103
  })
104
-
105
- # Return application (cv_score hidden from candidate in frontend)
106
  return {
107
  "id": app["id"],
108
  "job_id": app["job_id"],
@@ -113,7 +122,7 @@ async def apply(
113
  "resume_path": app["resume_path"],
114
  "status": app["status"],
115
  "applied_at": app["applied_at"],
116
- "cv_score": cv_score # Will be hidden in frontend for candidates
117
  }
118
 
119
 
 
5
  import os
6
  import logging
7
  from services.application_manager import create_application, get_applications_by_job, get_applications_by_user, update_status, get_application_by_id
8
+ from services.ai_engine import score_cv_against_job, analyze_cv_against_job
9
  from services.job_manager import get_job
10
  from services.email_service import send_status_email
11
  from services.reminders import run_reminder_check
 
46
  with open(resume_path, "wb") as f:
47
  f.write(content)
48
 
49
+ # Try to extract text from resume (txt / pdf / docx)
50
+ fn = (resume.filename or "").lower()
51
+ if fn.endswith('.txt'):
52
  cv_text = content.decode('utf-8', errors='ignore')
53
+ elif fn.endswith('.pdf'):
54
  try:
55
  import PyPDF2
56
  with open(resume_path, 'rb') as f:
57
  reader = PyPDF2.PdfReader(f)
58
  cv_text = ' '.join([page.extract_text() or '' for page in reader.pages])
 
 
 
59
  except Exception as e:
60
  logger.error(f"PDF extraction error: {e}")
61
  cv_text = ""
62
+ elif fn.endswith('.docx'):
63
+ try:
64
+ import docx # python-docx
65
+ d = docx.Document(resume_path)
66
+ parts = [p.text for p in d.paragraphs if p.text]
67
+ for tbl in d.tables:
68
+ for row in tbl.rows:
69
+ parts.append(" ".join(c.text for c in row.cells if c.text))
70
+ cv_text = "\n".join(parts)
71
+ except Exception as e:
72
+ logger.error(f"DOCX extraction error: {e}")
73
+ cv_text = ""
74
  else:
 
75
  cv_text = f"Resume uploaded: {resume.filename}"
76
+
77
  except Exception as e:
78
  logger.error(f"Resume processing error: {e}")
79
  resume_path = f"resume_{uuid.uuid4()}_{resume.filename}"
80
+
81
  # Get job details for scoring
82
  job = get_job(job_id)
83
  job_description = job.get('description', '') if job else ''
84
  job_title = job.get('title', '') if job else ''
85
+
86
+ # Rich CV↔JD analysis (AI when available, structured heuristic otherwise)
87
  cv_score = 0
88
+ cv_analysis = None
89
  if cv_text and job_description:
90
+ cv_analysis = analyze_cv_against_job(cv_text, job_description, job_title)
91
+ cv_score = cv_analysis.get("overall_score", 0)
92
+ logger.info(f"CV analysis for {candidate_name}: {cv_score}% ({cv_analysis.get('source')})")
93
  else:
94
  logger.warning(f"No CV text extracted for {candidate_name}, skipping scoring")
95
+
96
+ # Auto-decision: score >= 50 -> auto shortlist
97
  auto_status = "shortlisted" if cv_score >= 50 else "pending"
 
98
  if auto_status == "shortlisted":
99
+ logger.info(f"✅ Auto-shortlisted {candidate_name} for {job_title} ({cv_score}%)")
100
+
 
101
  app = create_application({
102
  "job_id": job_id,
103
  "user_id": user_id,
 
105
  "candidate_email": candidate_email,
106
  "cover_letter": cover_letter,
107
  "resume_path": resume_path,
108
+ "cv_text": (cv_text or "")[:8000], # durable in Mongo -> interview personalisation + CV view
109
  "cv_score": cv_score,
110
+ "cv_analysis": cv_analysis, # full breakdown for the recruiter
111
  "status": auto_status,
112
  "applied_at": datetime.now().isoformat()
113
  })
114
+
 
115
  return {
116
  "id": app["id"],
117
  "job_id": app["job_id"],
 
122
  "resume_path": app["resume_path"],
123
  "status": app["status"],
124
  "applied_at": app["applied_at"],
125
+ "cv_score": cv_score,
126
  }
127
 
128
 
services/ai_engine.py CHANGED
@@ -450,30 +450,118 @@ def generate_contextual_questions(job_description):
450
  # CV SCORING AGAINST JOB DESCRIPTION
451
  # ─────────────────────────────────────────────
452
 
453
- def score_cv_against_job(cv_text, job_description, job_title):
454
- """
455
- Score CV against job description using Gemini AI
456
- Returns score from 0-100
457
- """
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
458
  api_key = os.getenv("GOOGLE_API_KEY")
459
-
460
  if not api_key or api_key == "AIzaSyDB9W3DC-38GpXrrjUJu8OjgsnudmhEIVA":
461
- logger.warning("No valid API key for CV scoring")
462
- return calculate_fallback_score(cv_text, job_description)
463
-
464
  try:
465
- logger.info("🤖 Scoring CV against job description...")
466
-
467
  llm = ChatGoogleGenerativeAI(
468
- model=GEMINI_MODEL,
469
- google_api_key=api_key,
470
- temperature=0.1,
471
- max_tokens=200,
472
- max_retries=1,
473
  )
474
-
475
  prompt = ChatPromptTemplate.from_template("""
476
- You are an expert HR recruiter. Analyze the candidate's CV against the job description and give a match score.
477
 
478
  JOB TITLE: {job_title}
479
  JOB DESCRIPTION:
@@ -482,67 +570,49 @@ JOB DESCRIPTION:
482
  CANDIDATE CV:
483
  {cv_text}
484
 
485
- SCORING CRITERIA (0-100):
486
- - 90-100: Perfect match, all requirements met with strong experience
487
- - 70-89: Very good match, most requirements met
488
- - 50-69: Good match, some gaps but promising
489
- - 30-49: Partial match, significant gaps
490
- - 0-29: Poor match, not suitable
491
-
492
- Consider:
493
- 1. Required skills vs candidate skills
494
- 2. Years of experience match
495
- 3. Relevant technologies/domain knowledge
496
- 4. Education/certifications if mentioned
497
- 5. Overall relevance to the role
498
-
499
- Return ONLY the score as a number between 0 and 100.
500
- Example: 85
501
  """)
502
-
503
  chain = prompt | llm | StrOutputParser()
504
  result = safe_invoke(chain, {
505
- "cv_text": cv_text[:3000],
506
- "job_description": job_description,
507
- "job_title": job_title
508
- })
509
- if result is None:
510
- return calculate_fallback_score(cv_text, job_description)
511
- score = int(result.strip())
512
- score = max(0, min(100, score))
513
- logger.info(f"✅ CV Score: {score}% for job: {job_title}")
514
- return score
515
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
516
  except Exception as e:
517
- logger.error(f"CV scoring error: {e}")
518
- return calculate_fallback_score(cv_text, job_description)
519
 
520
 
521
- def calculate_fallback_score(cv_text, job_description):
522
- """
523
- Fallback scoring when AI is unavailable
524
- """
525
- cv_lower = cv_text.lower()
526
- job_lower = job_description.lower()
527
-
528
- # Extract keywords from job description
529
- words = job_lower.split()
530
- common_words = set(['the', 'a', 'an', 'and', 'or', 'but', 'in', 'on', 'at', 'to',
531
- 'for', 'of', 'with', 'by', 'from', 'as', 'is', 'was', 'are',
532
- 'be', 'have', 'has', 'had', 'this', 'that', 'these', 'those'])
533
- keywords = [w for w in words if len(w) > 3 and w not in common_words]
534
- keywords = list(set(keywords))[:30]
535
-
536
- # Count matches
537
- matches = sum(1 for kw in keywords if kw in cv_lower)
538
- score = int((matches / len(keywords)) * 100) if keywords else 50
539
-
540
- # Boost for longer CV (more detail)
541
- if len(cv_text) > 500:
542
- score = min(100, score + 10)
543
-
544
- logger.info(f"⚠️ Fallback CV Score: {score}%")
545
- return min(100, max(0, score))
546
 
547
 
548
  # ─────────────────────────────────────────────
 
450
  # CV SCORING AGAINST JOB DESCRIPTION
451
  # ─────────────────────────────────────────────
452
 
453
+ # Skills the analyzer recognises in a JD/CV (lowercase; multi-word allowed).
454
+ CV_SKILL_VOCAB = [
455
+ # languages
456
+ "python", "java", "javascript", "typescript", "c++", "c#", "go", "golang", "ruby",
457
+ "php", "swift", "kotlin", "scala", "rust", "matlab", "sql",
458
+ # web / frontend / backend
459
+ "react", "angular", "vue", "next.js", "nextjs", "node.js", "node", "express",
460
+ "django", "flask", "fastapi", "spring", "laravel", "html", "css", "tailwind",
461
+ "bootstrap", "redux", "graphql", "rest api", "rest", "microservices",
462
+ # data / ml
463
+ "machine learning", "deep learning", "nlp", "computer vision", "tensorflow",
464
+ "pytorch", "scikit-learn", "scikit", "pandas", "numpy", "data analysis",
465
+ "data science", "power bi", "tableau", "excel", "statistics", "spark", "hadoop",
466
+ # cloud / devops
467
+ "aws", "azure", "gcp", "docker", "kubernetes", "ci/cd", "jenkins", "terraform",
468
+ "linux", "git", "github", "gitlab", "devops",
469
+ # databases
470
+ "mongodb", "postgresql", "mysql", "redis", "firebase", "sqlite", "oracle", "elasticsearch",
471
+ # mobile
472
+ "android", "ios", "flutter", "react native",
473
+ # design
474
+ "figma", "photoshop", "illustrator", "ui/ux", "ux design", "ui design",
475
+ # security / qa
476
+ "cybersecurity", "penetration testing", "testing", "selenium", "qa", "junit", "automation",
477
+ # business / soft
478
+ "communication", "leadership", "teamwork", "problem solving", "agile", "scrum",
479
+ "project management", "marketing", "seo", "sales", "content writing",
480
+ "accounting", "finance", "customer service",
481
+ ]
482
+
483
+ _STOP = set("the a an and or but in on at to for of with by from as is was are be have "
484
+ "has had this that these those will can our we you your role job".split())
485
+
486
+
487
+ def _keyword_overlap(cv_lower, jd_lower):
488
+ """Percentage of meaningful JD keywords that appear in the CV."""
489
+ words = [w for w in re.findall(r"[a-zA-Z][a-zA-Z+.#-]{2,}", jd_lower) if w not in _STOP]
490
+ kws = list(dict.fromkeys(words))[:40]
491
+ if not kws:
492
+ return 50
493
+ hits = sum(1 for k in kws if k in cv_lower)
494
+ return int(min(100, round(hits / len(kws) * 100)))
495
+
496
+
497
+ def _fallback_cv_analysis(cv_text, job_description):
498
+ """Structured CV analysis without AI — skills match + experience + education."""
499
+ cv = (cv_text or "").lower()
500
+ jd = (job_description or "").lower()
501
+
502
+ required = [s for s in CV_SKILL_VOCAB if s in jd]
503
+ matched = [s for s in required if s in cv]
504
+ missing = [s for s in required if s not in cv]
505
+ skill_match = int(round(len(matched) / len(required) * 100)) if required else _keyword_overlap(cv, jd)
506
+
507
+ cv_years = [int(x) for x in re.findall(r"(\d+)\+?\s*(?:years|yrs|year)", cv)]
508
+ exp_years = max(cv_years) if cv_years else 0
509
+ jd_years = [int(x) for x in re.findall(r"(\d+)\+?\s*(?:years|yrs|year)", jd)]
510
+ need = max(jd_years) if jd_years else 0
511
+ if need:
512
+ experience_match = int(min(100, round((exp_years / need) * 100))) if exp_years else 20
513
+ else:
514
+ experience_match = 70 if exp_years else 50
515
+
516
+ edu_kw = ["bachelor", "master", "phd", "bsc", "msc", "b.s", "m.s", "mba", "degree",
517
+ "university", "diploma", "b.e", "b.tech", "computer science", "software engineering"]
518
+ education_match = 80 if any(k in cv for k in edu_kw) else 40
519
+
520
+ relevance = _keyword_overlap(cv, jd)
521
+ overall = int(round(skill_match * 0.5 + experience_match * 0.2 + education_match * 0.1 + relevance * 0.2))
522
+ overall = max(0, min(100, overall))
523
+ verdict = ("Strong match" if overall >= 75 else "Good match" if overall >= 55
524
+ else "Partial match" if overall >= 35 else "Weak match")
525
+ summary = (f"Matched {len(matched)} of {len(required) or '—'} key skills"
526
+ + (f"; ~{exp_years} yrs experience detected" if exp_years else "")
527
+ + f". {verdict}.")
528
+ return {
529
+ "overall_score": overall,
530
+ "skill_match": skill_match,
531
+ "experience_match": experience_match,
532
+ "education_match": education_match,
533
+ "relevance": relevance,
534
+ "matched_skills": [s.title() for s in matched][:15],
535
+ "missing_skills": [s.title() for s in missing][:15],
536
+ "experience_years": exp_years or None,
537
+ "summary": summary,
538
+ "verdict": verdict,
539
+ "source": "heuristic",
540
+ }
541
+
542
+
543
+ def analyze_cv_against_job(cv_text, job_description, job_title):
544
+ """Rich CV↔JD analysis: overall + sub-scores + matched/missing skills + summary.
545
+ Uses Gemini when available, falls back to a structured heuristic otherwise."""
546
+ if not cv_text or len(cv_text.strip()) < 20:
547
+ return {
548
+ "overall_score": 0, "skill_match": 0, "experience_match": 0,
549
+ "education_match": 0, "relevance": 0, "matched_skills": [], "missing_skills": [],
550
+ "experience_years": None, "summary": "No readable text could be extracted from this CV.",
551
+ "verdict": "Unreadable", "source": "none",
552
+ }
553
+
554
  api_key = os.getenv("GOOGLE_API_KEY")
 
555
  if not api_key or api_key == "AIzaSyDB9W3DC-38GpXrrjUJu8OjgsnudmhEIVA":
556
+ return _fallback_cv_analysis(cv_text, job_description)
557
+
 
558
  try:
 
 
559
  llm = ChatGoogleGenerativeAI(
560
+ model=GEMINI_MODEL, google_api_key=api_key,
561
+ temperature=0.1, max_tokens=700, max_retries=1,
 
 
 
562
  )
 
563
  prompt = ChatPromptTemplate.from_template("""
564
+ You are an expert technical recruiter. Analyse the candidate's CV against the job and return a STRUCTURED match assessment.
565
 
566
  JOB TITLE: {job_title}
567
  JOB DESCRIPTION:
 
570
  CANDIDATE CV:
571
  {cv_text}
572
 
573
+ Score each 0-100 and identify skills. Be fair and evidence-based — do not invent skills not in the CV.
574
+ Return ONLY this JSON (no markdown):
575
+ {{"overall_score": <0-100>, "skill_match": <0-100>, "experience_match": <0-100>, "education_match": <0-100>, "relevance": <0-100>, "experience_years": <int or null>, "matched_skills": ["..."], "missing_skills": ["..."], "summary": "<2 sentences>", "verdict": "Strong match|Good match|Partial match|Weak match"}}
 
 
 
 
 
 
 
 
 
 
 
 
 
576
  """)
 
577
  chain = prompt | llm | StrOutputParser()
578
  result = safe_invoke(chain, {
579
+ "cv_text": (cv_text or "")[:4000],
580
+ "job_description": (job_description or "")[:2000],
581
+ "job_title": job_title or "the role",
582
+ }, timeout=25)
583
+ if not result:
584
+ return _fallback_cv_analysis(cv_text, job_description)
585
+ m = re.search(r"\{.*\}", result, re.DOTALL)
586
+ if not m:
587
+ return _fallback_cv_analysis(cv_text, job_description)
588
+ d = json.loads(m.group(0))
589
+
590
+ def _clamp(v):
591
+ try: return max(0, min(100, int(v)))
592
+ except Exception: return 0
593
+ analysis = {
594
+ "overall_score": _clamp(d.get("overall_score", 0)),
595
+ "skill_match": _clamp(d.get("skill_match", 0)),
596
+ "experience_match": _clamp(d.get("experience_match", 0)),
597
+ "education_match": _clamp(d.get("education_match", 0)),
598
+ "relevance": _clamp(d.get("relevance", 0)),
599
+ "experience_years": d.get("experience_years"),
600
+ "matched_skills": [str(s) for s in (d.get("matched_skills") or [])][:15],
601
+ "missing_skills": [str(s) for s in (d.get("missing_skills") or [])][:15],
602
+ "summary": str(d.get("summary", ""))[:400],
603
+ "verdict": str(d.get("verdict", "")) or "Partial match",
604
+ "source": "ai",
605
+ }
606
+ logger.info(f"✅ AI CV analysis: {analysis['overall_score']}% for {job_title}")
607
+ return analysis
608
  except Exception as e:
609
+ logger.error(f"CV analysis error: {e}")
610
+ return _fallback_cv_analysis(cv_text, job_description)
611
 
612
 
613
+ def score_cv_against_job(cv_text, job_description, job_title):
614
+ """Backward-compatible wrapper — returns just the overall 0-100 score."""
615
+ return analyze_cv_against_job(cv_text, job_description, job_title)["overall_score"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
616
 
617
 
618
  # ─────────────────────────────────────────────
services/application_manager.py CHANGED
@@ -21,6 +21,7 @@ def create_application(data):
21
  "resume_path": data.get("resume_path"),
22
  "cv_text": data.get("cv_text", ""),
23
  "cv_score": data.get("cv_score", 0),
 
24
  "status": data.get("status", "pending"),
25
  "applied_at": data.get("applied_at", datetime.now().isoformat()),
26
  "updated_at": datetime.now().isoformat()
 
21
  "resume_path": data.get("resume_path"),
22
  "cv_text": data.get("cv_text", ""),
23
  "cv_score": data.get("cv_score", 0),
24
+ "cv_analysis": data.get("cv_analysis"), # full CV↔JD breakdown for recruiters
25
  "status": data.get("status", "pending"),
26
  "applied_at": data.get("applied_at", datetime.now().isoformat()),
27
  "updated_at": datetime.now().isoformat()