Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion backend/analyzer/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -408,13 +412,16 @@ 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,
"readability_score": readability_score,
"readability_label": readability_label,
"skills_found": detected,
"suggestions": suggestions,
"quantify_nudges": quantify_nudges,
"matched_skills": matched,
"missing_skills": missing,
"target_role": target_role,
Expand Down
107 changes: 107 additions & 0 deletions backend/resume_analyzer/quantify_checker.py
Original file line number Diff line number Diff line change
@@ -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
52 changes: 52 additions & 0 deletions backend/resume_analyzer/test_quantify_checker.py
Original file line number Diff line number Diff line change
@@ -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"])
19 changes: 12 additions & 7 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,14 +51,14 @@ 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 {
exportActionPlanMarkdown,
exportActionPlanPdf,
generateActionPlan,
} from './utils/actionPlanUtils'

type Theme = 'light' | 'dark'

interface UndoState {
Expand Down Expand Up @@ -161,21 +161,24 @@ const SuggestionCard: React.FC<SuggestionCardProps> = ({ text, index, backendUrl
}
}

const isQuantify = text.startsWith('QUANTIFY:')
const displayText = isQuantify ? text.replace('QUANTIFY:', '').trim() : text

return (
<div className="suggestion-card">
<div className="suggestion-card" style={isQuantify ? { borderLeft: '4px solid #3b82f6' } : {}}>
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '10px' }}>
<span style={{ fontSize: '16px' }}>πŸ’‘</span>
<span style={{ fontSize: '16px' }}>{isQuantify ? 'πŸ“Š' : 'πŸ’‘'}</span>
<span
style={{
fontSize: '12px',
fontWeight: '700',
color: 'var(--color-primary)',
color: isQuantify ? '#3b82f6' : 'var(--color-primary)',
textTransform: 'uppercase',
letterSpacing: '0.5px',
}}
>
Recommendation #{index + 1}
{isQuantify ? 'Quantify Achievement' : `Recommendation #${index + 1}`}
</span>
</div>
<p
Expand All @@ -186,7 +189,7 @@ const SuggestionCard: React.FC<SuggestionCardProps> = ({ text, index, backendUrl
lineHeight: '1.6',
}}
>
{text}
{displayText}
</p>
</div>

Expand Down Expand Up @@ -292,7 +295,7 @@ function App() {
const [score, setScore] = useState<number | null>(null)
const [skills, setSkills] = useState<string[]>([])
const [suggestions, setSuggestions] = useState<string[]>([])

const [quantifyNudges, setQuantifyNudges] = useState<QuantifyNudge[]>([])
const [readabilityLabel, setReadabilityLabel] = useState<string | null>(null)
const [undoState, setUndoState] = useState<UndoState | null>(null)
const [showUndoToast, setShowUndoToast] = useState(false)
Expand Down Expand Up @@ -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 || '')
Expand Down Expand Up @@ -2172,6 +2176,7 @@ function App() {
</div>
)}

<QuantifyNudges nudges={quantifyNudges} />
<CuratedTips targetRole={targetRole} />

<div style={{ marginTop: '24px', textAlign: 'center' }}>
Expand Down
19 changes: 19 additions & 0 deletions frontend/src/QuantifyNudges.css
Original file line number Diff line number Diff line change
@@ -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; }
73 changes: 73 additions & 0 deletions frontend/src/QuantifyNudges.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="nudge-card" role="listitem">
<div className="nudge-header" onClick={() => setExpanded(p => !p)}
role="button" tabIndex={0} onKeyDown={(e) => e.key === "Enter" && setExpanded(p => !p)}>
<div className="nudge-badge">
<span>πŸ“Š</span>
<span className="nudge-label">Bullet #{index + 1} needs a metric</span>
</div>
<span>{expanded ? "β–²" : "β–Ό"}</span>
</div>
<div className="nudge-original">
<span className="nudge-tag">Original</span>
<blockquote className="nudge-quote">{nudge.original_text}</blockquote>
</div>
{expanded && (
<div className="nudge-details">
<p className="nudge-suggestion">πŸ’‘ {nudge.suggestion}</p>
<div className="nudge-hint-box">
<span className="nudge-tag nudge-tag--hint">Example fix</span>
<p className="nudge-hint-text">{nudge.hint}</p>
</div>
</div>
)}
</div>
);
};

const QuantifyNudges: React.FC<Props> = ({ nudges }) => {
if (!nudges || nudges.length === 0) {
return (
<div className="quantify-nudges quantify-nudges--empty">
<span>βœ…</span>
<p>All achievement bullets are already quantified. Great work!</p>
</div>
);
}
return (
<section className="quantify-nudges">
<div className="quantify-nudges__header">
<h3 className="quantify-nudges__title">πŸ“Š Quantify Your Achievements</h3>
<span className="quantify-nudges__count">
{nudges.length} bullet{nudges.length !== 1 ? "s" : ""} need a metric
</span>
</div>
<p className="quantify-nudges__desc">
Numbers make bullets stronger. Each item below is missing a quantifiable detail.
</p>
<div role="list">
{nudges.map((nudge, i) => (
<NudgeCard key={nudge.line_index} nudge={nudge} index={i} />
))}
</div>
</section>
);
};

export default QuantifyNudges;
Loading