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
2 changes: 2 additions & 0 deletions backend/ai_service/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand All @@ -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():
Expand Down
12 changes: 12 additions & 0 deletions backend/ai_service/role_fit/models.py
Original file line number Diff line number Diff line change
@@ -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%'}")
57 changes: 57 additions & 0 deletions backend/ai_service/role_fit/router.py
Original file line number Diff line number Diff line change
@@ -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))
68 changes: 68 additions & 0 deletions backend/config/fitRules.json
Original file line number Diff line number Diff line change
@@ -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]
}
63 changes: 62 additions & 1 deletion backend/controllers/applications.controller.js
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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);
Expand Down
10 changes: 10 additions & 0 deletions backend/migrations/003_add_fit_analysis.sql
Original file line number Diff line number Diff line change
@@ -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);
80 changes: 80 additions & 0 deletions backend/scripts/backfillFitScores.js
Original file line number Diff line number Diff line change
@@ -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);
Loading
Loading