Spaces:
Sleeping
Sleeping
| """ | |
| app.py β AI Resume ATS Analyzer | |
| ================================ | |
| Main Streamlit application entry point. | |
| Features: | |
| - Upload PDF or DOCX resume | |
| - Automatic text extraction | |
| - Named entity recognition (name, orgs, locations) | |
| - Skills extraction (technical + soft) | |
| - Section detection (Skills, Experience, Projects, Education, Summary) | |
| - Resume base score (0β100) with breakdown | |
| - Job description semantic similarity via Sentence-BERT | |
| - ATS Score = 0.6 Γ base_score + 0.4 Γ job_match | |
| - Classification: Good / Average / Poor | |
| - Actionable suggestions | |
| - AI-powered resume rewrite via FLAN-T5 | |
| - Manual resume editing + re-evaluation | |
| Run locally: | |
| streamlit run app.py | |
| Deploy on Hugging Face Spaces: | |
| 1. Create a new Space (SDK: Streamlit) | |
| 2. Upload all project files | |
| 3. The Space will install requirements.txt and launch automatically | |
| """ | |
| import streamlit as st | |
| import pandas as pd | |
| import time | |
| # Local utility modules | |
| from utils.parser import parse_resume | |
| from utils.nlp_utils import ( | |
| extract_entities, | |
| detect_sections, | |
| extract_skills, | |
| get_missing_sections, | |
| classify_resume, | |
| generate_suggestions, | |
| ) | |
| from utils.scorer import compute_base_score, compute_ats_score | |
| from utils.similarity import compute_similarity, keyword_overlap_score | |
| from utils.generator import generate_improved_resume, generate_professional_summary | |
| # --------------------------------------------------------------------------- | |
| # Page configuration | |
| # --------------------------------------------------------------------------- | |
| st.set_page_config( | |
| page_title="AI Resume ATS Analyzer", | |
| page_icon="π", | |
| layout="wide", | |
| initial_sidebar_state="expanded", | |
| ) | |
| # --------------------------------------------------------------------------- | |
| # Custom CSS β clean, professional look | |
| # --------------------------------------------------------------------------- | |
| st.markdown( | |
| """ | |
| <style> | |
| /* Main header */ | |
| .main-header { | |
| font-size: 2.4rem; | |
| font-weight: 800; | |
| background: linear-gradient(90deg, #667eea 0%, #764ba2 100%); | |
| -webkit-background-clip: text; | |
| -webkit-text-fill-color: transparent; | |
| margin-bottom: 0.2rem; | |
| } | |
| /* Score cards */ | |
| .score-card { | |
| background: #f8f9fa; | |
| border-radius: 12px; | |
| padding: 1.2rem; | |
| text-align: center; | |
| border: 1px solid #e9ecef; | |
| box-shadow: 0 2px 6px rgba(0,0,0,0.06); | |
| } | |
| .score-number { | |
| font-size: 2.2rem; | |
| font-weight: 700; | |
| margin: 0; | |
| } | |
| .score-label { | |
| font-size: 0.85rem; | |
| color: #6c757d; | |
| text-transform: uppercase; | |
| letter-spacing: 0.05em; | |
| } | |
| /* Section headers */ | |
| .section-header { | |
| font-size: 1.1rem; | |
| font-weight: 600; | |
| color: #343a40; | |
| border-bottom: 2px solid #667eea; | |
| padding-bottom: 0.3rem; | |
| margin-top: 1.5rem; | |
| margin-bottom: 0.8rem; | |
| } | |
| /* Skill badges */ | |
| .skill-badge { | |
| display: inline-block; | |
| background: #e8f4fd; | |
| color: #1a6a9a; | |
| border-radius: 20px; | |
| padding: 0.2rem 0.7rem; | |
| margin: 0.15rem; | |
| font-size: 0.82rem; | |
| font-weight: 500; | |
| } | |
| /* Suggestion items */ | |
| .suggestion-item { | |
| background: #fffbf0; | |
| border-left: 4px solid #ffc107; | |
| padding: 0.6rem 0.9rem; | |
| border-radius: 0 6px 6px 0; | |
| margin-bottom: 0.5rem; | |
| font-size: 0.9rem; | |
| } | |
| /* Good/Average/Poor banner */ | |
| .classification-good { | |
| background: #d4edda; color: #155724; | |
| padding: 0.5rem 1rem; border-radius: 8px; | |
| font-size: 1.1rem; font-weight: 600; | |
| text-align: center; | |
| } | |
| .classification-average { | |
| background: #fff3cd; color: #856404; | |
| padding: 0.5rem 1rem; border-radius: 8px; | |
| font-size: 1.1rem; font-weight: 600; | |
| text-align: center; | |
| } | |
| .classification-poor { | |
| background: #f8d7da; color: #721c24; | |
| padding: 0.5rem 1rem; border-radius: 8px; | |
| font-size: 1.1rem; font-weight: 600; | |
| text-align: center; | |
| } | |
| /* Info boxes */ | |
| .stAlert { border-radius: 8px; } | |
| </style> | |
| """, | |
| unsafe_allow_html=True, | |
| ) | |
| # --------------------------------------------------------------------------- | |
| # Sidebar β About & Instructions | |
| # --------------------------------------------------------------------------- | |
| with st.sidebar: | |
| st.image( | |
| "https://img.icons8.com/color/96/resume.png", | |
| width=70, | |
| ) | |
| st.markdown("## π AI Resume Analyzer") | |
| st.markdown( | |
| """ | |
| **How to use:** | |
| 1. Upload your resume (PDF or DOCX) | |
| 2. Paste the job description | |
| 3. Click **Analyze Resume** | |
| 4. Review your scores & suggestions | |
| 5. Use **Generate Improved Resume** for AI rewrite | |
| 6. Edit & **Re-evaluate** anytime | |
| --- | |
| **Scoring Formula:** | |
| - Resume Score: up to 100 pts | |
| - Skills (20) + Experience (30) | |
| - Projects (20) + Education (10) | |
| - Length (10) + Diversity (10) | |
| - ATS Score = 0.6 Γ Resume Score | |
| + 0.4 Γ Job Match % | |
| --- | |
| **Powered by:** | |
| - π€ FLAN-T5 (text generation) | |
| - π€ Sentence-BERT (similarity) | |
| - π§ spaCy (NER) | |
| - π PyMuPDF + python-docx | |
| """ | |
| ) | |
| st.markdown("---") | |
| st.caption("Built with β€οΈ using open-source AI") | |
| # --------------------------------------------------------------------------- | |
| # Main UI β Header | |
| # --------------------------------------------------------------------------- | |
| st.markdown('<p class="main-header">π― AI Resume ATS Analyzer</p>', unsafe_allow_html=True) | |
| st.markdown( | |
| "Upload your resume and a job description to get an **AI-powered ATS score**, " | |
| "skill analysis, and personalized improvement suggestions.", | |
| unsafe_allow_html=True, | |
| ) | |
| st.markdown("---") | |
| # --------------------------------------------------------------------------- | |
| # Input Section β two columns | |
| # --------------------------------------------------------------------------- | |
| col_upload, col_jd = st.columns([1, 1], gap="large") | |
| with col_upload: | |
| st.markdown("### π€ Upload Resume") | |
| uploaded_file = st.file_uploader( | |
| "Supported formats: PDF, DOCX", | |
| type=["pdf", "docx"], | |
| help="Upload your resume in PDF or Word format", | |
| ) | |
| with col_jd: | |
| st.markdown("### π Job Description") | |
| job_description = st.text_area( | |
| "Paste the job description here", | |
| height=200, | |
| placeholder=( | |
| "e.g., We are looking for a Python Developer with experience in " | |
| "FastAPI, PostgreSQL, and Docker. You will work on backend systems, " | |
| "REST APIs, and cloud deployments on AWS..." | |
| ), | |
| help="The job description is used for semantic matching and tailored suggestions.", | |
| ) | |
| # --------------------------------------------------------------------------- | |
| # Session state β persist results across reruns | |
| # --------------------------------------------------------------------------- | |
| if "results" not in st.session_state: | |
| st.session_state.results = None | |
| if "resume_text_editable" not in st.session_state: | |
| st.session_state.resume_text_editable = "" | |
| if "improved_resume" not in st.session_state: | |
| st.session_state.improved_resume = "" | |
| # --------------------------------------------------------------------------- | |
| # Helper: run the full analysis pipeline | |
| # --------------------------------------------------------------------------- | |
| def run_analysis(resume_text: str, job_desc: str) -> dict: | |
| """ | |
| Execute the complete resume analysis pipeline. | |
| Args: | |
| resume_text: extracted or edited resume text | |
| job_desc : job description text | |
| Returns: | |
| Dictionary with all analysis results. | |
| """ | |
| # 1. NLP Analysis | |
| with st.spinner("π Running NLP analysis..."): | |
| entities = extract_entities(resume_text) | |
| sections = detect_sections(resume_text) | |
| skills = extract_skills(resume_text) | |
| missing_sections = get_missing_sections(sections) | |
| # 2. Scoring | |
| with st.spinner("π Computing resume score..."): | |
| score_result = compute_base_score(resume_text, sections, skills) | |
| # 3. Job Description Similarity | |
| with st.spinner("π― Matching against job description..."): | |
| if job_desc.strip(): | |
| job_match = compute_similarity(resume_text, job_desc) | |
| kw_overlap = keyword_overlap_score(resume_text, job_desc) | |
| else: | |
| job_match = 0.0 | |
| kw_overlap = {"overlap_pct": 0.0, "matched": [], "missing": []} | |
| # 4. ATS Score + Classification | |
| ats_score = compute_ats_score(score_result["total"], job_match) | |
| classification = classify_resume(ats_score) | |
| # 5. Suggestions | |
| suggestions = generate_suggestions( | |
| sections, skills, score_result["total"], job_match | |
| ) | |
| return { | |
| "entities": entities, | |
| "sections": sections, | |
| "skills": skills, | |
| "missing_sections": missing_sections, | |
| "base_score": score_result["total"], | |
| "score_breakdown": score_result["breakdown"], | |
| "job_match": job_match, | |
| "kw_overlap": kw_overlap, | |
| "ats_score": ats_score, | |
| "classification": classification, | |
| "suggestions": suggestions, | |
| } | |
| # --------------------------------------------------------------------------- | |
| # Helper: render analysis results | |
| # --------------------------------------------------------------------------- | |
| def render_results(results: dict, resume_text: str): | |
| """Render the full analysis results in the Streamlit UI.""" | |
| st.markdown("---") | |
| st.markdown("## π Analysis Results") | |
| # ββ Score Dashboard ββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| c1, c2, c3, c4 = st.columns(4) | |
| def _color_score(score, thresholds=(70, 45)): | |
| high, mid = thresholds | |
| if score >= high: | |
| return "#28a745" | |
| elif score >= mid: | |
| return "#ffc107" | |
| return "#dc3545" | |
| with c1: | |
| color = _color_score(results["base_score"]) | |
| st.markdown( | |
| f"""<div class="score-card"> | |
| <p class="score-number" style="color:{color}">{results["base_score"]}</p> | |
| <p class="score-label">Resume Score</p> | |
| </div>""", | |
| unsafe_allow_html=True, | |
| ) | |
| with c2: | |
| color = _color_score(results["job_match"]) | |
| st.markdown( | |
| f"""<div class="score-card"> | |
| <p class="score-number" style="color:{color}">{results["job_match"]}%</p> | |
| <p class="score-label">Job Match</p> | |
| </div>""", | |
| unsafe_allow_html=True, | |
| ) | |
| with c3: | |
| color = _color_score(results["ats_score"]) | |
| st.markdown( | |
| f"""<div class="score-card"> | |
| <p class="score-number" style="color:{color}">{results["ats_score"]}%</p> | |
| <p class="score-label">ATS Score</p> | |
| </div>""", | |
| unsafe_allow_html=True, | |
| ) | |
| with c4: | |
| label = results["classification"]["label"] | |
| cls_color = results["classification"]["color"] | |
| css_class = f"classification-{cls_color}" | |
| st.markdown( | |
| f"""<div class="score-card"> | |
| <div class="{css_class}" style="margin-top:0.5rem">{label}</div> | |
| <p class="score-label" style="margin-top:0.5rem">Classification</p> | |
| </div>""", | |
| unsafe_allow_html=True, | |
| ) | |
| st.markdown("<br>", unsafe_allow_html=True) | |
| # ββ Score Breakdown Bar Chart ββββββββββββββββββββββββββββββββββββββββββ | |
| with st.expander("π Score Breakdown", expanded=True): | |
| breakdown = results["score_breakdown"] | |
| max_vals = { | |
| "Skills": 20, | |
| "Experience": 30, | |
| "Projects": 20, | |
| "Education": 10, | |
| "Length": 10, | |
| "Diversity": 10, | |
| } | |
| df = pd.DataFrame( | |
| { | |
| "Category": list(breakdown.keys()), | |
| "Your Score": list(breakdown.values()), | |
| "Max Score": [max_vals.get(k, 10) for k in breakdown.keys()], | |
| } | |
| ) | |
| st.bar_chart(df.set_index("Category")[["Your Score", "Max Score"]]) | |
| # ββ Two column layout for details βββββββββββββββββββββββββββββββββββββ | |
| left_col, right_col = st.columns([1, 1], gap="large") | |
| with left_col: | |
| # ββ Detected Information ββββββββββββββββββββββββββββββββββββββββββ | |
| st.markdown('<p class="section-header">π Detected Information</p>', unsafe_allow_html=True) | |
| entities = results["entities"] | |
| if entities.get("name"): | |
| st.markdown(f"**π€ Candidate Name:** {entities['name']}") | |
| if entities.get("organizations"): | |
| st.markdown(f"**π’ Organizations:** {', '.join(entities['organizations'][:5])}") | |
| if entities.get("locations"): | |
| st.markdown(f"**π Locations:** {', '.join(entities['locations'][:5])}") | |
| # ββ Sections Detected βββββββββββββββββββββββββββββββββββββββββββββ | |
| st.markdown('<p class="section-header">π Sections Detected</p>', unsafe_allow_html=True) | |
| section_display = { | |
| "skills": "π οΈ Skills", | |
| "education": "π Education", | |
| "experience": "πΌ Experience", | |
| "projects": "π Projects", | |
| "summary": "π Summary", | |
| "certifications": "π Certifications", | |
| } | |
| cols = st.columns(2) | |
| for i, (key, label) in enumerate(section_display.items()): | |
| present = results["sections"].get(key, False) | |
| icon = "β " if present else "β" | |
| cols[i % 2].markdown(f"{icon} {label}") | |
| # ββ Missing Sections ββββββββββββββββββββββββββββββββββββββββββββββ | |
| if results["missing_sections"]: | |
| st.markdown('<p class="section-header">β οΈ Missing Sections</p>', unsafe_allow_html=True) | |
| for ms in results["missing_sections"]: | |
| st.warning(f"Missing: **{ms}**") | |
| with right_col: | |
| # ββ Technical Skills ββββββββββββββββββββββββββββββββββββββββββββββ | |
| st.markdown('<p class="section-header">βοΈ Technical Skills Found</p>', unsafe_allow_html=True) | |
| tech_skills = results["skills"].get("technical", []) | |
| if tech_skills: | |
| badges = "".join( | |
| f'<span class="skill-badge">{s.title()}</span>' | |
| for s in tech_skills | |
| ) | |
| st.markdown(badges, unsafe_allow_html=True) | |
| else: | |
| st.info("No technical skills detected. Add a Skills section.") | |
| # ββ Soft Skills βββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| st.markdown('<p class="section-header">π€ Soft Skills Found</p>', unsafe_allow_html=True) | |
| soft_skills = results["skills"].get("soft", []) | |
| if soft_skills: | |
| badges = "".join( | |
| f'<span class="skill-badge" style="background:#f0fff0;color:#276749">{s.title()}</span>' | |
| for s in soft_skills | |
| ) | |
| st.markdown(badges, unsafe_allow_html=True) | |
| else: | |
| st.info("No soft skills detected.") | |
| # ββ Keyword Overlap (if JD provided) βββββββββββββββββββββββββββββ | |
| kw = results["kw_overlap"] | |
| if kw["matched"] or kw["missing"]: | |
| st.markdown('<p class="section-header">π Keyword Overlap with JD</p>', unsafe_allow_html=True) | |
| k1, k2 = st.columns(2) | |
| with k1: | |
| st.metric("Overlap", f"{kw['overlap_pct']}%") | |
| if kw["matched"]: | |
| st.markdown("**β Matched:**") | |
| st.markdown(", ".join(kw["matched"][:10])) | |
| with k2: | |
| if kw["missing"]: | |
| st.markdown("**β Missing from JD:**") | |
| st.markdown(", ".join(kw["missing"][:10])) | |
| # ββ Suggestions ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| st.markdown("---") | |
| st.markdown("## π‘ Improvement Suggestions") | |
| for suggestion in results["suggestions"]: | |
| st.markdown( | |
| f'<div class="suggestion-item">{suggestion}</div>', | |
| unsafe_allow_html=True, | |
| ) | |
| # ββ Resume Text Preview βββββββββββββββββββββββββββββββββββββββββββββββ | |
| st.markdown("---") | |
| st.markdown("## π Resume Preview") | |
| word_count = len(resume_text.split()) | |
| st.caption(f"Word count: {word_count}") | |
| with st.expander("View extracted resume text", expanded=False): | |
| st.text_area( | |
| "Resume Text (read-only preview)", | |
| value=resume_text, | |
| height=300, | |
| disabled=True, | |
| key="preview_text", | |
| ) | |
| # --------------------------------------------------------------------------- | |
| # BUTTON 1 β Analyze Resume | |
| # --------------------------------------------------------------------------- | |
| st.markdown("<br>", unsafe_allow_html=True) | |
| analyze_col, _, _ = st.columns([1, 1, 1]) | |
| with analyze_col: | |
| analyze_clicked = st.button( | |
| "π Analyze Resume", | |
| type="primary", | |
| use_container_width=True, | |
| disabled=(uploaded_file is None), | |
| ) | |
| if analyze_clicked: | |
| if uploaded_file is None: | |
| st.error("Please upload a resume file first.") | |
| else: | |
| # Parse the uploaded file | |
| with st.spinner("π Extracting text from resume..."): | |
| parse_result = parse_resume(uploaded_file) | |
| if parse_result["error"]: | |
| st.error(f"β {parse_result['error']}") | |
| st.stop() | |
| resume_text = parse_result["text"] | |
| if len(resume_text.split()) < 20: | |
| st.warning( | |
| "β οΈ Very little text was extracted. " | |
| "The file might be image-based. Try a text-based PDF or DOCX." | |
| ) | |
| # Store editable text in session state | |
| st.session_state.resume_text_editable = resume_text | |
| # Run full pipeline | |
| results = run_analysis(resume_text, job_description) | |
| st.session_state.results = results | |
| st.success("β Analysis complete!") | |
| # Render results if available | |
| if st.session_state.results: | |
| render_results(st.session_state.results, st.session_state.resume_text_editable) | |
| # --------------------------------------------------------------------------- | |
| # BUTTON 2 β Generate Improved Resume (AI Rewrite) | |
| # --------------------------------------------------------------------------- | |
| if st.session_state.results: | |
| st.markdown("---") | |
| st.markdown("## π€ AI Resume Improvement") | |
| st.markdown( | |
| "Click below to generate an **ATS-optimized rewrite** of your resume " | |
| "using FLAN-T5. The AI will use professional tone, bullet points, " | |
| "and strong action verbs." | |
| ) | |
| gen_col, _ = st.columns([1, 2]) | |
| with gen_col: | |
| gen_clicked = st.button( | |
| "β¨ Generate Improved Resume", | |
| use_container_width=True, | |
| ) | |
| if gen_clicked: | |
| with st.spinner("π€ AI is rewriting your resume... (this may take 30β60 seconds on CPU)"): | |
| improved = generate_improved_resume( | |
| st.session_state.resume_text_editable, | |
| job_description, | |
| st.session_state.results.get("missing_sections", []), | |
| ) | |
| st.session_state.improved_resume = improved | |
| if st.session_state.improved_resume: | |
| st.markdown("### β AI-Generated Improved Resume") | |
| st.text_area( | |
| "You can copy this improved resume text:", | |
| value=st.session_state.improved_resume, | |
| height=400, | |
| key="improved_output", | |
| ) | |
| st.info( | |
| "π‘ **Tip:** Copy this improved text, paste it into the editor below, " | |
| "and click **Re-evaluate** to see your new ATS score!" | |
| ) | |
| # ββ Professional Summary Generator βββββββββββββββββββββββββββββββββββββ | |
| with st.expander("ποΈ Generate Professional Summary"): | |
| summ_col, _ = st.columns([1, 2]) | |
| with summ_col: | |
| target_role = st.text_input( | |
| "Target job title (optional)", | |
| placeholder="e.g., Backend Engineer", | |
| ) | |
| summ_btn = st.button("Generate Summary", key="summ_btn") | |
| if summ_btn: | |
| with st.spinner("Generating summary..."): | |
| summary = generate_professional_summary( | |
| name=st.session_state.results["entities"].get("name", ""), | |
| skills=st.session_state.results["skills"].get("technical", []), | |
| experience_present=st.session_state.results["sections"].get( | |
| "experience", False | |
| ), | |
| job_title=target_role, | |
| ) | |
| st.markdown("**Generated Summary:**") | |
| st.markdown(f"> {summary}") | |
| # --------------------------------------------------------------------------- | |
| # BUTTON 3 β Resume Editor + Re-evaluation | |
| # --------------------------------------------------------------------------- | |
| if st.session_state.results: | |
| st.markdown("---") | |
| st.markdown("## βοΈ Edit & Re-evaluate Resume") | |
| st.markdown( | |
| "Edit your resume text below (paste the AI-improved version or make " | |
| "manual changes) and click **Re-evaluate** to get a fresh ATS score." | |
| ) | |
| edited_text = st.text_area( | |
| "Edit your resume here:", | |
| value=st.session_state.resume_text_editable, | |
| height=400, | |
| key="editor_area", | |
| help="Make any changes to improve your resume, then click Re-evaluate.", | |
| ) | |
| re_eval_col, _ = st.columns([1, 2]) | |
| with re_eval_col: | |
| re_eval_clicked = st.button( | |
| "π Re-evaluate Edited Resume", | |
| type="secondary", | |
| use_container_width=True, | |
| ) | |
| if re_eval_clicked: | |
| if not edited_text.strip(): | |
| st.error("The editor is empty. Please paste your resume text.") | |
| else: | |
| # Update session state | |
| st.session_state.resume_text_editable = edited_text | |
| with st.spinner("π Re-analyzing your edited resume..."): | |
| new_results = run_analysis(edited_text, job_description) | |
| st.session_state.results = new_results | |
| st.success("β Re-evaluation complete!") | |
| st.markdown("### π Updated Results") | |
| render_results(new_results, edited_text) | |
| # --------------------------------------------------------------------------- | |
| # Footer | |
| # --------------------------------------------------------------------------- | |
| st.markdown("---") | |
| st.markdown( | |
| """ | |
| <div style="text-align:center; color:#6c757d; font-size:0.85rem; padding: 1rem 0;"> | |
| π― AI Resume ATS Analyzer β Built with open-source AI tools<br> | |
| Powered by FLAN-T5 Β· Sentence-BERT Β· spaCy Β· PyMuPDF | |
| </div> | |
| """, | |
| unsafe_allow_html=True, | |
| ) |