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..78b4bb0 --- /dev/null +++ b/backend/ai_service/role_fit/router.py @@ -0,0 +1,57 @@ +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. + +CRITICAL SCORING RULES: +- Isolate Experience from Skills: "Experience Match" MUST be scored purely on tenure (years) and MUST NOT be inflated by a good skill match. +- Mathematical Penalty: If the JD requires X years, and the candidate has Y years (where Y < X), aggressively penalize the Experience Match. If the gap is > 2 years, Experience Match should be 0-15%. +- Overall Score Guardrails: If a candidate is severely underqualified in tenure (e.g. 0 years for a Mid/Senior role requiring 4+ years), the `ai_score` MUST NOT exceed 65, even if their skills match perfectly. + +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": "10%"}}. +""" + +@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..a8f3979 100644 --- a/backend/controllers/applications.controller.js +++ b/backend/controllers/applications.controller.js @@ -1,4 +1,8 @@ const applicationService = require('../services/applications.service'); +const rssService = require('../services/rss.service'); +const fitAnalysisService = require('../services/fitAnalysis.service'); +const settingsService = require('../services/settings.service'); +const axios = require('axios'); const getAll = async (req, res) => { try { @@ -22,7 +26,64 @@ 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(async ({ candidateData, fitConfig }) => { + if (fitConfig.enabled !== false && fitConfig.provider) { + const aiProvider = fitConfig.provider; + + // Fetch user's decrypted API keys from DB + const aiConfigs = await settingsService.getAllAiConfigs(userId, req.supabase); + + // Ensure we have an API key for the chosen provider before firing + let token = null; + if (aiProvider === 'groq') token = aiConfigs?.groq_token; + else if (aiProvider === 'openai') token = aiConfigs?.openai_token; + else if (aiProvider === 'anthropic' || aiProvider === 'claude') token = aiConfigs?.claude_token; + else if (aiProvider === 'gemini') token = aiConfigs?.gemini_token; + + if (!token) { + console.warn(`Skipping AI Fit Analysis: No API key configured by user 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_token: aiConfigs?.groq_token, + openai_token: aiConfigs?.openai_token, + claude_token: aiConfigs?.claude_token, + gemini_token: aiConfigs?.gemini_token + }, + 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..3ffb7cd --- /dev/null +++ b/backend/scripts/backfillFitScores.js @@ -0,0 +1,80 @@ +require('dotenv').config({ path: require('path').resolve(__dirname, '../.env') }); +const { createClient } = require('@supabase/supabase-js'); +const fitAnalysisService = require('../services/fitAnalysis.service'); + +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..083854f --- /dev/null +++ b/backend/services/fitAnalysis.service.js @@ -0,0 +1,184 @@ +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[1]) { + expScore = 80; + reasons.push(`⚠️ Candidate experience (${totalExp} years) is above the target range (potentially overqualified).`); + } else { + const gap = targetRange[0] - totalExp; + if (gap <= 1) { + expScore = 50; + reasons.push(`⚠️ Candidate experience (${totalExp} years) is slightly below the target of ${targetRange[0]} years.`); + } else if (gap <= 2) { + expScore = 20; + reasons.push(`❌ Candidate experience (${totalExp} years) is notably below the target of ${targetRange[0]} years.`); + } else { + expScore = 0; + reasons.push(`❌ Candidate experience (${totalExp} years) is significantly below the minimum requirement of ${targetRange[0]} years.`); + } + } + + // 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)); + + // Hard Cap: If candidate experience is strictly less than target minimum, do not allow score > 65 + if (totalExp < targetRange[0] && score > 65) { + score = 65; + reasons.push(`⚠️ Final score capped at 65% because candidate lacks the minimum required experience.`); + } + + // 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..9abf96d --- /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('below the target') || r.includes('below the minimum'))).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('is slightly below the 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..f7f17c4 --- /dev/null +++ b/frontend/src/components/RoleFitAnalysis.jsx @@ -0,0 +1,61 @@ +import React from 'react'; + +const RoleFitAnalysis = ({ app }) => { + if (app.fit_score_deterministic == null) { + return null; + } + + const aiData = app.fit_analysis_ai; + const score = aiData?.ai_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 ( +
{aiData.short_summary}
+ {aiData.percentage_matches && Object.keys(aiData.percentage_matches).length > 0 && ( +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.
+