From 2bc2278e25538cf345613dd27177f523c28a45f3 Mon Sep 17 00:00:00 2001 From: SagiEv Date: Sun, 13 Sep 2026 18:35:38 +0300 Subject: [PATCH 01/10] feature implemented --- backend/ai_service/main.py | 2 + backend/ai_service/role_fit/models.py | 12 ++ backend/ai_service/role_fit/router.py | 52 ++++++ backend/config/fitRules.json | 68 +++++++ .../controllers/applications.controller.js | 53 +++++- backend/migrations/003_add_fit_analysis.sql | 10 + backend/scripts/backfillFitScores.js | 80 ++++++++ backend/services/fitAnalysis.service.js | 172 ++++++++++++++++++ backend/tests/fitAnalysis.test.js | 75 ++++++++ frontend/src/components/RoleFitAnalysis.jsx | 53 ++++++ frontend/src/pages/AnalyticsPage.jsx | 100 ++++++++++ frontend/src/pages/ApplicationDetailPage.jsx | 3 + frontend/src/pages/ApplicationsPage.jsx | 15 ++ frontend/src/pages/SettingsPage.jsx | 21 ++- 14 files changed, 713 insertions(+), 3 deletions(-) create mode 100644 backend/ai_service/role_fit/models.py create mode 100644 backend/ai_service/role_fit/router.py create mode 100644 backend/config/fitRules.json create mode 100644 backend/migrations/003_add_fit_analysis.sql create mode 100644 backend/scripts/backfillFitScores.js create mode 100644 backend/services/fitAnalysis.service.js create mode 100644 backend/tests/fitAnalysis.test.js create mode 100644 frontend/src/components/RoleFitAnalysis.jsx diff --git a/backend/ai_service/main.py b/backend/ai_service/main.py index 38c6378..e65a755 100644 --- a/backend/ai_service/main.py +++ b/backend/ai_service/main.py @@ -6,6 +6,7 @@ from message_creator.router import router as message_router from interview_analyzer.router import router as interview_router from embed_router import router as embed_router +from role_fit.router import router as fit_router logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) @@ -18,6 +19,7 @@ app.include_router(message_router, tags=["Networking Message Creator"]) app.include_router(interview_router, tags=["Interview Analyzer"]) app.include_router(embed_router, tags=["Embeddings"]) +app.include_router(fit_router, tags=["Role Fit Analysis"]) @app.get("/health") def health_check(): diff --git a/backend/ai_service/role_fit/models.py b/backend/ai_service/role_fit/models.py new file mode 100644 index 0000000..b00ba30 --- /dev/null +++ b/backend/ai_service/role_fit/models.py @@ -0,0 +1,12 @@ +from pydantic import BaseModel, Field + +class FitAnalysisRequest(BaseModel): + job_description: str + candidate_data: dict + api_keys: dict + provider: str = "groq" + +class FitAnalysisResponse(BaseModel): + ai_score: int = Field(description="Score from 0 to 100 representing the fit.") + short_summary: str = Field(description="A concise 1-2 sentence explanation of the fit.") + percentage_matches: dict = Field(description="Dictionary with match percentages, e.g., {'Skills Match': '80%', 'Experience Match': '60%'}") diff --git a/backend/ai_service/role_fit/router.py b/backend/ai_service/role_fit/router.py new file mode 100644 index 0000000..03bf449 --- /dev/null +++ b/backend/ai_service/role_fit/router.py @@ -0,0 +1,52 @@ +from fastapi import APIRouter, HTTPException +import json +from langchain_core.prompts import ChatPromptTemplate +from role_fit.models import FitAnalysisRequest, FitAnalysisResponse +from llm import get_fast_llm + +router = APIRouter(prefix="/role-fit", tags=["Role Fit Analysis"]) + +PROMPT_TEMPLATE = """ +You are an expert technical recruiter analyzing how well a candidate fits a job description. + +Job Description: +{job_description} + +Candidate Data: +{candidate_data} + +Evaluate the candidate based on: +1. Skills required vs skills possessed. +2. Experience required vs experience possessed. +3. Role seniority alignment. +4. General tech stack similarity. + +Provide a short, concise summary of the fit. Do not invent experience or skills. + +Return the result matching this JSON structure: +- ai_score: Integer from 0 to 100. +- short_summary: A 1-2 sentence explanation. +- percentage_matches: A dictionary showing percentage fits like {{"Skills Match": "80%", "Experience Match": "60%"}}. +""" + +@router.post("/analyze", response_model=FitAnalysisResponse) +async def analyze_fit(request: FitAnalysisRequest): + try: + llm = get_fast_llm(request.api_keys, request.provider) + + # We use structured output to guarantee JSON format + structured_llm = llm.with_structured_output(FitAnalysisResponse) + + prompt = ChatPromptTemplate.from_template(PROMPT_TEMPLATE) + + chain = prompt | structured_llm + + result = chain.invoke({ + "job_description": request.job_description, + "candidate_data": json.dumps(request.candidate_data) + }) + + return result + + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) diff --git a/backend/config/fitRules.json b/backend/config/fitRules.json new file mode 100644 index 0000000..209f6e0 --- /dev/null +++ b/backend/config/fitRules.json @@ -0,0 +1,68 @@ +{ + "experienceRegexes": [ + "up to (\\d+)\\s*years?", + "(\\d+)\\s*(?:-|to|–|and)\\s*(\\d+)\\s*years?", + "(\\d+)\\+?\\s*years?" + ], + "seniorityMap": { + "junior": [0, 2], + "entry level": [0, 2], + "graduate": [0, 1], + "intern": [0, 1], + "mid": [3, 5], + "intermediate": [3, 5], + "senior": [4, 99], + "lead": [5, 99], + "team lead": [5, 99], + "staff": [7, 99], + "principal": [8, 99], + "architect": [8, 99] + }, + "skillSynonyms": { + "react": ["react.js", "react js", "reactjs"], + "node": ["node.js", "node js", "nodejs", "node.js"], + "aws": ["amazon web services"], + "gcp": ["google cloud platform", "google cloud"], + "azure": ["microsoft azure"], + "js": ["javascript"], + "ts": ["typescript"], + "postgres": ["postgresql"], + "vue": ["vue.js", "vue js", "vuejs"], + "angular": ["angular.js", "angular js", "angularjs", "angular 2+"], + "k8s": ["kubernetes", "k3s"], + "go": ["golang"], + "cpp": ["c++"], + "csharp": ["c#", "c sharp"], + "python": ["python3"], + "java": ["j2ee", "java ee", "java 8", "java 11", "java 17"], + "ruby": ["ruby on rails", "rails"], + "django": ["django framework"], + "flask": ["flask framework"], + "spring": ["spring boot", "springboot"], + "dotnet": [".net", "dot net", ".net core", "dotnet core"], + "mongo": ["mongodb"], + "elastic": ["elasticsearch", "elk"], + "rabbit": ["rabbitmq"], + "kafka": ["apache kafka"], + "spark": ["apache spark"], + "hadoop": ["apache hadoop"], + "redis": ["redis cache"], + "git": ["github", "gitlab", "bitbucket", "version control"], + "ci/cd": ["cicd", "continuous integration", "continuous deployment", "continuous delivery"], + "ml": ["machine learning"], + "ai": ["artificial intelligence", "genai", "generative ai", "llm", "large language models"], + "nlp": ["natural language processing"], + "cv": ["computer vision"], + "ui/ux": ["ui", "ux", "user interface", "user experience"], + "css": ["css3", "sass", "scss", "less", "tailwind", "tailwindcss"], + "html": ["html5"], + "rest": ["rest api", "restful", "restful api"], + "graphql": ["graph ql"], + "docker": ["containerization", "containers"], + "linux": ["unix", "ubuntu", "centos", "debian"], + "agile": ["scrum", "kanban"], + "qa": ["quality assurance", "testing", "automation testing"], + "seo": ["search engine optimization"] + }, + "defaultSeniorityRange": [0, 2] +} diff --git a/backend/controllers/applications.controller.js b/backend/controllers/applications.controller.js index aa50163..6d568b1 100644 --- a/backend/controllers/applications.controller.js +++ b/backend/controllers/applications.controller.js @@ -1,4 +1,6 @@ const applicationService = require('../services/applications.service'); +const fitAnalysisService = require('../services/fitAnalysis.service'); +const axios = require('axios'); const getAll = async (req, res) => { try { @@ -22,7 +24,56 @@ const create = async (req, res) => { return res.status(401).json({ error: "Unauthorized: User not found" }); } const userId = req.user.id; - const data = await applicationService.createApplication(userId, req.body, req.supabase); + + let applicationData = { ...req.body }; + + // 1. Calculate deterministic fit synchronously if JD is provided + if (applicationData.info) { + try { + const { candidateData } = await fitAnalysisService.getFitContext(userId, req.supabase); + const scoreData = fitAnalysisService.calculateDeterministicFit(candidateData, applicationData.info); + applicationData.fit_score_deterministic = scoreData.score; + } catch (err) { + console.error("Error calculating deterministic fit:", err); + } + } + + const data = await applicationService.createApplication(userId, applicationData, req.supabase); + + // Trigger AI correctly now that we have the app ID + if (applicationData.info && applicationData.fit_score_deterministic !== undefined) { + fitAnalysisService.getFitContext(userId, req.supabase) + .then(({ candidateData, fitConfig }) => { + if (fitConfig.enabled !== false && fitConfig.provider) { + const aiProvider = fitConfig.provider; + + // Ensure we have an API key for the chosen provider before firing + const envKey = `${aiProvider.toUpperCase()}_API_KEY`; + if (!process.env[envKey]) { + console.warn(`Skipping AI Fit Analysis: No API key found in backend for provider '${aiProvider}'.`); + return; + } + + // We don't await this, let it run in the background + axios.post(`${process.env.AI_SERVICE_URL}/role-fit/analyze`, { + job_description: applicationData.info, + candidate_data: candidateData, + api_keys: { + groq: process.env.GROQ_API_KEY, + openai: process.env.OPENAI_API_KEY, + anthropic: process.env.ANTHROPIC_API_KEY, + gemini: process.env.GEMINI_API_KEY + }, + provider: aiProvider + }).then(async (response) => { + if (response.data) { + await req.supabase.from('applications').update({ fit_analysis_ai: response.data }).eq('id', data.id).eq('user_id', userId); + } + }).catch(err => console.error('AI Fit Analysis failed:', err.message)); + } + }).catch(err => console.error("Error triggering AI:", err)); + } + res.json(data); } catch (error) { console.error("POST /api/applications error:", error); diff --git a/backend/migrations/003_add_fit_analysis.sql b/backend/migrations/003_add_fit_analysis.sql new file mode 100644 index 0000000..7c6a806 --- /dev/null +++ b/backend/migrations/003_add_fit_analysis.sql @@ -0,0 +1,10 @@ +-- Add deterministic score column +ALTER TABLE applications +ADD COLUMN fit_score_deterministic INTEGER CHECK (fit_score_deterministic >= 0 AND fit_score_deterministic <= 100); + +-- Add AI analysis JSONB column +ALTER TABLE applications +ADD COLUMN fit_analysis_ai JSONB; + +-- Create an index to speed up analytics queries filtering by fit score +CREATE INDEX idx_applications_fit_score ON applications(fit_score_deterministic); diff --git a/backend/scripts/backfillFitScores.js b/backend/scripts/backfillFitScores.js new file mode 100644 index 0000000..dcb80ec --- /dev/null +++ b/backend/scripts/backfillFitScores.js @@ -0,0 +1,80 @@ +const { createClient } = require('@supabase/supabase-js'); +const fitAnalysisService = require('../services/fitAnalysis.service'); +require('dotenv').config({ path: '../.env' }); + +const supabaseUrl = process.env.SUPABASE_URL; +const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY; + +if (!supabaseUrl || !supabaseKey) { + console.error("Missing SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY in environment variables."); + process.exit(1); +} + +const supabase = createClient(supabaseUrl, supabaseKey); + +async function backfillFitScores() { + console.log("Starting backfill of deterministic fit scores..."); + + // Fetch all applications that have job info but no deterministic score + const { data: applications, error: appsError } = await supabase + .from('applications') + .select('id, user_id, info') + .not('info', 'is', null) + .is('fit_score_deterministic', null); + + if (appsError) { + console.error("Error fetching applications:", appsError.message); + return; + } + + if (!applications || applications.length === 0) { + console.log("No applications need backfilling."); + return; + } + + console.log(`Found ${applications.length} applications to process.`); + + let successCount = 0; + let errorCount = 0; + + for (const app of applications) { + try { + // Fetch candidate data + const [profileRes, skillsRes, experiencesRes] = await Promise.all([ + supabase.from('profile').select('*').eq('user_id', app.user_id).single(), + supabase.from('skills').select('*').eq('user_id', app.user_id), + supabase.from('user_experiences').select('*').eq('user_id', app.user_id) + ]); + + const candidateData = { + profile: profileRes.data || null, + skills: skillsRes.data || [], + experiences: experiencesRes.data || [] + }; + + const scoreData = fitAnalysisService.calculateDeterministicFit(candidateData, app.info); + + const { error: updateError } = await supabase + .from('applications') + .update({ fit_score_deterministic: scoreData.score }) + .eq('id', app.id); + + if (updateError) { + console.error(`Failed to update application ${app.id}:`, updateError.message); + errorCount++; + } else { + successCount++; + console.log(`Successfully updated application ${app.id} (Score: ${scoreData.score})`); + } + } catch (err) { + console.error(`Error processing application ${app.id}:`, err.message); + errorCount++; + } + } + + console.log("Backfill completed."); + console.log(`Successfully updated: ${successCount}`); + console.log(`Failed: ${errorCount}`); +} + +backfillFitScores().catch(console.error); diff --git a/backend/services/fitAnalysis.service.js b/backend/services/fitAnalysis.service.js new file mode 100644 index 0000000..3b75da8 --- /dev/null +++ b/backend/services/fitAnalysis.service.js @@ -0,0 +1,172 @@ +const rules = require('../config/fitRules.json'); +const profileRepository = require('../repositories/profile.repository'); +const skillsRepository = require('../repositories/skills.repository'); +const settingsRepository = require('../repositories/settings.repository'); + +// Helper to safely check regex match +function extractExplicitYears(jdText) { + for (const pattern of rules.experienceRegexes) { + const regex = new RegExp(pattern, 'i'); + const match = jdText.match(regex); + if (match) { + // Depending on regex, groups might be min/max or just min + if (match[2]) { // Range e.g. "2-5 years" + return [parseInt(match[1], 10), parseInt(match[2], 10)]; + } else { // Single number e.g. "2+ years" or "up to 2 years" + const val = parseInt(match[1], 10); + if (pattern.includes('up to')) { + return [0, val]; + } + return [val, 99]; // 2+ years + } + } + } + return null; +} + +function inferSeniority(jdText) { + const text = jdText.toLowerCase(); + + // Sort keys by priority (longer/more specific first, e.g., 'team lead' before 'lead') + const sortedKeys = Object.keys(rules.seniorityMap).sort((a, b) => b.length - a.length); + + for (const key of sortedKeys) { + // Use word boundaries for accurate matching + const regex = new RegExp(`\\b${key}\\b`, 'i'); + if (regex.test(text)) { + return rules.seniorityMap[key]; + } + } + return rules.defaultSeniorityRange; // [0, 2] Junior fallback +} + +function normalizeSkill(skillName) { + const lower = skillName.toLowerCase().trim(); + for (const [canonical, synonyms] of Object.entries(rules.skillSynonyms)) { + if (lower === canonical || synonyms.includes(lower)) { + return canonical; + } + } + return lower; +} + +function calculateTotalExperience(candidate) { + if (!candidate.experiences || candidate.experiences.length === 0) { + return 0; + } + + // Sum up years from user_experiences table + return candidate.experiences.reduce((sum, exp) => sum + (exp.years || 0), 0); +} + +exports.calculateDeterministicFit = (candidate, jdText) => { + const reasons = []; + let score = 0; + + const jdTextLower = jdText.toLowerCase(); + + // 1. Resolve Experience Target + let targetRange = extractExplicitYears(jdText); + if (targetRange) { + reasons.push(`Explicit experience requirement found: ${targetRange[0]}${targetRange[1] === 99 ? '+' : '-' + targetRange[1]} years.`); + } else { + targetRange = inferSeniority(jdText); + reasons.push(`Inferred experience requirement from seniority keywords: ${targetRange[0]}${targetRange[1] === 99 ? '+' : '-' + targetRange[1]} years.`); + } + + // 2. Evaluate Candidate Experience + const totalExp = calculateTotalExperience(candidate); + let expScore = 0; + + if (totalExp >= targetRange[0] && totalExp <= targetRange[1]) { + expScore = 100; + reasons.push(`✅ Candidate experience (${totalExp} years) fits the target range.`); + } else if (totalExp >= targetRange[0] - 1 && totalExp < targetRange[0]) { + expScore = 75; + reasons.push(`⚠️ Candidate experience (${totalExp} years) almost fits, slightly below target.`); + } else if (totalExp > targetRange[1]) { + expScore = 80; + reasons.push(`⚠️ Candidate experience (${totalExp} years) is above the target range (potentially overqualified).`); + } else { + expScore = 20; + reasons.push(`❌ Candidate experience (${totalExp} years) does not fit the minimum requirement.`); + } + + // 3. Evaluate Skills + let skillScore = 0; + const candidateSkills = candidate.skills ? candidate.skills.map(s => normalizeSkill(s.name || s)) : []; + + if (candidateSkills.length === 0) { + reasons.push(`❌ No candidate skills provided to match.`); + } else { + let matchedCount = 0; + const matchedSkills = []; + + for (const skill of candidateSkills) { + // Find skill in JD text (using boundaries for basic safety, except for some symbols) + const escapedSkill = skill.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + // If skill has no letters/numbers (e.g. C++), word boundary might fail, but for simple tech it works + const skillRegex = new RegExp(`(?:\\b|\\s)${escapedSkill}(?:\\b|\\s|[,.])`, 'i'); + if (skillRegex.test(jdTextLower)) { + matchedCount++; + matchedSkills.push(skill); + } + } + + // Deterministic baseline: assume a good JD requires around 3-5 core skills. + // We cap the denominator to avoid penalizing short JDs too heavily. + // If candidate matches up to 4 skills, they get good skill score. + const targetSkillsToMatch = 4; + skillScore = Math.min((matchedCount / targetSkillsToMatch) * 100, 100); + + if (matchedCount > 0) { + reasons.push(`✅ Matched skills: ${matchedSkills.slice(0, 3).join(', ')}${matchedCount > 3 ? ` + ${matchedCount - 3} more` : ''}.`); + } else { + reasons.push(`❌ No known candidate skills found in JD.`); + } + } + + // 4. Final Scoring (Weighted) + // Experience: 40%, Skills: 60% + score = Math.round((expScore * 0.4) + (skillScore * 0.6)); + + // Map to Classification + let classification = 'Red'; + if (score >= 80) classification = 'Green'; + else if (score >= 50) classification = 'Yellow'; + + return { + score, + classification, + reasons + }; +}; + +exports.getFitContext = async (userId, supabaseClient) => { + try { + const [profileRes, skillsRes, experiencesRes, settingsRes] = await Promise.all([ + profileRepository.findFirstProfile(userId, supabaseClient), + skillsRepository.findAll(userId, supabaseClient), + profileRepository.findUserExperiences(userId, supabaseClient), + settingsRepository.findSettings(userId, supabaseClient) + ]); + + const candidateData = { + profile: profileRes.data || null, + skills: skillsRes.data || [], + experiences: experiencesRes.data || [] + }; + + const settings = settingsRes.data || {}; + const aiRouting = settings.ai_routing || {}; + const fitConfig = aiRouting.jobFitAnalysis || { enabled: true, provider: settings.ai_model }; + + return { candidateData, fitConfig }; + } catch (err) { + console.error("Error fetching fit context:", err); + return { + candidateData: { profile: null, skills: [], experiences: [] }, + fitConfig: { enabled: false } + }; + } +}; diff --git a/backend/tests/fitAnalysis.test.js b/backend/tests/fitAnalysis.test.js new file mode 100644 index 0000000..38ed177 --- /dev/null +++ b/backend/tests/fitAnalysis.test.js @@ -0,0 +1,75 @@ +const { calculateDeterministicFit } = require('../services/fitAnalysis.service'); + +describe('Fit Analysis Service - Deterministic Calculation', () => { + + const mockCandidateExp3 = { + experiences: [{ years: 3 }], + skills: [{ name: 'React' }, { name: 'Node.js' }, { name: 'AWS' }] + }; + + const mockCandidateExp0 = { + experiences: [], + skills: [{ name: 'Python' }] + }; + + const mockCandidateExp6 = { + experiences: [{ years: 6 }], + skills: [{ name: 'React' }, { name: 'C#' }, { name: 'SQL' }] + }; + + const mockCandidateExp1 = { + experiences: [{ years: 1 }], + skills: [{ name: 'React' }, { name: 'Node' }] + }; + + test('Skai - AI Engineer (Explicit Range 2-5 years)', () => { + const jdText = "2–5 years of experience in software development. Experience with React, Node, AWS, and GenAI."; + + // Candidate with 3 years and 3 matched skills + const result3 = calculateDeterministicFit(mockCandidateExp3, jdText); + expect(result3.reasons.some(r => r.includes('Explicit experience requirement found: 2-5 years'))).toBe(true); + expect(result3.reasons.some(r => r.includes('✅ Candidate experience (3 years) fits'))).toBe(true); + expect(result3.score).toBeGreaterThan(70); // High score + + // Candidate with 0 years + const result0 = calculateDeterministicFit(mockCandidateExp0, jdText); + expect(result0.reasons.some(r => r.includes('does not fit the minimum requirement'))).toBe(true); + expect(result0.score).toBeLessThan(50); // Red + }); + + test('Glassix - Junior Fullstack Developer (Implicit Seniority)', () => { + const jdText = "We're looking for a Junior Fullstack Developer. React, .NET, MS SQL, Redis, MongoDB, AWS."; + + // Candidate with 1 year + const result1 = calculateDeterministicFit(mockCandidateExp1, jdText); + expect(result1.reasons.some(r => r.includes('Inferred experience requirement from seniority keywords: 0-2 years'))).toBe(true); + expect(result1.reasons.some(r => r.includes('✅ Candidate experience (1 years) fits'))).toBe(true); + + // Candidate with 6 years (overqualified for Junior) + const result6 = calculateDeterministicFit(mockCandidateExp6, jdText); + expect(result6.reasons.some(r => r.includes('above the target range'))).toBe(true); + // Overqualified gets a minor penalty, score should be ok but not perfect if skills match perfectly + // Let's just check the reason string + }); + + test('Appdome - Software Engineer (Explicit Minimum 2+ years)', () => { + const jdText = "2+ years of experience developing backend systems in Python or Java. Kubernetes, Docker, AWS."; + + // Candidate with 3 years + const result3 = calculateDeterministicFit(mockCandidateExp3, jdText); + expect(result3.reasons.some(r => r.includes('Explicit experience requirement found: 2+ years'))).toBe(true); + expect(result3.reasons.some(r => r.includes('✅ Candidate experience (3 years) fits'))).toBe(true); + + // Candidate with 1 year (Almost fits) + const result1 = calculateDeterministicFit(mockCandidateExp1, jdText); + expect(result1.reasons.some(r => r.includes('almost fits, slightly below target'))).toBe(true); + }); + + test('Edge case: Up to 2 years', () => { + const jdText = "Looking for someone with up to 2 years of experience."; + + const result = calculateDeterministicFit(mockCandidateExp1, jdText); + expect(result.reasons.some(r => r.includes('Explicit experience requirement found: 0-2 years'))).toBe(true); + expect(result.reasons.some(r => r.includes('✅ Candidate experience (1 years) fits'))).toBe(true); + }); +}); diff --git a/frontend/src/components/RoleFitAnalysis.jsx b/frontend/src/components/RoleFitAnalysis.jsx new file mode 100644 index 0000000..40445cf --- /dev/null +++ b/frontend/src/components/RoleFitAnalysis.jsx @@ -0,0 +1,53 @@ +import React from 'react'; + +const RoleFitAnalysis = ({ app }) => { + if (app.fit_score_deterministic == null) { + return null; + } + + const aiData = app.fit_analysis_ai; + const score = aiData?.overall_score || app.fit_score_deterministic; + const isGoodFit = score >= 80; + const isPartialFit = score >= 50 && score < 80; + + let badgeClass = 'badge-danger'; + let label = 'Poor Fit'; + if (isGoodFit) { + badgeClass = 'badge-success'; + label = 'Good Fit'; + } else if (isPartialFit) { + badgeClass = 'badge-warning'; + label = 'Partial Fit'; + } + + return ( +
+
+
+ 🎯 +

