# 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: Relevance: Justification: 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)