diff --git a/backend/analyzer/services.py b/backend/analyzer/services.py index 7d4c56cd..225dd3b7 100644 --- a/backend/analyzer/services.py +++ b/backend/analyzer/services.py @@ -5,6 +5,7 @@ from django.contrib.auth import get_user_model from .models import ResumeAnalysis from .skill_matcher import extract_skills +from resume_analyzer.quantify_checker import flag_unquantified_bullets User = get_user_model() @@ -327,7 +328,10 @@ def analyze_resume(file_path, target_role, file_name="resume.pdf", user_id=None, matched = [] missing = [] - required = ROLE_SKILLS.get(target_role, []) + if job_description and job_description.strip(): + required = extract_skills(job_description) + else: + required = ROLE_SKILLS.get(target_role, []) for skill in required: if skill in detected: @@ -408,6 +412,8 @@ def analyze_resume(file_path, target_role, file_name="resume.pdf", user_id=None, "suggestions": role_suggestions, } + quantify_nudges = flag_unquantified_bullets(raw_text.split('\n')) + return { "id": analysis_id, "score": score, @@ -415,6 +421,7 @@ def analyze_resume(file_path, target_role, file_name="resume.pdf", user_id=None, "readability_label": readability_label, "skills_found": detected, "suggestions": suggestions, + "quantify_nudges": quantify_nudges, "matched_skills": matched, "missing_skills": missing, "target_role": target_role, diff --git a/backend/resume_analyzer/quantify_checker.py b/backend/resume_analyzer/quantify_checker.py new file mode 100644 index 00000000..0ab08748 --- /dev/null +++ b/backend/resume_analyzer/quantify_checker.py @@ -0,0 +1,107 @@ +import re +from typing import TypedDict + +NUMERIC_PATTERN = re.compile( + r""" + \d+ + | \d+[%xX] + | \$[\d,]+ + | [\d,]+\s*(k|m|b)\b + | \b(zero|one|two|three|four|five|six|seven|eight|nine|ten| + hundred|thousand|million|billion|dozen|several|multiple| + first|second|third|half|double|triple|quadruple)\b + | \b\d+\+ + | \b\d+[-]\d+ + """, + re.VERBOSE | re.IGNORECASE, +) + +EXEMPT_PATTERNS = [ + re.compile(r, re.IGNORECASE) + for r in [ + r"^(objective|summary|profile|about|overview)\s*[:\-]", + r"^(skills?|technologies|tools?|languages?|frameworks?)\s*[:\-]", + r"@", + r"\b(university|college|school|bachelor|master|phd|b\.?s\.?|m\.?s\.?|gpa)\b", + r"\b(january|february|march|april|may|june|july|august|september|october|november|december)\b", + r"^\s*references\b", + ] +] + +ACCOMPLISHMENT_VERBS = re.compile( + r"\b(achiev|improv|increas|decreas|reduc|optimiz|accelerat|deliver|" + r"develop|launch|lead|led|manag|built|build|creat|design|implement|automat|" + r"deploy|drove|generat|saved|cut|grew|scal|streamlin|transform|" + r"boost|enhanc|resolv|migrat|refactor|consolidat|negoti|complet|" + r"execut|oversaw|coordin|establish|spearhead|champion)\w*", + re.IGNORECASE, +) + +METRIC_PROMPTS = { + "improv": "e.g. 'improved load time by 40%'", + "increas": "e.g. 'increased revenue by $50k'", + "decreas": "e.g. 'decreased bug count by 60%'", + "reduc": "e.g. 'reduced deployment time from 2h to 15 min'", + "optimiz": "e.g. 'optimized query time from 3s to 200ms'", + "lead": "e.g. 'led a team of 5 engineers'", + "led": "e.g. 'led a team of 5 engineers'", + "manag": "e.g. 'managed 3 direct reports'", + "develop": "e.g. 'developed 3 REST APIs serving 10k daily users'", + "launch": "e.g. 'launched feature used by 5,000+ customers'", + "built": "e.g. 'built a pipeline processing 1M records/day'", + "automat": "e.g. 'automated 8 workflows, saving 20 hrs/week'", + "deploy": "e.g. 'deployed 12 microservices to AWS'", + "drove": "e.g. 'drove a 25% increase in conversion rate'", + "saved": "e.g. 'saved $30k/year in infrastructure costs'", + "grew": "e.g. 'grew user base from 500 to 5,000 in 6 months'", + "scal": "e.g. 'scaled system to handle 100k concurrent requests'", + "resolv": "e.g. 'resolved 95% of P1 incidents within SLA'", + "default": "Try adding a number, percentage, dollar amount, or time saved.", +} + +class QuantifyNudge(TypedDict): + line_index: int + original_text: str + suggestion: str + hint: str + +def is_exempt(line: str) -> bool: + stripped = line.strip().lstrip("-*▪▸ \t") + if len(stripped) < 15: + return True + for pat in EXEMPT_PATTERNS: + if pat.search(stripped): + return True + return False + +def _pick_hint(line: str) -> str: + for key, hint in METRIC_PROMPTS.items(): + if key == "default": + continue + if re.search(r"\b" + key, line, re.IGNORECASE): + return hint + return METRIC_PROMPTS["default"] + +def flag_unquantified_bullets(bullets: list[str]) -> list[QuantifyNudge]: + nudges: list[QuantifyNudge] = [] + for idx, raw in enumerate(bullets): + line = raw.strip() + if not line: + continue + if is_exempt(line): + continue + if not ACCOMPLISHMENT_VERBS.search(line): + continue + if NUMERIC_PATTERN.search(line): + continue + nudges.append(QuantifyNudge( + line_index=idx, + original_text=raw, + suggestion=( + "This bullet describes an accomplishment but lacks a " + "quantifiable detail. Adding a number, percentage, or " + "metric will make it significantly stronger." + ), + hint=_pick_hint(line), + )) + return nudges diff --git a/backend/resume_analyzer/test_quantify_checker.py b/backend/resume_analyzer/test_quantify_checker.py new file mode 100644 index 00000000..c19c02ef --- /dev/null +++ b/backend/resume_analyzer/test_quantify_checker.py @@ -0,0 +1,52 @@ +import pytest +from quantify_checker import flag_unquantified_bullets + +SHOULD_FLAG = [ + "Improved application performance significantly", + "Reduced server downtime and improved reliability", + "Led cross-functional teams to deliver product features", + "Developed RESTful APIs for the internal platform", + "Automated deployment pipeline using GitHub Actions", + "Managed the onboarding process for new developers", + "Optimized database queries for better response times", + "Built a real-time notification system for the app", +] + +SHOULD_NOT_FLAG = [ + "Improved application performance by 40%", + "Led a team of 8 engineers across 3 time zones", + "Summary: Results-driven software engineer with 5 years", + "Skills: Python, Django, React, PostgreSQL", + "Bachelor of Engineering, University of Mumbai, 2023", + "atharv@example.com | Mumbai, India", + "", +] + +class TestFlagUnquantifiedBullets: + def test_flags_accomplishment_without_number(self): + for bullet in SHOULD_FLAG: + nudges = flag_unquantified_bullets([bullet]) + assert len(nudges) == 1, f"Expected flag for: '{bullet}', got {len(nudges)}" + + def test_does_not_flag_already_quantified(self): + for bullet in SHOULD_NOT_FLAG: + nudges = flag_unquantified_bullets([bullet]) + assert len(nudges) == 0, f"Should not flag: '{bullet}'" + + def test_correct_indices(self): + bullets = [ + "Summary: Experienced engineer", + "Improved performance significantly", + "Increased revenue by 25%", + "Automated CI/CD pipeline", + ] + nudges = flag_unquantified_bullets(bullets) + assert len(nudges) == 2 + assert nudges[0]["line_index"] == 1 + assert nudges[1]["line_index"] == 3 + + def test_empty_list(self): + assert flag_unquantified_bullets([]) == [] + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 78bf7f00..9fe10962 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -51,6 +51,7 @@ import { FilePreview } from './components/FilePreview/FilePreview' import { ShareResult } from './components/ShareResult' import { SharedResultView } from './SharedResultView' import CookieConsentBanner from './components/CookieConsentBanner' +import QuantifyNudges, { type QuantifyNudge } from './QuantifyNudges' import AdminDashboard from './components/AdminDashboard' import { ActionPlanChecklist } from './components/ActionPlanChecklist' import { @@ -58,7 +59,6 @@ import { exportActionPlanPdf, generateActionPlan, } from './utils/actionPlanUtils' - type Theme = 'light' | 'dark' interface UndoState { @@ -161,21 +161,24 @@ const SuggestionCard: React.FC = ({ text, index, backendUrl } } + const isQuantify = text.startsWith('QUANTIFY:') + const displayText = isQuantify ? text.replace('QUANTIFY:', '').trim() : text + return ( -
+
- 💡 + {isQuantify ? '📊' : '💡'} - Recommendation #{index + 1} + {isQuantify ? 'Quantify Achievement' : `Recommendation #${index + 1}`}

= ({ text, index, backendUrl lineHeight: '1.6', }} > - {text} + {displayText}

@@ -292,7 +295,7 @@ function App() { const [score, setScore] = useState(null) const [skills, setSkills] = useState([]) const [suggestions, setSuggestions] = useState([]) - + const [quantifyNudges, setQuantifyNudges] = useState([]) const [readabilityLabel, setReadabilityLabel] = useState(null) const [undoState, setUndoState] = useState(null) const [showUndoToast, setShowUndoToast] = useState(false) @@ -670,6 +673,7 @@ function App() { setScore(res.data.score) setSkills(res.data.skills_found || []) setSuggestions(res.data.suggestions || []) + setQuantifyNudges(res.data.quantify_nudges || []) setMatchedSkills(res.data.matched_skills || []) setMissingSkills(res.data.missing_skills || []) setResumeText(res.data.resume_text || '') @@ -2172,6 +2176,7 @@ function App() {
)} +
diff --git a/frontend/src/QuantifyNudges.css b/frontend/src/QuantifyNudges.css new file mode 100644 index 00000000..851d8315 --- /dev/null +++ b/frontend/src/QuantifyNudges.css @@ -0,0 +1,19 @@ +.quantify-nudges { margin: 1.5rem 0; padding: 1.25rem 1.5rem; background: #1e2a3a; border: 1px solid #2d4060; border-left: 4px solid #f59e0b; border-radius: 10px; } +.quantify-nudges--empty { display: flex; align-items: center; gap: 0.6rem; color: #4ade80; font-size: 0.95rem; } +.quantify-nudges__header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.5rem; } +.quantify-nudges__title { font-size: 1.05rem; font-weight: 700; color: #f59e0b; margin: 0; } +.quantify-nudges__count { font-size: 0.78rem; font-weight: 600; background: #f59e0b22; color: #f59e0b; border: 1px solid #f59e0b55; border-radius: 999px; padding: 2px 10px; } +.quantify-nudges__desc { font-size: 0.85rem; color: #94a3b8; margin: 0 0 1rem 0; line-height: 1.5; } +.nudge-card { background: #16202e; border: 1px solid #2d4060; border-radius: 8px; margin-bottom: 0.75rem; overflow: hidden; } +.nudge-card:hover { border-color: #f59e0b66; } +.nudge-header { display: flex; justify-content: space-between; align-items: center; padding: 0.65rem 1rem; cursor: pointer; background: #1a2840; } +.nudge-badge { display: flex; align-items: center; gap: 0.4rem; } +.nudge-label { font-size: 0.82rem; font-weight: 600; color: #cbd5e1; } +.nudge-original { padding: 0.6rem 1rem; border-top: 1px solid #2d4060; } +.nudge-quote { margin: 0.3rem 0 0 0; padding-left: 0.8rem; border-left: 3px solid #f59e0b66; font-size: 0.88rem; color: #e2e8f0; font-style: italic; } +.nudge-details { padding: 0.6rem 1rem 0.9rem; border-top: 1px dashed #2d4060; background: #111b27; } +.nudge-suggestion { font-size: 0.85rem; color: #94a3b8; margin: 0 0 0.6rem 0; } +.nudge-hint-box { background: #f59e0b11; border: 1px solid #f59e0b33; border-radius: 6px; padding: 0.5rem 0.75rem; } +.nudge-hint-text { font-size: 0.83rem; color: #fbbf24; margin: 0.25rem 0 0 0; font-style: italic; } +.nudge-tag { font-size: 0.68rem; font-weight: 700; text-transform: uppercase; color: #64748b; background: #1e2a3a; border: 1px solid #2d4060; border-radius: 4px; padding: 1px 6px; display: inline-block; } +.nudge-tag--hint { color: #f59e0b; background: #f59e0b11; border-color: #f59e0b44; } diff --git a/frontend/src/QuantifyNudges.tsx b/frontend/src/QuantifyNudges.tsx new file mode 100644 index 00000000..bd80468b --- /dev/null +++ b/frontend/src/QuantifyNudges.tsx @@ -0,0 +1,73 @@ +import React, { useState } from "react"; +import "./QuantifyNudges.css"; + +export interface QuantifyNudge { + line_index: number; + original_text: string; + suggestion: string; + hint: string; +} + +interface Props { + nudges: QuantifyNudge[]; +} + +const NudgeCard: React.FC<{ nudge: QuantifyNudge; index: number }> = ({ nudge, index }) => { + const [expanded, setExpanded] = useState(false); + return ( +
+
setExpanded(p => !p)} + role="button" tabIndex={0} onKeyDown={(e) => e.key === "Enter" && setExpanded(p => !p)}> +
+ 📊 + Bullet #{index + 1} needs a metric +
+ {expanded ? "▲" : "▼"} +
+
+ Original +
{nudge.original_text}
+
+ {expanded && ( +
+

💡 {nudge.suggestion}

+
+ Example fix +

{nudge.hint}

+
+
+ )} +
+ ); +}; + +const QuantifyNudges: React.FC = ({ nudges }) => { + if (!nudges || nudges.length === 0) { + return ( +
+ +

All achievement bullets are already quantified. Great work!

+
+ ); + } + return ( +
+
+

📊 Quantify Your Achievements

+ + {nudges.length} bullet{nudges.length !== 1 ? "s" : ""} need a metric + +
+

+ Numbers make bullets stronger. Each item below is missing a quantifiable detail. +

+
+ {nudges.map((nudge, i) => ( + + ))} +
+
+ ); +}; + +export default QuantifyNudges;