Role Fit Analysis

+
+
+
{score}%
+ {label} +
+
+
+ {aiData?.analysis ? ( +
+ {aiData.analysis.split('\n').map((paragraph, index) => ( +

{paragraph}

+ ))} +
+ ) : ( +
+

This is a deterministically calculated baseline score based on keyword matching.

+

No AI analysis is available for this application yet. Ensure AI Role Fit Analysis is enabled in settings.

+
+ )} +
+
+ ); +}; + +export default RoleFitAnalysis; diff --git a/frontend/src/pages/AnalyticsPage.jsx b/frontend/src/pages/AnalyticsPage.jsx index e1b097a..755fb02 100644 --- a/frontend/src/pages/AnalyticsPage.jsx +++ b/frontend/src/pages/AnalyticsPage.jsx @@ -93,6 +93,42 @@ const AnalyticsPage = () => { // bar chart: scale relative to max const maxMonth = Math.max(...Object.values(monthlyGroups), 1); + // --- job fit stats --- + let totalFitScore = 0; + let fitCount = 0; + let interviewFitScore = 0; + let interviewFitCount = 0; + let rejectedFitScore = 0; + let rejectedFitCount = 0; + let goodFitCount = 0; + let partialFitCount = 0; + let poorFitCount = 0; + + applications.forEach(a => { + const score = a.fit_analysis_ai?.overall_score ?? a.fit_score_deterministic; + if (score != null) { + totalFitScore += score; + fitCount++; + const s = (a.STATUS || '').toLowerCase(); + + if (s.includes('interview') || s.includes('offer') || s.includes('hired')) { + interviewFitScore += score; + interviewFitCount++; + } else if (s.includes('reject')) { + rejectedFitScore += score; + rejectedFitCount++; + } + + if (score >= 80) goodFitCount++; + else if (score >= 50) partialFitCount++; + else poorFitCount++; + } + }); + + const avgFitScore = fitCount > 0 ? Math.round(totalFitScore / fitCount) : 0; + const avgInterviewFitScore = interviewFitCount > 0 ? Math.round(interviewFitScore / interviewFitCount) : 0; + const avgRejectedFitScore = rejectedFitCount > 0 ? Math.round(rejectedFitScore / rejectedFitCount) : 0; + return { total, thisWeek, @@ -109,6 +145,16 @@ const AnalyticsPage = () => { pending: +((pending / safeTotal) * 100).toFixed(1), }, counts: { rejected, interviews, offers, pending }, + fitStats: { + hasFitData: fitCount > 0, + avgFitScore, + avgInterviewFitScore, + avgRejectedFitScore, + goodFitCount, + partialFitCount, + poorFitCount, + totalFitCount: fitCount + } }; }, [applications]); @@ -302,6 +348,60 @@ const AnalyticsPage = () => { )} + {/* ── Job Fit Insights ──────────────────────────────────── */} + {an.fitStats.hasFitData && ( + <> +

Job Fit Insights

+
+ 🎯} + label="Avg. Fit Score" + value={`${an.fitStats.avgFitScore}%`} + sub={`Based on ${an.fitStats.totalFitCount} applications`} + accent="#3b82f6" + /> + 📈} + label="Interviews Avg. Fit" + value={an.fitStats.avgInterviewFitScore > 0 ? `${an.fitStats.avgInterviewFitScore}%` : 'N/A'} + sub="Average score of applications that reached interviews" + accent="#10b981" + /> + 📉} + label="Rejections Avg. Fit" + value={an.fitStats.avgRejectedFitScore > 0 ? `${an.fitStats.avgRejectedFitScore}%` : 'N/A'} + sub="Average score of applications that were rejected" + accent="#ef4444" + /> +
+ +
+
+ Good Fit (≥80%) +
+
+
+ {an.fitStats.goodFitCount} +
+
+ Partial Fit (50-79%) +
+
+
+ {an.fitStats.partialFitCount} +
+
+ Poor Fit (<50%) +
+
+
+ {an.fitStats.poorFitCount} +
+
+ + )} +
); }; diff --git a/frontend/src/pages/ApplicationDetailPage.jsx b/frontend/src/pages/ApplicationDetailPage.jsx index 0477caa..838484f 100644 --- a/frontend/src/pages/ApplicationDetailPage.jsx +++ b/frontend/src/pages/ApplicationDetailPage.jsx @@ -5,6 +5,7 @@ import { useSettings } from '../hooks/useSettings'; import { useApplicationHistory } from '../hooks/useApplicationHistory'; import { useEvents } from '../hooks/useEvents'; import { GHOSTING_THRESHOLD_DAYS } from '../utils/constants'; +import RoleFitAnalysis from '../components/RoleFitAnalysis'; // ── Status pipeline order ───────────────────────────────────────────────────── const STATUS_PIPELINE = [ @@ -516,6 +517,8 @@ const ApplicationDetailPage = ({ app, onBack, onUpdate, dismissedGhostings = {},
+ {/* ── Role Fit Analysis ── */} + {/* ── Notes card ── */}
diff --git a/frontend/src/pages/ApplicationsPage.jsx b/frontend/src/pages/ApplicationsPage.jsx index aa5dd4c..5c75549 100644 --- a/frontend/src/pages/ApplicationsPage.jsx +++ b/frontend/src/pages/ApplicationsPage.jsx @@ -192,6 +192,7 @@ const ApplicationsPage = () => { Company Role ID + Fit Date Status {filterType !== 'archived' && Stage} @@ -212,6 +213,20 @@ const ApplicationsPage = () => { > {app.COMPANY} {app.ROLE_ID} + + {app.fit_score_deterministic != null ? ( +
= 80 ? 'var(--success-c)' : (app.fit_score_deterministic >= 50 ? 'var(--warning-c)' : 'var(--danger-c)'), + display: 'inline-block' + }} + /> + ) : ( + - + )} + {formatDate(app.DATE, settings?.timezone) || '—'} { + const newRouting = { ...settings.ai_routing, [feature]: { ...routing, enabled: e.target.checked } }; + try { await saveAiRouting(newRouting); flash('success', 'Routing updated.'); } + catch { flash('error', 'Failed to update routing.'); } + }} + disabled={saving} + /> + + +
+ )}
From b33fb066513d88718eacdfc569773eeaad1418ab Mon Sep 17 00:00:00 2001 From: SagiEv Date: Sun, 13 Sep 2026 18:45:21 +0300 Subject: [PATCH 02/10] UI IMPROVEMENT --- backend/scripts/backfillFitScores.js | 2 +- frontend/src/pages/ApplicationsPage.jsx | 30 ++++++++++++------------- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/backend/scripts/backfillFitScores.js b/backend/scripts/backfillFitScores.js index dcb80ec..16ef9bc 100644 --- a/backend/scripts/backfillFitScores.js +++ b/backend/scripts/backfillFitScores.js @@ -1,6 +1,6 @@ +require('dotenv').config({ path: '../.env' }); const { createClient } = require('@supabase/supabase-js'); const fitAnalysisService = require('../services/fitAnalysis.service'); -require('dotenv').config({ path: '../.env' }); const supabaseUrl = process.env.SUPABASE_URL; const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY; diff --git a/frontend/src/pages/ApplicationsPage.jsx b/frontend/src/pages/ApplicationsPage.jsx index 5c75549..f4f2dad 100644 --- a/frontend/src/pages/ApplicationsPage.jsx +++ b/frontend/src/pages/ApplicationsPage.jsx @@ -192,10 +192,10 @@ const ApplicationsPage = () => { Company Role ID - Fit Date Status {filterType !== 'archived' && Stage} + Fit @@ -213,20 +213,6 @@ const ApplicationsPage = () => { > {app.COMPANY} {app.ROLE_ID} - - {app.fit_score_deterministic != null ? ( -
= 80 ? 'var(--success-c)' : (app.fit_score_deterministic >= 50 ? 'var(--warning-c)' : 'var(--danger-c)'), - display: 'inline-block' - }} - /> - ) : ( - - - )} - {formatDate(app.DATE, settings?.timezone) || '—'}