File size: 8,586 Bytes
a438728
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9db3523
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
# 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)