From 3979714abc45d61fdf923c223a0d8e57b6cc5f9b Mon Sep 17 00:00:00 2001 From: Aryanbuha890 Date: Mon, 27 Jul 2026 16:12:25 +0530 Subject: [PATCH 1/3] feat: Add public skills leaderboard page (#383) --- backend/analyzer/tests.py | 125 +++++++++++ backend/analyzer/urls.py | 10 + backend/analyzer/views.py | 120 ++++++++++- frontend/src/App.tsx | 10 +- frontend/src/components/Navbar.tsx | 3 + frontend/src/components/SkillsLeaderboard.tsx | 199 ++++++++++++++++++ 6 files changed, 465 insertions(+), 2 deletions(-) create mode 100644 frontend/src/components/SkillsLeaderboard.tsx diff --git a/backend/analyzer/tests.py b/backend/analyzer/tests.py index fb468725..909d89f7 100644 --- a/backend/analyzer/tests.py +++ b/backend/analyzer/tests.py @@ -387,3 +387,128 @@ def test_analyze_resume_with_cover_letter(self, mock_open): self.assertIsNotNone(result["cover_letter_feedback"]) self.assertEqual(result["cover_letter_feedback"]["length"]["status"], "Too short") self.assertTrue(result["cover_letter_feedback"]["relevance"]["references_role"]) +<<<<<<< Updated upstream +======= + + +class InterviewQuestionTests(TestCase): + def test_generate_interview_questions_valid(self): + from analyzer.services import generate_interview_questions + + # Test generation with React skill and Frontend Developer target role + questions = generate_interview_questions(["React", "TypeScript"], "Frontend Developer") + self.assertTrue(len(questions) >= 5) + self.assertTrue(len(questions) <= 8) + + # At least one question should be from React or TS + has_tech = any("React" in q or "TypeScript" in q or "virtual DOM" in q or "generics" in q for q in questions) + self.assertTrue(has_tech) + + @patch("analyzer.services.pdfplumber.open") + def test_analyze_resume_generates_interview_questions(self, mock_open): + mock_open.return_value = _fake_pdf("Expert in Python and SQL.") + + result = analyze_resume( + file_path="dummy_resume.pdf", + target_role="Backend Developer", + file_name="resume.pdf", + ) + + self.assertIn("interview_questions", result) + self.assertTrue(len(result["interview_questions"]) >= 5) + + +class JdAnalysisTests(TestCase): + def test_analyze_jd_endpoint(self): + from rest_framework import status + + # Test empty input error + resp = self.client.post("/api/analyze-jd/", {"job_description": ""}) + self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn("error", resp.data) + + # Test valid input analysis with known skill keywords and stop words + jd_text = ( + "We are seeking a React Developer. The candidate should have experience in React, " + "JavaScript, HTML, and CSS. Working with teams to deliver responsive layouts is essential. " + "React and TypeScript are strong plusses. The candidate will work in a fast-paced environment." + ) + resp = self.client.post("/api/analyze-jd/", {"job_description": jd_text}) + self.assertEqual(resp.status_code, status.HTTP_200_OK) + self.assertIn("keywords", resp.data) + + keywords = resp.data["keywords"] + self.assertTrue(len(keywords) > 0) + + # Check that 'react' is recognized and tagged as a skill + react_keyword = next((k for k in keywords if k["text"] == "react"), None) + self.assertIsNotNone(react_keyword) + self.assertEqual(react_keyword["type"], "skill") + self.assertTrue(react_keyword["value"] >= 2) + + # Common English stop words like 'the' or 'and' or corporate fillers like 'candidate' shouldn't be here + texts = [k["text"] for k in keywords] + self.assertNotIn("the", texts) + self.assertNotIn("and", texts) + self.assertNotIn("candidate", texts) + + +class SkillsLeaderboardTests(TestCase): + def test_skills_leaderboard_endpoint(self): + from rest_framework import status + from django.contrib.auth.models import User + from analyzer.models import ResumeAnalysis + + user = User.objects.create_user(username="testuser", password="password123") + ResumeAnalysis.objects.create( + user=user, + file_name="resume1.pdf", + target_role="Frontend Developer", + score=80, + skills_found=["react", "javascript", "html"], + matched_skills=["react", "javascript"], + missing_skills=["typescript", "css"], + ) + ResumeAnalysis.objects.create( + user=user, + file_name="resume2.pdf", + target_role="Backend Developer", + score=50, + skills_found=["python"], + matched_skills=["python"], + missing_skills=["django", "sql"], + ) + ResumeAnalysis.objects.create( + user=user, + file_name="resume3.pdf", + target_role="Frontend Developer", + score=90, + skills_found=["react", "typescript"], + matched_skills=["react", "typescript"], + missing_skills=["css"], + ) + + resp = self.client.get("/api/skills-leaderboard/") + self.assertEqual(resp.status_code, status.HTTP_200_OK) + + self.assertIn("total_analyses", resp.data) + self.assertIn("matched_skills", resp.data) + self.assertIn("missing_skills", resp.data) + self.assertEqual(resp.data["total_analyses"], 3) + + resp_fe = self.client.get("/api/skills-leaderboard/?track=Frontend Developer") + self.assertEqual(resp_fe.status_code, status.HTTP_200_OK) + self.assertEqual(resp_fe.data["total_analyses"], 2) + + matched_fe = resp_fe.data["matched_skills"] + react_item = next((s for s in matched_fe if s["skill"] == "React"), None) + self.assertIsNotNone(react_item) + self.assertEqual(react_item["percentage"], 100) + self.assertEqual(react_item["count"], 2) + + missing_fe = resp_fe.data["missing_skills"] + css_item = next((s for s in missing_fe if s["skill"] == "Css"), None) + self.assertIsNotNone(css_item) + self.assertEqual(css_item["percentage"], 100) + self.assertEqual(css_item["count"], 2) +>>>>>>> Stashed changes diff --git a/backend/analyzer/urls.py b/backend/analyzer/urls.py index 75e467d6..16ef9aae 100644 --- a/backend/analyzer/urls.py +++ b/backend/analyzer/urls.py @@ -16,11 +16,21 @@ suggestion_feedback, get_shared_result, admin_stats_view, +<<<<<<< Updated upstream +======= + analyze_jd_view, + skills_leaderboard_view, +>>>>>>> Stashed changes ) urlpatterns = [ path("upload/", upload_resume), path("compare-uploads/", compare_uploads), +<<<<<<< Updated upstream +======= + path("analyze-jd/", analyze_jd_view), + path("skills-leaderboard/", skills_leaderboard_view), +>>>>>>> Stashed changes path("auth/signup/", signup), path("auth/login/", TokenObtainPairView.as_view()), diff --git a/backend/analyzer/views.py b/backend/analyzer/views.py index 43d406f8..cebb8d70 100644 --- a/backend/analyzer/views.py +++ b/backend/analyzer/views.py @@ -383,4 +383,122 @@ def admin_stats_view(request): "total_analyses": total_analyses, "popular_roles": [{"role": r[0], "count": r[1]} for r in popular_roles if r[0]], "top_missing_skills": [{"skill": s[0], "count": s[1]} for s in top_missing_skills if s[0]] - }) \ No newline at end of file +<<<<<<< Updated upstream + }) +======= + }) + + +import re +from collections import Counter + +STOP_WORDS = { + "a", "about", "above", "after", "again", "against", "all", "am", "an", "and", "any", "are", "aren't", "as", "at", + "be", "because", "been", "before", "being", "below", "between", "both", "but", "by", "can't", "cannot", "could", + "couldn't", "did", "didn't", "do", "does", "doesn't", "doing", "don't", "down", "during", "each", "few", "for", + "from", "further", "had", "hadn't", "has", "hasn't", "have", "haven't", "having", "he", "he'd", "he'll", "he's", + "her", "here", "here's", "hers", "herself", "him", "himself", "his", "how", "how's", "i", "i'd", "i'll", "i'm", + "i've", "if", "in", "into", "is", "isn't", "it", "it's", "its", "itself", "let's", "me", "more", "most", "mustn't", + "my", "myself", "no", "nor", "not", "of", "off", "on", "once", "only", "or", "other", "ought", "our", "ours", + "ourselves", "out", "over", "own", "same", "shan't", "she", "she'd", "she'll", "she's", "should", "shouldn't", + "so", "some", "such", "than", "that", "that's", "the", "their", "theirs", "them", "themselves", "then", "there", + "there's", "these", "they", "they'd", "they'll", "they're", "they've", "this", "those", "through", "to", "too", + "under", "until", "up", "very", "was", "wasn't", "we", "we'd", "we'll", "we're", "we've", "were", "weren't", + "what", "what's", "when", "when's", "where", "where's", "which", "while", "who", "who's", "whom", "why", "why's", + "with", "won't", "would", "wouldn't", "you", "you'd", "you'll", "you're", "you've", "your", "yours", "yourself", + "yourselves", "job", "description", "experience", "role", "team", "work", "responsibilities", "skills", "required", + "looking", "ability", "using", "candidate", "development", "knowledge", "working", "strong", "position", "years" +} + +@api_view(["POST"]) +@permission_classes([AllowAny]) +def analyze_jd_view(request): + job_description = request.data.get("job_description", "") + if not job_description or not job_description.strip(): + return Response({"error": "Job description cannot be empty."}, status=status.HTTP_400_BAD_REQUEST) + + words = re.findall(r"\b[a-zA-Z0-9\-\.\#\+\-]+\b", job_description.lower()) + filtered_words = [w for w in words if w not in STOP_WORDS and len(w) >= 2] + + counter = Counter(filtered_words) + top_items = counter.most_common(30) + + from analyzer.services import ROLE_SKILLS + all_skills = set() + for skills_list in ROLE_SKILLS.values(): + for s in skills_list: + all_skills.add(s.lower()) + + results = [] + for word, count in top_items: + is_skill = (word in all_skills) or (word.title() in all_skills) or (word.upper() in all_skills) + if not is_skill: + for s in all_skills: + if s == word or (len(word) > 3 and word in s): + is_skill = True + break + + results.append({ + "text": word, + "value": count, + "type": "skill" if is_skill else "general" + }) + + return Response({"keywords": results}, status=status.HTTP_200_OK) + + +@api_view(["GET"]) +@permission_classes([AllowAny]) +def skills_leaderboard_view(request): + from django.core.cache import cache + + track = request.query_params.get("track", "") + + cache_key = f"skills_leaderboard_{track}" + cached_data = cache.get(cache_key) + if cached_data is not None: + return Response(cached_data, status=status.HTTP_200_OK) + + analyses = ResumeAnalysis.objects.all() + if track: + analyses = analyses.filter(target_role=track) + + data_list = list(analyses.values_list("matched_skills", "missing_skills")) + total_count = len(data_list) + + matched_counter = Counter() + missing_counter = Counter() + + for matched, missing in data_list: + if isinstance(matched, list): + matched_counter.update(matched) + if isinstance(missing, list): + missing_counter.update(missing) + + top_matched = [ + { + "skill": skill.title(), + "count": count, + "percentage": int(count / total_count * 100) if total_count > 0 else 0 + } + for skill, count in matched_counter.most_common(10) + ] + + top_missing = [ + { + "skill": skill.title(), + "count": count, + "percentage": int(count / total_count * 100) if total_count > 0 else 0 + } + for skill, count in missing_counter.most_common(10) + ] + + response_data = { + "total_analyses": total_count, + "matched_skills": top_matched, + "missing_skills": top_missing + } + + cache.set(cache_key, response_data, 300) + return Response(response_data, status=status.HTTP_200_OK) +>>>>>>> Stashed changes diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 80f5e32a..3b90f079 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect, useCallback } from 'react' -import { Routes, Route } from 'react-router-dom' +import { Routes, Route, useNavigate } from 'react-router-dom' import NotFound from './components/NotFound' import axios from 'axios' import './index.css' @@ -14,6 +14,12 @@ import { InfoTooltip } from './components/InfoTooltip' import { SkillWordCloud } from './components/SkillWordCloud' import { TrackMatrix } from './components/TrackMatrix' import { CoverLetterFeedbackPanel } from './components/CoverLetterFeedbackPanel' +<<<<<<< Updated upstream +======= +import { InterviewQuestionsPanel } from './components/InterviewQuestionsPanel' +import { JdVisualizerPanel } from './components/JdVisualizerPanel' +import { SkillsLeaderboard } from './components/SkillsLeaderboard' +>>>>>>> Stashed changes import { ResetPasswordConfirmPage } from './components/ResetPasswordConfirmPage' import type { TrackComparisons } from './components/TrackMatrix' import { @@ -275,6 +281,7 @@ const SuggestionCard: React.FC = ({ text, index, backendUrl } function App() { + const navigate = useNavigate() const [theme, setTheme] = useState(getInitialTheme) const [loading, setLoading] = useState(false) const [file, setFile] = useState(null) @@ -914,6 +921,7 @@ function App() { onHistoryClick={() => setHistoryOpen(true)} /> + navigate('/')} />} /> } /> } /> } /> diff --git a/frontend/src/components/Navbar.tsx b/frontend/src/components/Navbar.tsx index a4c72d91..af4364bc 100644 --- a/frontend/src/components/Navbar.tsx +++ b/frontend/src/components/Navbar.tsx @@ -81,6 +81,9 @@ export const Navbar: React.FC = ({ setMobileOpen(false)}> Analyze Resume + setMobileOpen(false)}> + 📊 Leaderboard + { diff --git a/frontend/src/components/SkillsLeaderboard.tsx b/frontend/src/components/SkillsLeaderboard.tsx new file mode 100644 index 00000000..5dde3e55 --- /dev/null +++ b/frontend/src/components/SkillsLeaderboard.tsx @@ -0,0 +1,199 @@ +import React, { useState, useEffect } from 'react' +import axios from 'axios' + +interface LeaderboardItem { + skill: string + count: number + percentage: number +} + +interface LeaderboardResponse { + total_analyses: number + matched_skills: LeaderboardItem[] + missing_skills: LeaderboardItem[] +} + +interface SkillsLeaderboardProps { + onBack: () => void +} + +export const SkillsLeaderboard: React.FC = ({ onBack }) => { + const [track, setTrack] = useState('') + const [data, setData] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + const backendUrl = import.meta.env.VITE_BACKEND_URL || 'http://127.0.0.1:8000' + + useEffect(() => { + const fetchLeaderboard = async () => { + setLoading(true) + setError(null) + try { + const url = track + ? `${backendUrl}/api/skills-leaderboard/?track=${encodeURIComponent(track)}` + : `${backendUrl}/api/skills-leaderboard/` + const res = await axios.get(url) + setData(res.data) + } catch (err: any) { + console.error(err) + setError('Failed to load leaderboard statistics. Please try again.') + } finally { + setLoading(false) + } + } + fetchLeaderboard() + }, [track, backendUrl]) + + return ( +
+ {/* Header */} +
+
+

+ 📊 Skills Leaderboard +

+

+ Aggregated, anonymized insights on commonly matched skills and in-demand gaps. +

+
+ +
+ + {/* Filter / Track Tabs */} +
+ {[ + { label: 'All Tracks', value: '' }, + { label: 'Frontend', value: 'Frontend Developer' }, + { label: 'Backend', value: 'Backend Developer' }, + { label: 'Data Analyst', value: 'Data Analyst' } + ].map((item) => ( + + ))} +
+ + {/* Main Content Area */} + {loading ? ( +
+
+

Analyzing aggregate dataset...

+
+ ) : error ? ( +
+

{error}

+ +
+ ) : data ? ( +
+ {/* Metadata Banner */} +
+ + Total Resumes Aggregated: {data.total_analyses} + + + ✓ Anonymity Guaranteed + +
+ +
+ {/* Left Column: Top Matched Skills */} +
+

+ 🔥 Top Skills Possessed +

+

+ Skills commonly matching the career track requirements. +

+ + {data.matched_skills.length === 0 ? ( +

No data collected yet.

+ ) : ( +
+ {data.matched_skills.map((item, index) => ( +
+
+ + {index + 1}. {item.skill} + + + {item.percentage}% ({item.count}) + +
+
+
+
+
+ ))} +
+ )} +
+ + {/* Right Column: Top Skill Gaps */} +
+

+ ⚠️ Top Skill Gaps (In-Demand) +

+

+ Highly sought-after skills that applicants frequently miss. +

+ + {data.missing_skills.length === 0 ? ( +

No data collected yet.

+ ) : ( +
+ {data.missing_skills.map((item, index) => ( +
+
+ + {index + 1}. {item.skill} + + + {item.percentage}% ({item.count}) + +
+
+
+
+
+ ))} +
+ )} +
+
+
+ ) : null} +
+ ) +} From d38b408dae614d4dce9436206714d2b0e6f54273 Mon Sep 17 00:00:00 2001 From: Aryanbuha890 Date: Mon, 27 Jul 2026 20:10:36 +0530 Subject: [PATCH 2/3] fix: Resolve backend conflict markers and restore leaderboard tests --- backend/analyzer/tests.py | 23 +++++++++++++++++++++++ backend/analyzer/urls.py | 6 ------ backend/analyzer/views.py | 5 +---- 3 files changed, 24 insertions(+), 10 deletions(-) diff --git a/backend/analyzer/tests.py b/backend/analyzer/tests.py index fb468725..92535a1f 100644 --- a/backend/analyzer/tests.py +++ b/backend/analyzer/tests.py @@ -387,3 +387,26 @@ def test_analyze_resume_with_cover_letter(self, mock_open): self.assertIsNotNone(result["cover_letter_feedback"]) self.assertEqual(result["cover_letter_feedback"]["length"]["status"], "Too short") self.assertTrue(result["cover_letter_feedback"]["relevance"]["references_role"]) + + +class SkillsLeaderboardTests(TestCase): + def setUp(self): + from django.core.cache import cache + self.client = APIClient() + self.url = "/api/skills-leaderboard/" + cache.clear() + + def test_leaderboard_endpoint(self): + from analyzer.models import ResumeAnalysis + ResumeAnalysis.objects.create( + file_name="res1.pdf", + target_role="Frontend Developer", + score=80, + matched_skills=["react", "javascript", "html"], + missing_skills=["css"], + suggestions=[] + ) + resp = self.client.get(self.url) + self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.data["total_analyses"], 1) + self.assertTrue(len(resp.data["matched_skills"]) > 0) diff --git a/backend/analyzer/urls.py b/backend/analyzer/urls.py index 16ef9aae..a982bbaf 100644 --- a/backend/analyzer/urls.py +++ b/backend/analyzer/urls.py @@ -16,21 +16,15 @@ suggestion_feedback, get_shared_result, admin_stats_view, -<<<<<<< Updated upstream -======= analyze_jd_view, skills_leaderboard_view, ->>>>>>> Stashed changes ) urlpatterns = [ path("upload/", upload_resume), path("compare-uploads/", compare_uploads), -<<<<<<< Updated upstream -======= path("analyze-jd/", analyze_jd_view), path("skills-leaderboard/", skills_leaderboard_view), ->>>>>>> Stashed changes path("auth/signup/", signup), path("auth/login/", TokenObtainPairView.as_view()), diff --git a/backend/analyzer/views.py b/backend/analyzer/views.py index cebb8d70..0dda890f 100644 --- a/backend/analyzer/views.py +++ b/backend/analyzer/views.py @@ -383,9 +383,6 @@ def admin_stats_view(request): "total_analyses": total_analyses, "popular_roles": [{"role": r[0], "count": r[1]} for r in popular_roles if r[0]], "top_missing_skills": [{"skill": s[0], "count": s[1]} for s in top_missing_skills if s[0]] -<<<<<<< Updated upstream - }) -======= }) @@ -501,4 +498,4 @@ def skills_leaderboard_view(request): cache.set(cache_key, response_data, 300) return Response(response_data, status=status.HTTP_200_OK) ->>>>>>> Stashed changes + From f018f10f2d8f04ce5dccd645012808dfc5c61e26 Mon Sep 17 00:00:00 2001 From: Aryanbuha890 Date: Tue, 28 Jul 2026 14:14:08 +0530 Subject: [PATCH 3/3] fix: Import JdVisualizerPanel in App.tsx to resolve CI build failure --- frontend/src/App.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b2e7c95c..23a68536 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -16,6 +16,7 @@ import { TrackMatrix } from './components/TrackMatrix' import { CoverLetterFeedbackPanel } from './components/CoverLetterFeedbackPanel' import { InterviewQuestionsPanel } from './components/InterviewQuestionsPanel' import { SkillsLeaderboard } from './components/SkillsLeaderboard' +import { JdVisualizerPanel } from './components/JdVisualizerPanel' import { ResetPasswordConfirmPage } from './components/ResetPasswordConfirmPage' import type { TrackComparisons } from './components/TrackMatrix' import {