SHL_Reco_sys / recommendation_engine.py
Saalil's picture
Upload 13 files
1567477 verified
Raw
History Blame Contribute Delete
10.2 kB
"""
Recommendation engine using embeddings + LLM for SHL assessments
"""
import pandas as pd
import numpy as np
from sentence_transformers import SentenceTransformer
from sklearn.metrics.pairwise import cosine_similarity
import os
import pickle
from typing import List, Dict, Tuple
import google.generativeai as genai
from dotenv import load_dotenv
load_dotenv()
class RecommendationEngine:
def __init__(self, catalog_path: str = 'data/shl_catalogue.csv'):
"""Initialize the recommendation engine"""
print("Loading catalog...")
self.df = pd.read_csv(catalog_path)
# Load embedding model (lightweight for speed)
print("Loading embedding model...")
self.model = SentenceTransformer('all-MiniLM-L6-v2')
# Configure Gemini
api_key = os.getenv('GEMINI_API_KEY')
if api_key and api_key != 'your_api_key_here':
genai.configure(api_key=api_key)
self.use_llm = True
else:
print("Warning: GEMINI_API_KEY not found. LLM explanations disabled.")
self.use_llm = False
# Create embeddings cache path in the same directory as catalog
catalog_dir = os.path.dirname(os.path.abspath(catalog_path))
self.embeddings_path = os.path.join(catalog_dir, 'embeddings.pkl')
# Load or create embeddings
self.embeddings = self.load_or_create_embeddings()
print(f"Loaded {len(self.df)} assessments")
def load_or_create_embeddings(self) -> np.ndarray:
"""Load embeddings from cache or create new ones"""
if os.path.exists(self.embeddings_path):
print("Loading cached embeddings...")
with open(self.embeddings_path, 'rb') as f:
return pickle.load(f)
else:
print("Creating embeddings...")
return self.create_embeddings()
def create_embeddings(self) -> np.ndarray:
"""Create embeddings for all assessments"""
# Combine relevant fields for embedding
texts = []
for _, row in self.df.iterrows():
text = f"{row['assessment_name']} {row['description']} {row['domain']} {row['test_type']}"
texts.append(text)
# Generate embeddings
embeddings = self.model.encode(texts, show_progress_bar=True)
# Cache embeddings
with open(self.embeddings_path, 'wb') as f:
pickle.dump(embeddings, f)
return embeddings
def retrieve_candidates(self, query: str, top_k: int = 20) -> List[Tuple[int, float]]:
"""
Retrieve top-k candidate assessments based on semantic similarity
Returns list of (index, score) tuples
"""
# Encode query
query_embedding = self.model.encode([query])
# Calculate similarities
similarities = cosine_similarity(query_embedding, self.embeddings)[0]
# Get top-k indices
top_indices = np.argsort(similarities)[::-1][:top_k]
return [(idx, similarities[idx]) for idx in top_indices]
def balance_recommendations(self, candidates: List[Dict], query: str) -> List[Dict]:
"""
Balance recommendations to include diverse test types
Focus on balancing Personality (P) and Knowledge/Skills (K) when query mentions both
"""
query_lower = query.lower()
# Check if query needs balanced recommendations
needs_personality = any(word in query_lower for word in
['collaborate', 'team', 'behavior', 'personality', 'communication',
'leadership', 'soft skill', 'stakeholder'])
needs_technical = any(word in query_lower for word in
['java', 'python', 'sql', 'javascript', 'coding', 'programming',
'technical', 'developer', 'engineer', 'analyst', 'data'])
needs_cognitive = any(word in query_lower for word in
['cognitive', 'reasoning', 'analytical', 'thinking', 'aptitude'])
if (needs_personality and needs_technical) or (needs_personality and needs_cognitive):
# Need balanced mix
personality_tests = [c for c in candidates if c['test_type'] == 'P']
technical_tests = [c for c in candidates if c['test_type'] in ['K', 'C']]
# Aim for roughly equal split
target = len(candidates) // 2
balanced = personality_tests[:target] + technical_tests[:target]
# Fill remaining with highest scored
remaining_slots = len(candidates) - len(balanced)
other_candidates = [c for c in candidates if c not in balanced]
balanced.extend(other_candidates[:remaining_slots])
return balanced[:len(candidates)]
return candidates
def generate_explanation(self, query: str, recommendations: List[Dict]) -> Dict:
"""Generate LLM explanation for recommendations"""
if not self.use_llm:
return {
'explanation': 'Recommendations based on semantic similarity to query.',
'best_recommendation': recommendations[0]['assessment_name'] if recommendations else None
}
try:
# Create prompt
prompt = f"""You are an HR assessment recommendation expert.
Job Query: {query}
Top Recommended Assessments:
"""
for i, rec in enumerate(recommendations[:5], 1):
prompt += f"\n{i}. {rec['assessment_name']} (Type: {rec['test_type_label']})"
prompt += f"\n Description: {rec['description'][:150]}..."
prompt += """
Task:
1. For each assessment, explain in 1-2 sentences why it's relevant for this role
2. Identify which ONE assessment is the best overall fit and explain why
3. If the role requires both technical and behavioral skills, ensure you recommend a balanced mix
Format your response as:
**Assessment 1: [Name]**
[Explanation]
**Assessment 2: [Name]**
[Explanation]
...
**Best Overall: [Name]**
[Brief reasoning why this is the best fit]
"""
# Call Gemini
model = genai.GenerativeModel('gemini-2.5-flash')
response = model.generate_content(prompt)
# Extract best recommendation
best_rec = recommendations[0]['assessment_name']
if 'Best Overall:' in response.text:
best_line = [line for line in response.text.split('\n') if 'Best Overall:' in line]
if best_line:
best_rec = best_line[0].split('Best Overall:')[1].strip().split('**')[0].strip()
return {
'explanation': response.text,
'best_recommendation': best_rec
}
except Exception as e:
print(f"LLM error: {e}")
return {
'explanation': f'Recommendations based on semantic similarity. Error generating detailed explanation: {str(e)}',
'best_recommendation': recommendations[0]['assessment_name'] if recommendations else None
}
def recommend(self, query: str, top_k: int = 10) -> Dict:
"""
Main recommendation function
Returns top-k recommendations with explanations
"""
# Retrieve candidates
candidates = self.retrieve_candidates(query, top_k=min(top_k * 2, 30))
# Convert to list of dicts
recommendations = []
for idx, score in candidates:
row = self.df.iloc[idx]
test_type_map = {
'P': 'Personality & Behavior',
'K': 'Knowledge & Skills',
'C': 'Cognitive',
'G': 'General'
}
recommendations.append({
'assessment_name': row['assessment_name'],
'url': row['url'],
'description': row['description'],
'test_type': row['test_type'],
'test_type_label': test_type_map.get(row['test_type'], 'General'),
'domain': row['domain'],
'similarity_score': float(score)
})
# Balance recommendations
recommendations = self.balance_recommendations(recommendations, query)
# Take top-k after balancing
recommendations = recommendations[:top_k]
# Generate LLM explanation
llm_result = self.generate_explanation(query, recommendations)
return {
'query': query,
'recommendations': recommendations,
'explanation': llm_result['explanation'],
'best_recommendation': llm_result['best_recommendation'],
'total_results': len(recommendations)
}
def main():
"""Test the recommendation engine"""
engine = RecommendationEngine()
# Test query
test_query = "I am hiring for Java developers who can also collaborate effectively with my business teams."
print(f"\n{'='*80}")
print(f"Query: {test_query}")
print('='*80)
result = engine.recommend(test_query, top_k=5)
print(f"\nTop {len(result['recommendations'])} Recommendations:")
for i, rec in enumerate(result['recommendations'], 1):
print(f"\n{i}. {rec['assessment_name']}")
print(f" Type: {rec['test_type_label']} | Domain: {rec['domain']}")
print(f" Score: {rec['similarity_score']:.3f}")
print(f" URL: {rec['url']}")
if result['explanation']:
print(f"\n{'='*80}")
print("LLM Explanation:")
print('='*80)
print(result['explanation'])
if __name__ == "__main__":
main()