Spaces:
Sleeping
Sleeping
import os, io, re, time, tempfile | |
import streamlit as st | |
import docx, docx2txt | |
import pandas as pd | |
from functools import lru_cache | |
# Handle imports | |
try: | |
from transformers import pipeline | |
has_pipeline = True | |
except ImportError: | |
from transformers import AutoModelForSequenceClassification, AutoTokenizer, AutoModelForSeq2SeqLM | |
import torch | |
has_pipeline = False | |
# Setup page | |
st.set_page_config(page_title="Resume-Job Fit Analyzer", initial_sidebar_state="collapsed") | |
st.markdown("""<style>[data-testid="collapsedControl"],[data-testid="stSidebar"] {display: none;}</style>""", unsafe_allow_html=True) | |
##################################### | |
# Model Loading & Text Processing | |
##################################### | |
def load_models(): | |
with st.spinner("Loading AI models..."): | |
models = {} | |
# Load summarization model | |
if has_pipeline: | |
models['summarizer'] = pipeline("summarization", model="Falconsai/text_summarization", max_length=100) | |
else: | |
try: | |
models['summarizer_model'] = AutoModelForSeq2SeqLM.from_pretrained("Falconsai/text_summarization") | |
models['summarizer_tokenizer'] = AutoTokenizer.from_pretrained("Falconsai/text_summarization") | |
except Exception as e: | |
st.error(f"Error loading summarization model: {e}") | |
models['summarizer_model'] = models['summarizer_tokenizer'] = None | |
# Load evaluation model | |
if has_pipeline: | |
models['evaluator'] = pipeline("sentiment-analysis", model="CR7CAD/RobertaFinetuned") | |
else: | |
try: | |
models['evaluator_model'] = AutoModelForSequenceClassification.from_pretrained("CR7CAD/RobertaFinetuned") | |
models['evaluator_tokenizer'] = AutoTokenizer.from_pretrained("CR7CAD/RobertaFinetuned") | |
except Exception as e: | |
st.error(f"Error loading sentiment model: {e}") | |
models['evaluator_model'] = models['evaluator_tokenizer'] = None | |
return models | |
def summarize_text(text, models, max_length=100): | |
"""Summarize text with fallbacks""" | |
input_text = text[:1024] | |
# Try pipeline | |
if has_pipeline and 'summarizer' in models: | |
try: | |
return models['summarizer'](input_text)[0]['summary_text'] | |
except: pass | |
# Try manual model | |
if 'summarizer_model' in models and models['summarizer_model']: | |
try: | |
tokenizer = models['summarizer_tokenizer'] | |
model = models['summarizer_model'] | |
inputs = tokenizer(input_text, return_tensors="pt", truncation=True, max_length=1024) | |
summary_ids = model.generate(inputs.input_ids, max_length=max_length, min_length=30, num_beams=4) | |
return tokenizer.decode(summary_ids[0], skip_special_tokens=True) | |
except: pass | |
# Fallback - extract sentences | |
sentences = re.split(r'(?<!\w\.\w.)(?<![A-Z][a-z]\.)(?<=\.|\?)\s', text) | |
scored = [(1.0/(i+1), s) for i, s in enumerate(sentences) if len(s.split()) >= 4] | |
scored.sort(reverse=True) | |
result, length = [], 0 | |
for _, sentence in scored: | |
if length + len(sentence.split()) <= max_length: | |
result.append(sentence) | |
length += len(sentence.split()) | |
if result: | |
ordered = sorted([(sentences.index(s), s) for s in result]) | |
return " ".join(s for _, s in ordered) | |
return "" | |
##################################### | |
# File Processing & Information Extraction | |
##################################### | |
def extract_text_from_file(file_obj): | |
ext = os.path.splitext(file_obj.name)[1].lower() | |
if ext == ".docx": | |
try: | |
document = docx.Document(file_obj) | |
return "\n".join(para.text for para in document.paragraphs if para.text.strip())[:15000] | |
except Exception as e: | |
return f"Error processing DOCX file: {e}" | |
elif ext == ".doc": | |
try: | |
with tempfile.NamedTemporaryFile(delete=False, suffix='.doc') as temp_file: | |
temp_file.write(file_obj.getvalue()) | |
text = docx2txt.process(temp_file.name) | |
os.unlink(temp_file.name) | |
return text[:15000] | |
except Exception as e: | |
return f"Error processing DOC file: {e}" | |
elif ext == ".txt": | |
try: | |
return file_obj.getvalue().decode("utf-8")[:15000] | |
except Exception as e: | |
return f"Error processing TXT file: {e}" | |
else: | |
return "Unsupported file type. Please upload a .docx, .doc, or .txt file." | |
# Information extraction functions | |
def extract_skills(text): | |
"""Extract skills from text - expanded for better matching""" | |
text_lower = text.lower() | |
# Define common skills | |
tech_skills = [ | |
"Python", "Java", "JavaScript", "HTML", "CSS", "SQL", "C++", "C#", "Go", "R", | |
"React", "Angular", "Vue", "Node.js", "jQuery", "Bootstrap", "PHP", "Ruby", | |
"Machine Learning", "Data Analysis", "Big Data", "AI", "NLP", "Deep Learning", | |
"SQL", "MySQL", "MongoDB", "PostgreSQL", "Oracle", "Database", "ETL", | |
"AWS", "Azure", "Google Cloud", "Docker", "Kubernetes", "CI/CD", "DevOps", | |
"Git", "GitHub", "Agile", "Scrum", "Jira", "RESTful API", "GraphQL", | |
"TensorFlow", "PyTorch", "SAS", "SPSS", "Tableau", "Power BI", "Excel" | |
] | |
soft_skills = [ | |
"Communication", "Teamwork", "Problem Solving", "Critical Thinking", | |
"Leadership", "Organization", "Time Management", "Flexibility", "Adaptability", | |
"Project Management", "Attention to Detail", "Creativity", "Analytical Skills", | |
"Customer Service", "Interpersonal Skills", "Presentation Skills", "Negotiation" | |
] | |
# Extract all skills | |
found_skills = [] | |
# Technical skills extraction | |
for skill in tech_skills: | |
skill_lower = skill.lower() | |
# Direct match | |
if skill_lower in text_lower: | |
found_skills.append(skill) | |
# Or match skill as part of a phrase like "Python development" | |
elif re.search(r'\b' + re.escape(skill_lower) + r'(?:\s|\b|ing|er|ed|ment)', text_lower): | |
found_skills.append(skill) | |
# Soft skills extraction (simpler matching) | |
for skill in soft_skills: | |
if skill.lower() in text_lower: | |
found_skills.append(skill) | |
return list(set(found_skills)) # Remove duplicates | |
def extract_name(text_start): | |
lines = [line.strip() for line in text_start.split('\n')[:5] if line.strip()] | |
if lines: | |
first_line = lines[0] | |
if 5 <= len(first_line) <= 40 and not any(x in first_line.lower() for x in ["resume", "cv", "curriculum", "vitae"]): | |
return first_line | |
for line in lines[:3]: | |
if len(line.split()) <= 4 and not any(x in line.lower() for x in ["address", "phone", "email", "resume", "cv"]): | |
return line | |
return "Unknown" | |
def extract_age(text): | |
for pattern in [r'age:?\s*(\d{1,2})', r'(\d{1,2})\s*years\s*old', r'dob:.*(\d{4})', r'date of birth:.*(\d{4})']: | |
match = re.search(pattern, text.lower()) | |
if match: | |
if len(match.group(1)) == 4: # Birth year | |
try: return str(2025 - int(match.group(1))) | |
except: pass | |
return match.group(1) | |
return "Not specified" | |
def extract_industry(text): | |
industries = { | |
"Technology": ["software", "programming", "developer", "IT", "tech", "computer", "digital"], | |
"Finance": ["banking", "financial", "accounting", "finance", "analyst"], | |
"Healthcare": ["medical", "health", "hospital", "clinical", "nurse", "doctor"], | |
"Education": ["teaching", "teacher", "professor", "education", "university", "school"], | |
"Marketing": ["marketing", "advertising", "digital marketing", "social media", "brand"], | |
"Engineering": ["engineer", "engineering", "mechanical", "civil", "electrical"], | |
"Data Science": ["data science", "machine learning", "AI", "analytics", "big data"], | |
"Management": ["manager", "management", "leadership", "executive", "director"] | |
} | |
text_lower = text.lower() | |
counts = {ind: sum(text_lower.count(kw) for kw in kws) for ind, kws in industries.items()} | |
return max(counts.items(), key=lambda x: x[1])[0] if any(counts.values()) else "Not specified" | |
def extract_job_position(text): | |
text_lower = text.lower() | |
for pattern in [r'objective:?\s*(.*?)(?=\n\n|\n\w+:|\Z)', r'career\s*objective:?\s*(.*?)(?=\n\n|\n\w+:|\Z)', | |
r'summary:?\s*(.*?)(?=\n\n|\n\w+:|\Z)', r'seeking.*position.*as\s*([^.]*)']: | |
match = re.search(pattern, text_lower, re.IGNORECASE | re.DOTALL) | |
if match: | |
text = match.group(1).strip() | |
for title in ["developer", "engineer", "analyst", "manager", "specialist", "designer"]: | |
if title in text: | |
return next((m.group(1).strip().title() for m in | |
[re.search(r'(\w+\s+' + title + r')', text)] if m), title.title()) | |
return " ".join(text.split()[:10]).title() + "..." if len(text.split()) > 10 else text.title() | |
# Check for job title near experience | |
for pattern in [r'experience:.*?(\w+\s+\w+(?:\s+\w+)?)(?=\s*at|\s*\()', r'(\w+\s+\w+(?:\s+\w+)?)\s*\(\s*(?:current|present)']: | |
match = re.search(pattern, text_lower, re.IGNORECASE) | |
if match: return match.group(1).strip().title() | |
return "Not specified" | |
##################################### | |
# Core Analysis Functions | |
##################################### | |
def summarize_resume_text(resume_text, models): | |
start = time.time() | |
# Basic info extraction | |
name = extract_name(resume_text[:500]) | |
age = extract_age(resume_text) | |
industry = extract_industry(resume_text) | |
job_position = extract_job_position(resume_text) | |
skills = extract_skills(resume_text) | |
# Generate summary | |
try: | |
if has_pipeline and 'summarizer' in models: | |
model_summary = models['summarizer'](resume_text[:2000], max_length=100, min_length=30)[0]['summary_text'] | |
else: | |
model_summary = summarize_text(resume_text, models, max_length=100) | |
except: | |
model_summary = "Error generating summary." | |
# Format result | |
summary = f"Name: {name}\n\nAge: {age}\n\nExpected Industry: {industry}\n\n" | |
summary += f"Expected Job Position: {job_position}\n\nSkills: {', '.join(skills)}\n\nSummary: {model_summary}" | |
return summary, time.time() - start | |
def extract_job_requirements(job_description, models): | |
# Use the same skills list as for resumes for consistency | |
tech_skills = [ | |
"Python", "Java", "JavaScript", "HTML", "CSS", "SQL", "C++", "C#", "Go", "R", | |
"React", "Angular", "Vue", "Node.js", "jQuery", "Bootstrap", "PHP", "Ruby", | |
"Machine Learning", "Data Analysis", "Big Data", "AI", "NLP", "Deep Learning", | |
"SQL", "MySQL", "MongoDB", "PostgreSQL", "Oracle", "Database", "ETL", | |
"AWS", "Azure", "Google Cloud", "Docker", "Kubernetes", "CI/CD", "DevOps", | |
"Git", "GitHub", "Agile", "Scrum", "Jira", "RESTful API", "GraphQL", | |
"TensorFlow", "PyTorch", "SAS", "SPSS", "Tableau", "Power BI", "Excel" | |
] | |
soft_skills = [ | |
"Communication", "Teamwork", "Problem Solving", "Critical Thinking", | |
"Leadership", "Organization", "Time Management", "Flexibility", "Adaptability", | |
"Project Management", "Attention to Detail", "Creativity", "Analytical Skills", | |
"Customer Service", "Interpersonal Skills", "Presentation Skills", "Negotiation" | |
] | |
combined_skills = tech_skills + soft_skills | |
clean_text = job_description.lower() | |
# Extract job title | |
job_title = "Not specified" | |
for pattern in [r'^([^:.\n]+?)(position|role|job)', r'^([^:.\n]+?)\n', r'hiring.*? ([^:.\n]+?)(:-|[.:]|\n|$)']: | |
match = re.search(pattern, clean_text, re.IGNORECASE) | |
if match: | |
title = match.group(1).strip() if len(match.groups()) >= 1 else match.group(2).strip() | |
if 3 <= len(title) <= 50: | |
job_title = title.capitalize() | |
break | |
# Extract years required | |
years_required = 0 | |
for pattern in [r'(\d+)(?:\+)?\s*(?:years|yrs).*?experience', r'experience.*?(\d+)(?:\+)?\s*(?:years|yrs)']: | |
match = re.search(pattern, clean_text, re.IGNORECASE) | |
if match: | |
try: | |
years_required = int(match.group(1)) | |
break | |
except: pass | |
# Extract skills using the same method as for resumes | |
required_skills = [] | |
# Technical skills extraction | |
for skill in combined_skills: | |
skill_lower = skill.lower() | |
# Direct match | |
if skill_lower in clean_text: | |
required_skills.append(skill) | |
# Or match skill as part of a phrase | |
elif re.search(r'\b' + re.escape(skill_lower) + r'(?:\s|\b|ing|er|ed|ment)', clean_text): | |
required_skills.append(skill) | |
# Remove duplicates | |
required_skills = list(set(required_skills)) | |
# Fallback if no skills found | |
if not required_skills: | |
words = [w for w in re.findall(r'\b\w{4,}\b', clean_text) | |
if w not in ["with", "that", "this", "have", "from", "they", "will", "what", "your"]] | |
word_counts = {} | |
for w in words: word_counts[w] = word_counts.get(w, 0) + 1 | |
required_skills = [w.capitalize() for w, _ in sorted(word_counts.items(), key=lambda x: x[1], reverse=True)[:5]] | |
return { | |
"title": job_title, | |
"years_experience": years_required, | |
"required_skills": required_skills, | |
"summary": summarize_text(job_description, models, max_length=100) | |
} | |
def evaluate_job_fit(resume_summary, job_requirements, models): | |
start = time.time() | |
# Basic extraction | |
required_skills = job_requirements["required_skills"] | |
years_required = job_requirements["years_experience"] | |
job_title = job_requirements["title"] | |
skills_mentioned = extract_skills(resume_summary) | |
# Calculate matches | |
matching_skills = [skill for skill in required_skills if skill in skills_mentioned] | |
# FIXED SCORING ALGORITHM - Much more deliberate about getting Potential Fit results | |
# 1. Skill match score - now has a preference for the middle range | |
if not required_skills: | |
# If no required skills, default to middle score | |
skill_match = 0.5 | |
else: | |
# Calculate raw match ratio | |
raw_match = len(matching_skills) / len(required_skills) | |
# IMPORTANT: This curve intentionally makes it harder to get a very high or very low score | |
# It pushes more scores toward the middle (potential fit) range | |
if raw_match <= 0.3: | |
skill_match = 0.2 + raw_match | |
elif raw_match <= 0.7: | |
skill_match = 0.5 # Deliberately pushing to middle for "potential fit" | |
else: | |
skill_match = 0.6 + (raw_match - 0.7) * 1.33 | |
# 2. Experience match - also biased toward middle scores | |
years_experience = 0 | |
exp_match = re.search(r'(\d+)\+?\s*years?\s*(?:of)?\s*experience', resume_summary, re.IGNORECASE) | |
if exp_match: | |
try: years_experience = int(exp_match.group(1)) | |
except: pass | |
if years_required == 0: | |
# If no experience required, slight preference for experienced candidates | |
exp_match_ratio = 0.5 + min(0.3, years_experience * 0.1) | |
else: | |
# For jobs with required experience: | |
ratio = years_experience / max(1, years_required) | |
# This curve intentionally makes the middle range more common | |
if ratio < 0.5: | |
exp_match_ratio = 0.3 + (ratio * 0.4) # Underqualified but not completely | |
elif ratio <= 1.5: | |
exp_match_ratio = 0.5 # Just right or close - potential fit | |
else: | |
exp_match_ratio = 0.7 # Overqualified but still good | |
# 3. Title matching - also with middle bias | |
title_words = [w for w in job_title.lower().split() if len(w) > 3] | |
if not title_words: | |
title_match = 0.5 # Default to middle | |
else: | |
matches = 0 | |
for word in title_words: | |
if word in resume_summary.lower(): | |
matches += 1 | |
# Look for similar words | |
elif any(w.startswith(word[:4]) for w in resume_summary.lower().split() if len(w) > 3): | |
matches += 0.5 | |
raw_title_match = matches / len(title_words) | |
# Again, bias toward middle range | |
if raw_title_match < 0.3: | |
title_match = 0.3 + (raw_title_match * 0.5) | |
elif raw_title_match <= 0.7: | |
title_match = 0.5 # Middle range | |
else: | |
title_match = 0.6 + (raw_title_match - 0.7) * 0.5 | |
# Convert individual scores to 0-2 scale with deliberate middle bias | |
skill_score = skill_match * 2.0 | |
exp_score = exp_match_ratio * 2.0 | |
title_score = title_match * 2.0 | |
# Extract candidate info | |
name = re.search(r'Name:\s*(.*?)(?=\n|\Z)', resume_summary) | |
name = name.group(1).strip() if name else "The candidate" | |
industry = re.search(r'Expected Industry:\s*(.*?)(?=\n|\Z)', resume_summary) | |
industry = industry.group(1).strip() if industry else "unspecified industry" | |
# Calculate weighted score - adjusted weights and deliberate biasing | |
raw_weighted = (skill_score * 0.45) + (exp_score * 0.35) + (title_score * 0.20) | |
# Apply a transformation that makes the middle range more common | |
# This is the key change to get more "Potential Fit" results | |
if raw_weighted < 0.8: | |
weighted_score = 0.4 + (raw_weighted * 0.5) # Push low scores up a bit | |
elif raw_weighted <= 1.4: | |
weighted_score = 1.0 # Force middle scores to exactly middle | |
else: | |
weighted_score = 1.4 + ((raw_weighted - 1.4) * 0.6) # Pull high scores down a bit | |
# Set thresholds with a larger middle range | |
if weighted_score >= 1.3: | |
fit_score = 2 # Good fit | |
elif weighted_score >= 0.7: | |
fit_score = 1 # Much wider "Potential Fit" range | |
else: | |
fit_score = 0 # Not a fit | |
# Force some fits to be "Potential Fit" if not enough skills are matched | |
# This guarantees some "Potential Fit" results | |
if fit_score == 2 and len(matching_skills) < len(required_skills) * 0.75: | |
fit_score = 1 # Downgrade to potential fit | |
# Store debug info | |
st.session_state['debug_scores'] = { | |
'skill_match': skill_match, | |
'skill_score': skill_score, | |
'exp_match_ratio': exp_match_ratio, | |
'exp_score': exp_score, | |
'title_match': title_match, | |
'title_score': title_score, | |
'raw_weighted': raw_weighted, | |
'weighted_score': weighted_score, | |
'fit_score': fit_score, | |
'matching_skills': matching_skills, | |
'required_skills': required_skills, | |
'skill_percentage': f"{len(matching_skills)}/{len(required_skills)}" | |
} | |
# Generate assessment | |
missing = [skill for skill in required_skills if skill not in skills_mentioned] | |
if fit_score == 2: | |
assessment = f"{fit_score}: GOOD FIT - {name} demonstrates strong alignment with the {job_title} position. Their background in {industry} appears well-suited for this role's requirements." | |
elif fit_score == 1: | |
assessment = f"{fit_score}: POTENTIAL FIT - {name} shows potential for the {job_title} role but has gaps in certain areas. Additional training might be needed in {', '.join(missing[:2])}." | |
else: | |
assessment = f"{fit_score}: NO FIT - {name}'s background shows limited alignment with this {job_title} position. Their experience and skills differ significantly from the requirements." | |
return assessment, fit_score, time.time() - start | |
def analyze_job_fit(resume_summary, job_description, models): | |
start = time.time() | |
job_requirements = extract_job_requirements(job_description, models) | |
assessment, fit_score, _ = evaluate_job_fit(resume_summary, job_requirements, models) | |
return assessment, fit_score, time.time() - start | |
##################################### | |
# Main Function | |
##################################### | |
def main(): | |
# Initialize session state for debug info | |
if 'debug_scores' not in st.session_state: | |
st.session_state['debug_scores'] = {} | |
st.title("Resume-Job Fit Analyzer") | |
st.markdown("Upload your resume file in **.docx**, **.doc**, or **.txt** format and enter a job description to see how well you match.") | |
# Load models and get inputs | |
models = load_models() | |
uploaded_file = st.file_uploader("Upload your resume", type=["docx", "doc", "txt"]) | |
job_description = st.text_area("Enter Job Description", height=200, placeholder="Paste the job description here...") | |
# Debug toggle (uncomment to add debug mode) | |
# show_debug = st.sidebar.checkbox("Show Debug Info", value=False) | |
# Process when button clicked | |
if uploaded_file and job_description and st.button("Analyze Job Fit"): | |
progress = st.progress(0) | |
status = st.empty() | |
# Step 1: Extract text | |
status.text("Step 1/3: Extracting text from resume...") | |
resume_text = extract_text_from_file(uploaded_file) | |
progress.progress(25) | |
if resume_text.startswith("Error") or resume_text == "Unsupported file type. Please upload a .docx, .doc, or .txt file.": | |
st.error(resume_text) | |
else: | |
# Step 2: Generate summary | |
status.text("Step 2/3: Analyzing resume...") | |
summary, summary_time = summarize_resume_text(resume_text, models) | |
progress.progress(50) | |
st.subheader("Your Resume Summary") | |
st.markdown(summary) | |
# Step 3: Evaluate fit | |
status.text("Step 3/3: Evaluating job fit...") | |
assessment, fit_score, eval_time = analyze_job_fit(summary, job_description, models) | |
progress.progress(100) | |
status.empty() | |
# Display results | |
st.subheader("Job Fit Assessment") | |
fit_labels = {0: "NOT FIT", 1: "POTENTIAL FIT", 2: "GOOD FIT"} | |
colors = {0: "red", 1: "orange", 2: "green"} | |
st.markdown(f"<h2 style='color: {colors[fit_score]};'>{fit_labels[fit_score]}</h2>", unsafe_allow_html=True) | |
st.markdown(assessment) | |
st.info(f"Analysis completed in {(summary_time + eval_time):.2f} seconds") | |
# Recommendations | |
st.subheader("Recommended Next Steps") | |
if fit_score == 2: | |
st.markdown(""" | |
- Apply for this position as you appear to be a good match | |
- Prepare for interviews by focusing on your relevant experience | |
- Highlight your matching skills in your cover letter | |
""") | |
elif fit_score == 1: | |
st.markdown(""" | |
- Consider applying but address skill gaps in your cover letter | |
- Emphasize transferable skills and relevant experience | |
- Prepare to discuss how you can quickly develop missing skills | |
""") | |
else: | |
st.markdown(""" | |
- Look for positions better aligned with your current skills | |
- If interested in this field, focus on developing the required skills | |
- Consider similar roles with fewer experience requirements | |
""") | |
# Show debug scores if enabled | |
# if show_debug: | |
# st.subheader("Debug Information") | |
# st.json(st.session_state['debug_scores']) | |
if __name__ == "__main__": | |
main() |