Spaces:
Running
Running
# PDFs | |
from langchain_community.document_loaders import PyPDFLoader | |
from langchain.vectorstores import FAISS | |
from langchain.embeddings import HuggingFaceEmbeddings as HFE | |
from langchain.schema import Document | |
# Groq | |
from langchain_groq import ChatGroq | |
from langchain_core.messages import HumanMessage | |
from langchain_community.chat_message_histories import ChatMessageHistory | |
from langchain_core.chat_history import BaseChatMessageHistory | |
from langchain_core.runnables.history import RunnableWithMessageHistory | |
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder | |
from groq import Groq | |
# Expanded Queries | |
import ast | |
# Cross Encoder | |
from sentence_transformers import CrossEncoder | |
# BM25 | |
from rank_bm25 import BM25Okapi | |
import numpy as np | |
# Gradio | |
import gradio as gr | |
# GROQ_API = userdata.get('GROQ_API') | |
embed_model = "sentence-transformers/all-MiniLM-L6-v2" | |
cross_encoder = CrossEncoder('cross-encoder/ms-marco-MiniLM-L-12-v2') | |
prompt = ChatPromptTemplate.from_messages( | |
[ | |
("system", """ | |
You are a helpful HR assistant specializing in the resume screening phase. | |
Your goal is to identify the best, most suitable, or highest-potential | |
candidates whose qualifications align well with the provided job title | |
and job description. If a question or request falls outside the scope | |
of resume screening and candidate alignment, | |
please respond with 'I don't know'. | |
"""), | |
MessagesPlaceholder(variable_name="history", optional=True), | |
("system", "Context: {context}"), | |
("human", "{question}"), | |
] | |
) | |
query_expansion_prompt = ChatPromptTemplate([ | |
("system", """ | |
You are an expert HR assistant. Given a job description and a user query, | |
generate 3 alternative, diverse search queries that capture different | |
aspects of what makes a great candidate for this role. Each query should | |
focus on a different facet (e.g., skills, leadership, hands-on experience, | |
certifications, unique achievements). | |
If the job description is empty, generate a general job description for the role | |
mentioned in the user query and then create the 3 alternative search queries based on that. | |
Return ONLY the generated queries as a Python list of strings. Do not include | |
any other explanatory text or formatting. | |
"""), | |
("human", "Job Description: {job_description}\nUser Query: {user_query}") | |
]) | |
JUDGE_PROMPT = """ | |
You are an expert recruiter. Given the job description, the user query, and the system's answer, rate: | |
Faithfulness: Does the answer accurately reflect the resume(s) provided? (1-5) | |
Relevance: Does the answer address the job requirements and user query? (1-5) | |
Provide your feedback as follows: | |
Faithfulness: <score> | |
Relevance: <score> | |
Justification: <brief explanation> | |
Job Description: | |
{job_description} | |
User Query: | |
{user_query} | |
System Answer: | |
{system_answer} | |
""" | |
def load_single_pdf(path): | |
loader = PyPDFLoader(path) | |
pages = loader.load() | |
full_text = "\n".join([page.page_content for page in pages]) | |
return Document(page_content=full_text) | |
def chunks_embed(chunks, model_name): | |
"""Create embeds for doc chunks and store in FAISS""" | |
embeds = HFE(model_name=model_name) | |
# Create FAISS index | |
db = FAISS.from_documents(chunks, embeds) | |
print(f"Created FAISS Index with {len(chunks)} documents.") | |
return db | |
def search_docs_mmr(db, query, k, fetch_k, lambda_mult): | |
""" | |
Retrieve the most similar docs to the query using MMR | |
(Maximum Marginal Relevance) | |
""" | |
if not db: | |
print("Error: No document database available") | |
return [] | |
docs = db.max_marginal_relevance_search( | |
query, k=fetch_k, lambda_mult=lambda_mult | |
) | |
return docs | |
def combine_results(results): | |
# Combine the content from results to create context | |
context = "" | |
for doc in results: | |
context += doc.page_content + "\n" | |
return context | |
# 1. Prepare corpus for BM25 | |
def prepare_bm25_corpus(docs): | |
# Tokenize for BM25 (simple whitespace split, can improve) | |
return [doc.page_content.lower().split() for doc in docs] | |
# 2. Initialize BM25 | |
def init_bm25(docs): | |
corpus = prepare_bm25_corpus(docs) | |
return BM25Okapi(corpus) | |
# 3. BM25 Search | |
def bm25_search(bm25, query, docs, top_k=10): | |
query_tokens = query.lower().split() | |
scores = bm25.get_scores(query_tokens) | |
top_indices = np.argsort(scores)[::-1][:top_k] | |
return [docs[i] for i in top_indices], [scores[i] for i in top_indices] | |
# Hybrid Merge Functino | |
def hybrid_merge(semantic_results, bm25_results): | |
# Merge by union, keeping order (semantic first, then BM25 if not already present) | |
seen = set() | |
merged = [] | |
for doc in semantic_results + bm25_results: | |
if doc.page_content not in seen: | |
merged.append(doc) | |
seen.add(doc.page_content) | |
return merged | |
def llm_judge_groq(api_key, job_description, user_query, system_answer): | |
judge_prompt = JUDGE_PROMPT.format( | |
job_description=job_description, | |
user_query=user_query, | |
system_answer=system_answer | |
) | |
client = Groq(api_key=api_key) | |
completion = client.chat.completions.create( | |
model="deepseek-r1-distill-llama-70b", | |
messages=[{"role": "user", "content": judge_prompt}], | |
max_tokens=512 | |
) | |
return completion.choices[0].message.content | |
def screen_resumes(api_key, job_description, user_query, files): | |
embed_model = "sentence-transformers/all-MiniLM-L6-v2" | |
cross_encoder = CrossEncoder('cross-encoder/ms-marco-MiniLM-L-12-v2') | |
# Model and prompt setup (inside function, using user API key) | |
model = ChatGroq(model="llama-3.1-8b-instant", api_key=api_key) | |
history = {} | |
def get_session_history(session_id: str): | |
if session_id not in history: | |
history[session_id] = ChatMessageHistory() | |
return history[session_id] | |
with_message_history = RunnableWithMessageHistory(model, get_session_history) | |
chain = prompt | model | |
with_message_history = RunnableWithMessageHistory( | |
chain, | |
get_session_history, | |
input_messages_key="question", | |
history_messages_key="history" | |
) | |
# Load and process resumes | |
resume_paths = [file.name for file in files] | |
chunks = [load_single_pdf(path) for path in resume_paths] | |
embeds = chunks_embed(chunks, embed_model) | |
bm25 = init_bm25(chunks) | |
# Query Expansion | |
prompt_value = query_expansion_prompt.invoke({ | |
"job_description": job_description, | |
"user_query": user_query, | |
}) | |
expanded_queries_response = model.invoke(prompt_value.messages) | |
expanded_queries = ast.literal_eval(expanded_queries_response.content) | |
# Hybrid Retrieval | |
all_semantic = [] | |
all_bm25 = [] | |
for q in expanded_queries: | |
semantic_docs = search_docs_mmr(embeds, q, 10, 100, 0.7) | |
bm25_docs, _ = bm25_search(bm25, q, chunks, top_k=10) | |
all_semantic.extend(semantic_docs) | |
all_bm25.extend(bm25_docs) | |
merged_results = hybrid_merge(all_semantic, all_bm25) | |
unique_results_list = merged_results | |
# Cross-encoder Re-ranking | |
pairs = [(user_query, doc.page_content) for doc in unique_results_list] | |
scores = cross_encoder.predict(pairs) | |
ranked = sorted(zip(scores, unique_results_list), key=lambda x: x[0], reverse=True) | |
top_n = min(5, len(ranked)) | |
ranked_top_n = [doc for score, doc in ranked[:top_n]] | |
context = "\n\n".join([doc.page_content for doc in ranked_top_n]) | |
# LLM Final Reasoning | |
inputs = { | |
"context": context, | |
"question": user_query, | |
} | |
config = {"configurable": {"session_id": "GradioSession"}} | |
response = with_message_history.invoke(inputs, config=config) | |
system_output = response.content | |
# LLM-as-a-Judge Evaluation | |
judge_feedback = llm_judge_groq(api_key, job_description, user_query, system_output) | |
return system_output, context, judge_feedback | |
demo = gr.Interface( | |
fn=screen_resumes, | |
inputs=[ | |
gr.Textbox(label="Groq API Key", type="password", lines=1, placeholder="sk..."), | |
gr.Textbox(lines=4, label="Job Description"), | |
gr.Textbox(lines=2, label="User Query"), | |
gr.File(file_count="multiple", label="Upload Resume PDFs") | |
], | |
outputs=[ | |
gr.Textbox(label="Screening Result (LLM Output)"), | |
gr.Textbox(label="Top Ranked Resumes (Raw Text)"), | |
gr.Textbox(label="LLM-as-a-Judge Evaluation (DeepSeek)") | |
], | |
title="Resume Screening Assistant (Hybrid + LLM-as-a-Judge)", | |
description="Enter your Groq API key, upload resumes, enter a job description and query, get the best candidates with explanations, and see an automated evaluation." | |
) | |
demo.launch(share=True) |