root commited on
Commit
baade64
Β·
1 Parent(s): 529c12e
Files changed (2) hide show
  1. app.py +165 -282
  2. requirements.txt +4 -7
app.py CHANGED
@@ -19,7 +19,6 @@ from transformers import AutoModelForCausalLM, AutoTokenizer
19
  import time
20
  import faiss
21
  import re
22
- import openai
23
 
24
  # Download NLTK resources
25
  try:
@@ -47,7 +46,7 @@ with st.sidebar:
47
 
48
  # Advanced options
49
  st.subheader("Advanced Options")
50
- top_k = st.selectbox("Number of results to display", options=[1, 5, 10, 20, 50], index=2)
51
 
52
  # LLM Settings
53
  st.subheader("LLM Settings")
@@ -66,7 +65,7 @@ with st.sidebar:
66
  st.markdown("### πŸ“Š Models Used")
67
  st.markdown("- **Embedding**: BAAI/bge-large-en-v1.5")
68
  st.markdown("- **Cross-Encoder**: ms-marco-MiniLM-L6-v2")
69
- st.markdown("- **LLM**: Qwen/Qwen3-14B")
70
  st.markdown("### πŸ“ˆ Scoring Formula")
71
  st.markdown("**Final Score = Cross-Encoder (0-1) + BM25 (0.1-0.2) + Intent (0-0.3)**")
72
 
@@ -81,22 +80,28 @@ if 'resume_texts' not in st.session_state:
81
  st.session_state.resume_texts = []
82
  if 'file_names' not in st.session_state:
83
  st.session_state.file_names = []
84
- if 'explanations_generated' not in st.session_state:
85
- st.session_state.explanations_generated = False
86
  if 'current_job_description' not in st.session_state:
87
  st.session_state.current_job_description = ""
88
- if 'vllm_4b_endpoint' not in st.session_state:
89
- st.session_state.vllm_4b_endpoint = "http://localhost:8001/v1" # Qwen3-4B vLLM endpoint
90
- if 'vllm_14b_endpoint' not in st.session_state:
91
- st.session_state.vllm_14b_endpoint = "http://localhost:8002/v1" # Qwen3-14B vLLM endpoint
 
 
 
 
 
 
92
 
93
  @st.cache_resource
94
  def load_embedding_model():
95
  """Load and cache the BGE embedding model"""
 
96
  try:
97
  with st.spinner("πŸ”„ Loading BAAI/bge-large-en-v1.5 model..."):
98
  model = SentenceTransformer('BAAI/bge-large-en-v1.5')
99
  st.success("βœ… Embedding model loaded successfully!")
 
100
  return model
101
  except Exception as e:
102
  st.error(f"❌ Error loading embedding model: {str(e)}")
@@ -105,33 +110,44 @@ def load_embedding_model():
105
  @st.cache_resource
106
  def load_cross_encoder():
107
  """Load and cache the Cross-Encoder model"""
 
108
  try:
109
  with st.spinner("πŸ”„ Loading Cross-Encoder ms-marco-MiniLM-L6-v2..."):
110
  from sentence_transformers import CrossEncoder
111
  model = CrossEncoder('cross-encoder/ms-marco-MiniLM-L6-v2')
112
  st.success("βœ… Cross-Encoder model loaded successfully!")
 
113
  return model
114
  except Exception as e:
115
  st.error(f"❌ Error loading Cross-Encoder model: {str(e)}")
116
  return None
117
 
118
- def vllm_chat_completion(prompt, endpoint, max_tokens=200, temperature=0.7):
119
- openai.api_base = endpoint
120
- openai.api_key = "EMPTY" # vLLM does not require a real key
121
- response = openai.ChatCompletion.create(
122
- model="Qwen/Qwen3-4B" if "4b" in endpoint else "Qwen/Qwen3-14B",
123
- messages=[{"role": "user", "content": prompt}],
124
- max_tokens=max_tokens,
125
- temperature=temperature,
126
- stream=False
 
 
 
127
  )
128
- return response.choices[0].message.content.strip()
 
 
129
 
130
  class ResumeScreener:
131
  def __init__(self):
132
- # Load models
 
133
  self.embedding_model = load_embedding_model()
 
134
  self.cross_encoder = load_cross_encoder()
 
 
135
 
136
  def extract_text_from_file(self, file_path, file_type):
137
  """Extract text from various file types"""
@@ -213,72 +229,75 @@ class ResumeScreener:
213
 
214
  def advanced_pipeline_ranking(self, resume_texts, job_description):
215
  """Advanced pipeline: FAISS recall -> Cross-encoder -> BM25 -> LLM intent -> Final ranking"""
 
216
  if not resume_texts:
217
  return []
218
-
219
- # Stage 1: FAISS Recall (Top 50)
220
- st.write("πŸ” **Stage 1**: FAISS Recall - Finding top 50 candidates...")
221
  top_50_indices = self.faiss_recall(resume_texts, job_description, top_k=50)
222
-
223
- # Stage 2: Cross-Encoder Re-ranking (Top 20)
224
- st.write("🎯 **Stage 2**: Cross-Encoder Re-ranking - Selecting top 20...")
 
225
  top_20_results = self.cross_encoder_rerank(resume_texts, job_description, top_50_indices, top_k=20)
226
-
227
- # Stage 3: BM25 Keyword Matching
228
- st.write("πŸ”€ **Stage 3**: BM25 Keyword Matching...")
 
229
  top_20_with_bm25 = self.add_bm25_scores(resume_texts, job_description, top_20_results)
230
-
231
- # Stage 4: LLM Intent Analysis
232
- st.write("πŸ€– **Stage 4**: LLM Intent Analysis...")
 
233
  top_20_with_intent = self.add_intent_scores(resume_texts, job_description, top_20_with_bm25)
234
-
235
- # Stage 5: Final Combined Ranking (Top 5)
236
- st.write("πŸ† **Stage 5**: Final Combined Ranking...")
 
237
  final_results = self.calculate_final_scores(top_20_with_intent)
238
-
 
239
  return final_results[:5] # Return top 5
240
 
241
  def faiss_recall(self, resume_texts, job_description, top_k=50):
242
  """Stage 1: Use FAISS for initial recall to find top 50 resumes"""
243
- try:
244
- # Get job embedding
245
- job_embedding = self.get_embedding(job_description)
246
-
247
- # Get resume embeddings
248
- resume_embeddings = []
249
- progress_bar = st.progress(0)
250
-
251
- for i, text in enumerate(resume_texts):
252
- if text:
253
- embedding = self.embedding_model.encode(text[:8192],
254
- convert_to_numpy=True,
255
- normalize_embeddings=True)
256
- resume_embeddings.append(embedding)
257
- else:
258
- resume_embeddings.append(np.zeros(1024))
259
- progress_bar.progress((i + 1) / len(resume_texts))
260
-
261
- progress_bar.empty()
262
-
263
- # Create FAISS index
264
- resume_embeddings = np.array(resume_embeddings).astype('float32')
265
- dimension = resume_embeddings.shape[1]
266
- index = faiss.IndexFlatIP(dimension) # Inner product for cosine similarity
267
- index.add(resume_embeddings)
268
-
269
- # Search for top K
270
- job_embedding = job_embedding.reshape(1, -1).astype('float32')
271
- scores, indices = index.search(job_embedding, min(top_k, len(resume_texts)))
272
-
273
- return indices[0].tolist()
274
-
275
- except Exception as e:
276
- st.error(f"Error in FAISS recall: {str(e)}")
277
- # Fallback: return all indices
278
- return list(range(min(top_k, len(resume_texts))))
279
 
280
  def cross_encoder_rerank(self, resume_texts, job_description, top_50_indices, top_k=20):
281
  """Stage 2: Use Cross-Encoder to re-rank top 50 and select top 20"""
 
282
  try:
283
  if not self.cross_encoder:
284
  st.error("Cross-encoder not loaded!")
@@ -299,6 +318,8 @@ class ResumeScreener:
299
  if not pairs:
300
  return [(idx, 0.0) for idx in top_50_indices[:top_k]]
301
 
 
 
302
  # Get cross-encoder scores
303
  progress_bar = st.progress(0)
304
  scores = []
@@ -310,8 +331,11 @@ class ResumeScreener:
310
  batch_scores = self.cross_encoder.predict(batch)
311
  scores.extend(batch_scores)
312
  progress_bar.progress(min(1.0, (i + batch_size) / len(pairs)))
 
313
 
314
  progress_bar.empty()
 
 
315
 
316
  # Combine indices with scores and sort
317
  indexed_scores = list(zip(valid_indices, scores))
@@ -325,6 +349,8 @@ class ResumeScreener:
325
 
326
  def add_bm25_scores(self, resume_texts, job_description, top_20_results):
327
  """Stage 3: Add BM25 scores to top 20 resumes"""
 
 
328
  try:
329
  # Get texts for top 20
330
  top_20_texts = [resume_texts[idx] for idx, _ in top_20_results]
@@ -352,6 +378,8 @@ class ResumeScreener:
352
  bm25_score = normalized_bm25[i] if i < len(normalized_bm25) else 0.15
353
  results_with_bm25.append((idx, cross_score, bm25_score))
354
 
 
 
355
  return results_with_bm25
356
 
357
  except Exception as e:
@@ -360,63 +388,75 @@ class ResumeScreener:
360
 
361
  def add_intent_scores(self, resume_texts, job_description, top_20_with_bm25):
362
  """Stage 4: Add LLM intent analysis scores"""
363
- try:
364
- results_with_intent = []
365
- progress_bar = st.progress(0)
366
-
367
- for i, (idx, cross_score, bm25_score) in enumerate(top_20_with_bm25):
368
- intent_score = self.analyze_intent(resume_texts[idx], job_description)
369
- results_with_intent.append((idx, cross_score, bm25_score, intent_score))
370
- progress_bar.progress((i + 1) / len(top_20_with_bm25))
371
-
372
- progress_bar.empty()
373
- return results_with_intent
374
-
375
- except Exception as e:
376
- st.error(f"Error adding intent scores: {str(e)}")
377
- return [(idx, cross_score, bm25_score, 0.1) for idx, cross_score, bm25_score in top_20_with_bm25]
378
 
379
  def analyze_intent(self, resume_text, job_description):
380
- """Analyze candidate's intent using LLM"""
 
 
381
  try:
382
- # Truncate texts
383
- resume_snippet = resume_text[:1500] if len(resume_text) > 1500 else resume_text
384
- job_snippet = job_description[:800] if len(job_description) > 800 else job_description
385
 
386
- prompt = f"""You are given a job description and a candidate's resume.
387
- Clearly answer: "Is the candidate likely seeking this job? Respond with 'Yes', 'Maybe', or 'No' and give a brief justification."
388
-
389
- Job Description:
390
- {job_snippet}
391
-
392
- Candidate Resume:
393
- {resume_snippet}
394
-
395
- Response format:
396
- Intent: [Yes/Maybe/No]
397
- Reason: [Brief justification]"""
398
 
399
- response = vllm_chat_completion(
400
  prompt,
401
- st.session_state.vllm_4b_endpoint,
402
- max_tokens=100
 
403
  )
 
 
 
 
 
 
 
404
 
405
- # Parse response
406
- response_lower = response.lower()
407
- if 'intent: yes' in response_lower or 'intent:yes' in response_lower:
408
- return 0.3
409
- elif 'intent: maybe' in response_lower or 'intent:maybe' in response_lower:
410
- return 0.1
 
411
  else:
412
- return 0.0
413
-
 
 
 
 
 
 
 
 
 
 
414
  except Exception as e:
415
- st.warning(f"Error analyzing intent: {str(e)}")
416
- return 0.1 # Default to "Maybe"
 
417
 
418
  def calculate_final_scores(self, results_with_all_scores):
419
  """Stage 5: Calculate final combined scores"""
 
 
420
  try:
421
  final_results = []
422
 
@@ -438,6 +478,8 @@ Reason: [Brief justification]"""
438
  # Sort by final score
439
  final_results.sort(key=lambda x: x['final_score'], reverse=True)
440
 
 
 
441
  return final_results
442
 
443
  except Exception as e:
@@ -482,99 +524,6 @@ Reason: [Brief justification]"""
482
  found_skills.append(word)
483
 
484
  return list(set(found_skills))[:15] # Return top 15 unique skills
485
-
486
- def generate_simple_explanation(self, score, semantic_score, bm25_score, skills):
487
- """Generate simple explanation for the match (fallback)"""
488
- if score > 0.8:
489
- quality = "excellent"
490
- elif score > 0.6:
491
- quality = "strong"
492
- elif score > 0.4:
493
- quality = "moderate"
494
- else:
495
- quality = "limited"
496
-
497
- explanation = f"This candidate shows {quality} alignment with the position (score: {score:.2f}). "
498
-
499
- if semantic_score > bm25_score:
500
- explanation += f"The resume demonstrates strong conceptual relevance ({semantic_score:.2f}) suggesting good experience fit. "
501
- else:
502
- explanation += f"The resume has high keyword match ({bm25_score:.2f}) indicating direct skill alignment. "
503
-
504
- if skills:
505
- explanation += f"Key matching competencies include: {', '.join(skills[:5])}."
506
-
507
- return explanation
508
-
509
- def generate_llm_explanation(self, resume_text, job_description, score, skills, max_retries=3):
510
- """Generate detailed explanation using Qwen3-14B"""
511
- if not st.session_state.vllm_14b_endpoint:
512
- return self.generate_simple_explanation(score, score, score, skills)
513
-
514
- # Truncate texts to manage token limits
515
- resume_snippet = resume_text[:2000] if len(resume_text) > 2000 else resume_text
516
- job_snippet = job_description[:1000] if len(job_description) > 1000 else job_description
517
-
518
- prompt = f"""You are an expert HR analyst. Analyze this individual candidate's resume against the job requirements and write EXACTLY 150 words explaining why this specific candidate is suitable for the position.
519
-
520
- Structure your 150-word analysis as follows:
521
- 1. Experience alignment (40-50 words)
522
- 2. Key strengths and skills match (40-50 words)
523
- 3. Unique value proposition (40-50 words)
524
- 4. Overall recommendation (10-20 words)
525
-
526
- Job Requirements:
527
- {job_snippet}
528
-
529
- Candidate's Resume:
530
- {resume_snippet}
531
-
532
- Identified Matching Skills: {', '.join(skills[:10])}
533
- Compatibility Score: {score:.1%}
534
-
535
- Write a professional, detailed 150-word analysis for THIS INDIVIDUAL CANDIDATE:"""
536
-
537
- for attempt in range(max_retries):
538
- try:
539
- response = vllm_chat_completion(
540
- prompt,
541
- st.session_state.vllm_14b_endpoint,
542
- max_tokens=200
543
- )
544
-
545
- # Extract the response and ensure it's about 150 words
546
- explanation = response.strip()
547
- word_count = len(explanation.split())
548
-
549
- # If response is close to 150 words (130-170), accept it
550
- if 130 <= word_count <= 170:
551
- return explanation
552
-
553
- # If response is too short or too long, try again with adjusted prompt
554
- if word_count < 130:
555
- # Response too short, try again
556
- continue
557
- elif word_count > 170:
558
- # Response too long, truncate to approximately 150 words
559
- words = explanation.split()
560
- truncated = ' '.join(words[:150])
561
- # Add proper ending if truncated
562
- if not truncated.endswith('.'):
563
- truncated += '.'
564
- return truncated
565
-
566
- return explanation
567
-
568
- except Exception as e:
569
- if attempt < max_retries - 1:
570
- time.sleep(2) # Wait before retry
571
- continue
572
- else:
573
- # Fallback to simple explanation
574
- return self.generate_simple_explanation(score, score, score, skills)
575
-
576
- # If all retries failed, use simple explanation
577
- return self.generate_simple_explanation(score, score, score, skills)
578
 
579
  def create_download_link(df, filename="resume_screening_results.csv"):
580
  """Create download link for results"""
@@ -584,7 +533,7 @@ def create_download_link(df, filename="resume_screening_results.csv"):
584
 
585
  # Main App Interface
586
  st.title("🎯 AI-Powered Resume Screener")
587
- st.markdown("*Find the perfect candidates using BAAI/bge-large-en-v1.5 embeddings and Qwen3-14B explanations*")
588
  st.markdown("---")
589
 
590
  # Initialize screener
@@ -611,7 +560,6 @@ if st.session_state.resume_texts:
611
  st.session_state.resume_texts = []
612
  st.session_state.file_names = []
613
  st.session_state.results = []
614
- st.session_state.explanations_generated = False
615
  st.session_state.current_job_description = ""
616
  st.rerun()
617
 
@@ -782,12 +730,15 @@ with col1:
782
  disabled=not (job_description and st.session_state.resume_texts),
783
  type="primary",
784
  help="Run the complete 5-stage advanced pipeline"):
 
785
  if len(st.session_state.resume_texts) == 0:
786
  st.error("❌ Please upload resumes first!")
787
  elif not job_description.strip():
788
  st.error("❌ Please enter a job description!")
789
  else:
 
790
  with st.spinner("πŸš€ Running Advanced Pipeline Analysis..."):
 
791
  try:
792
  # Run the advanced pipeline
793
  pipeline_results = screener.advanced_pipeline_ranking(
@@ -814,81 +765,19 @@ with col1:
814
  'intent_score': result_data['intent_score'],
815
  'skills': skills,
816
  'text': text,
817
- 'text_preview': text[:500] + "..." if len(text) > 500 else text,
818
- 'explanation': None # No detailed explanation yet
819
  })
820
 
821
- # Add simple explanations for now
822
- for result in results:
823
- result['explanation'] = screener.generate_simple_explanation(
824
- result['final_score'],
825
- result['cross_encoder_score'],
826
- result['bm25_score'],
827
- result['skills']
828
- )
829
-
830
  # Store in session state
831
  st.session_state.results = results
832
- st.session_state.explanations_generated = False
833
  st.session_state.current_job_description = job_description
834
 
835
  st.success(f"πŸš€ Advanced pipeline complete! Found top {len(st.session_state.results)} candidates.")
 
836
 
837
  except Exception as e:
838
  st.error(f"❌ Error during analysis: {str(e)}")
839
 
840
- # Second button: Generate AI explanations (slower, optional)
841
- with col2:
842
- # Show this button only if we have results and LLM is enabled
843
- show_explanation_button = (
844
- st.session_state.results and
845
- use_llm_explanations and
846
- st.session_state.vllm_14b_endpoint and
847
- not st.session_state.explanations_generated
848
- )
849
-
850
- if show_explanation_button:
851
- if st.button("πŸ€– Generate AI Explanations",
852
- type="secondary",
853
- help="Generate detailed 150-word explanations using Qwen3-14B (takes longer)"):
854
- with st.spinner("πŸ€– Generating detailed AI explanations..."):
855
- try:
856
- explanation_progress = st.progress(0)
857
- explanation_text = st.empty()
858
-
859
- for i, result in enumerate(st.session_state.results):
860
- explanation_text.text(f"πŸ€– Generating AI explanation for candidate {i+1}/{len(st.session_state.results)}...")
861
-
862
- llm_explanation = screener.generate_llm_explanation(
863
- result['text'],
864
- st.session_state.current_job_description,
865
- result['final_score'],
866
- result['skills']
867
- )
868
- result['explanation'] = llm_explanation
869
-
870
- explanation_progress.progress((i + 1) / len(st.session_state.results))
871
-
872
- explanation_progress.empty()
873
- explanation_text.empty()
874
-
875
- # Mark explanations as generated
876
- st.session_state.explanations_generated = True
877
-
878
- st.success(f"πŸ€– AI explanations generated for all {len(st.session_state.results)} candidates!")
879
-
880
- except Exception as e:
881
- st.error(f"❌ Error generating explanations: {str(e)}")
882
-
883
- elif st.session_state.results and st.session_state.explanations_generated:
884
- st.info("βœ… AI explanations already generated!")
885
-
886
- elif st.session_state.results and not use_llm_explanations:
887
- st.info("πŸ’‘ Enable 'Generate AI Explanations' in sidebar to use this feature")
888
-
889
- elif st.session_state.results and not st.session_state.vllm_14b_endpoint:
890
- st.warning("⚠️ LLM model not available. Check your Hugging Face token.")
891
-
892
  # Display Results
893
  if st.session_state.results:
894
  st.header("πŸ† Top Candidates")
@@ -956,7 +845,6 @@ if st.session_state.results:
956
  "Intent_Score": result['intent_score'],
957
  "Intent_Analysis": intent_text,
958
  "Skills": "; ".join(result['skills']),
959
- "AI_Explanation": result['explanation'],
960
  "Resume_Preview": result['text_preview']
961
  })
962
 
@@ -987,9 +875,6 @@ if st.session_state.results:
987
  st.write(f"β€’ {skill}")
988
 
989
  with col2:
990
- st.write("**πŸ’‘ AI-Generated Match Analysis:**")
991
- st.info(result['explanation'])
992
-
993
  st.write("**πŸ“„ Resume Preview:**")
994
  st.text_area("", result['text_preview'], height=200, disabled=True, key=f"preview_{result['rank']}")
995
 
@@ -1049,7 +934,6 @@ with col1:
1049
  st.session_state.resume_texts = []
1050
  st.session_state.file_names = []
1051
  st.session_state.results = []
1052
- st.session_state.explanations_generated = False
1053
  st.session_state.current_job_description = ""
1054
  st.success("βœ… Resumes cleared!")
1055
  st.rerun()
@@ -1059,7 +943,6 @@ with col2:
1059
  st.session_state.resume_texts = []
1060
  st.session_state.file_names = []
1061
  st.session_state.results = []
1062
- st.session_state.explanations_generated = False
1063
  st.session_state.current_job_description = ""
1064
 
1065
  if torch.cuda.is_available():
@@ -1073,7 +956,7 @@ st.markdown("---")
1073
  st.markdown(
1074
  """
1075
  <div style='text-align: center; color: #666;'>
1076
- πŸš€ Powered by BAAI/bge-large-en-v1.5 & Qwen3-14B | Built with Streamlit
1077
  </div>
1078
  """,
1079
  unsafe_allow_html=True
 
19
  import time
20
  import faiss
21
  import re
 
22
 
23
  # Download NLTK resources
24
  try:
 
46
 
47
  # Advanced options
48
  st.subheader("Advanced Options")
49
+ top_k = st.selectbox("Number of results to display", [1,2,3,4,5], index=4)
50
 
51
  # LLM Settings
52
  st.subheader("LLM Settings")
 
65
  st.markdown("### πŸ“Š Models Used")
66
  st.markdown("- **Embedding**: BAAI/bge-large-en-v1.5")
67
  st.markdown("- **Cross-Encoder**: ms-marco-MiniLM-L6-v2")
68
+ st.markdown("- **LLM**: Qwen/Qwen3-1.7B")
69
  st.markdown("### πŸ“ˆ Scoring Formula")
70
  st.markdown("**Final Score = Cross-Encoder (0-1) + BM25 (0.1-0.2) + Intent (0-0.3)**")
71
 
 
80
  st.session_state.resume_texts = []
81
  if 'file_names' not in st.session_state:
82
  st.session_state.file_names = []
 
 
83
  if 'current_job_description' not in st.session_state:
84
  st.session_state.current_job_description = ""
85
+ if 'qwen3_1_7b_tokenizer' not in st.session_state:
86
+ print("[Init] Loading Qwen3-1.7B Tokenizer...")
87
+ st.session_state.qwen3_1_7b_tokenizer = AutoTokenizer.from_pretrained("Qwen/Qwen3-1.7B")
88
+ print("[Init] Qwen3-1.7B Tokenizer Loaded.")
89
+ if 'qwen3_1_7b_model' not in st.session_state:
90
+ print("[Init] Loading Qwen3-1.7B Model...")
91
+ st.session_state.qwen3_1_7b_model = AutoModelForCausalLM.from_pretrained(
92
+ "Qwen/Qwen3-1.7B", torch_dtype="auto", device_map="auto"
93
+ )
94
+ print("[Init] Qwen3-1.7B Model Loaded.")
95
 
96
  @st.cache_resource
97
  def load_embedding_model():
98
  """Load and cache the BGE embedding model"""
99
+ print("[Cache] Attempting to load Embedding Model (BAAI/bge-large-en-v1.5)...")
100
  try:
101
  with st.spinner("πŸ”„ Loading BAAI/bge-large-en-v1.5 model..."):
102
  model = SentenceTransformer('BAAI/bge-large-en-v1.5')
103
  st.success("βœ… Embedding model loaded successfully!")
104
+ print("[Cache] Embedding Model (BAAI/bge-large-en-v1.5) LOADED.")
105
  return model
106
  except Exception as e:
107
  st.error(f"❌ Error loading embedding model: {str(e)}")
 
110
  @st.cache_resource
111
  def load_cross_encoder():
112
  """Load and cache the Cross-Encoder model"""
113
+ print("[Cache] Attempting to load Cross-Encoder Model (ms-marco-MiniLM-L6-v2)...")
114
  try:
115
  with st.spinner("πŸ”„ Loading Cross-Encoder ms-marco-MiniLM-L6-v2..."):
116
  from sentence_transformers import CrossEncoder
117
  model = CrossEncoder('cross-encoder/ms-marco-MiniLM-L6-v2')
118
  st.success("βœ… Cross-Encoder model loaded successfully!")
119
+ print("[Cache] Cross-Encoder Model (ms-marco-MiniLM-L6-v2) LOADED.")
120
  return model
121
  except Exception as e:
122
  st.error(f"❌ Error loading Cross-Encoder model: {str(e)}")
123
  return None
124
 
125
+ def generate_qwen3_response(prompt, tokenizer, model, max_new_tokens=200):
126
+ messages = [{"role": "user", "content": prompt}]
127
+ text = tokenizer.apply_chat_template(
128
+ messages,
129
+ tokenize=False,
130
+ add_generation_prompt=True,
131
+ enable_thinking=True
132
+ )
133
+ model_inputs = tokenizer([text], return_tensors="pt").to(model.device)
134
+ generated_ids = model.generate(
135
+ **model_inputs,
136
+ max_new_tokens=max_new_tokens
137
  )
138
+ output_ids = generated_ids[0][len(model_inputs.input_ids[0]):].tolist()
139
+ response = tokenizer.decode(output_ids, skip_special_tokens=True).strip("\n")
140
+ return response
141
 
142
  class ResumeScreener:
143
  def __init__(self):
144
+ print("[ResumeScreener] Initializing...")
145
+ st.text("Initializing Screener: Loading embedding model...")
146
  self.embedding_model = load_embedding_model()
147
+ st.text("Initializing Screener: Loading cross-encoder model...")
148
  self.cross_encoder = load_cross_encoder()
149
+ print("[ResumeScreener] Initialized.")
150
+ st.text("Screener Ready.")
151
 
152
  def extract_text_from_file(self, file_path, file_type):
153
  """Extract text from various file types"""
 
229
 
230
  def advanced_pipeline_ranking(self, resume_texts, job_description):
231
  """Advanced pipeline: FAISS recall -> Cross-encoder -> BM25 -> LLM intent -> Final ranking"""
232
+ print("[Pipeline] Advanced Pipeline Ranking started.")
233
  if not resume_texts:
234
  return []
235
+ st.info("πŸ” Stage 1: FAISS Recall - Finding top candidates...")
236
+ print("[Pipeline] Calling faiss_recall.")
 
237
  top_50_indices = self.faiss_recall(resume_texts, job_description, top_k=50)
238
+ print(f"[Pipeline] faiss_recall returned {len(top_50_indices)} indices.")
239
+
240
+ st.info("🎯 Stage 2: Cross-Encoder Re-ranking - Selecting top candidates...")
241
+ print("[Pipeline] Calling cross_encoder_rerank.")
242
  top_20_results = self.cross_encoder_rerank(resume_texts, job_description, top_50_indices, top_k=20)
243
+ print(f"[Pipeline] cross_encoder_rerank returned {len(top_20_results)} results.")
244
+
245
+ st.info("πŸ”€ Stage 3: BM25 Keyword Matching...")
246
+ print("[Pipeline] Calling add_bm25_scores.")
247
  top_20_with_bm25 = self.add_bm25_scores(resume_texts, job_description, top_20_results)
248
+ print(f"[Pipeline] add_bm25_scores processed.")
249
+
250
+ st.info("πŸ€– Stage 4: LLM Intent Analysis (Qwen3-1.7B)...")
251
+ print("[Pipeline] Calling add_intent_scores.")
252
  top_20_with_intent = self.add_intent_scores(resume_texts, job_description, top_20_with_bm25)
253
+ print(f"[Pipeline] add_intent_scores processed.")
254
+
255
+ st.info("πŸ† Stage 5: Final Combined Ranking...")
256
+ print("[Pipeline] Calling calculate_final_scores.")
257
  final_results = self.calculate_final_scores(top_20_with_intent)
258
+ print(f"[Pipeline] calculate_final_scores returned {len(final_results)} results.")
259
+ print("[Pipeline] Advanced Pipeline Ranking finished.")
260
  return final_results[:5] # Return top 5
261
 
262
  def faiss_recall(self, resume_texts, job_description, top_k=50):
263
  """Stage 1: Use FAISS for initial recall to find top 50 resumes"""
264
+ print("[faiss_recall] Method started.")
265
+ st.text("FAISS Recall: Embedding job description...")
266
+ job_embedding = self.get_embedding(job_description)
267
+ print("[faiss_recall] Job description embedded.")
268
+ st.text(f"FAISS Recall: Embedding {len(resume_texts)} resumes...")
269
+ resume_embeddings = []
270
+ progress_bar = st.progress(0)
271
+
272
+ for i, text in enumerate(resume_texts):
273
+ if text:
274
+ embedding = self.embedding_model.encode(text[:8192],
275
+ convert_to_numpy=True,
276
+ normalize_embeddings=True)
277
+ resume_embeddings.append(embedding)
278
+ else:
279
+ resume_embeddings.append(np.zeros(1024))
280
+ progress_bar.progress((i + 1) / len(resume_texts))
281
+ if i % 10 == 0: # Print progress every 10 resumes
282
+ print(f"[faiss_recall] Embedded resume {i+1}/{len(resume_texts)}")
283
+
284
+ progress_bar.empty()
285
+ print("[faiss_recall] All resumes embedded.")
286
+ st.text("FAISS Recall: Building FAISS index...")
287
+ resume_embeddings = np.array(resume_embeddings).astype('float32')
288
+ dimension = resume_embeddings.shape[1]
289
+ index = faiss.IndexFlatIP(dimension) # Inner product for cosine similarity
290
+ index.add(resume_embeddings)
291
+ print("[faiss_recall] FAISS index built.")
292
+ st.text("FAISS Recall: Searching index...")
293
+ job_embedding = job_embedding.reshape(1, -1).astype('float32')
294
+ scores, indices = index.search(job_embedding, min(top_k, len(resume_texts)))
295
+ print("[faiss_recall] FAISS search complete.")
296
+ return indices[0].tolist()
 
 
 
297
 
298
  def cross_encoder_rerank(self, resume_texts, job_description, top_50_indices, top_k=20):
299
  """Stage 2: Use Cross-Encoder to re-rank top 50 and select top 20"""
300
+ print("[cross_encoder_rerank] Method started.")
301
  try:
302
  if not self.cross_encoder:
303
  st.error("Cross-encoder not loaded!")
 
318
  if not pairs:
319
  return [(idx, 0.0) for idx in top_50_indices[:top_k]]
320
 
321
+ st.text(f"Cross-Encoder: Preparing {len(pairs)} pairs for re-ranking...")
322
+ print(f"[cross_encoder_rerank] Prepared {len(pairs)} pairs.")
323
  # Get cross-encoder scores
324
  progress_bar = st.progress(0)
325
  scores = []
 
331
  batch_scores = self.cross_encoder.predict(batch)
332
  scores.extend(batch_scores)
333
  progress_bar.progress(min(1.0, (i + batch_size) / len(pairs)))
334
+ print(f"[cross_encoder_rerank] Processed batch {i//batch_size + 1}")
335
 
336
  progress_bar.empty()
337
+ print("[cross_encoder_rerank] All pairs scored.")
338
+ st.text("Cross-Encoder: Re-ranking complete.")
339
 
340
  # Combine indices with scores and sort
341
  indexed_scores = list(zip(valid_indices, scores))
 
349
 
350
  def add_bm25_scores(self, resume_texts, job_description, top_20_results):
351
  """Stage 3: Add BM25 scores to top 20 resumes"""
352
+ print("[add_bm25_scores] Method started.")
353
+ st.text("BM25: Calculating keyword scores...")
354
  try:
355
  # Get texts for top 20
356
  top_20_texts = [resume_texts[idx] for idx, _ in top_20_results]
 
378
  bm25_score = normalized_bm25[i] if i < len(normalized_bm25) else 0.15
379
  results_with_bm25.append((idx, cross_score, bm25_score))
380
 
381
+ print("[add_bm25_scores] BM25 scores calculated and normalized.")
382
+ st.text("BM25: Keyword scores added.")
383
  return results_with_bm25
384
 
385
  except Exception as e:
 
388
 
389
  def add_intent_scores(self, resume_texts, job_description, top_20_with_bm25):
390
  """Stage 4: Add LLM intent analysis scores"""
391
+ print("[add_intent_scores] Method started.")
392
+ st.text(f"LLM Intent: Analyzing intent for {len(top_20_with_bm25)} candidates (Qwen3-1.7B)...")
393
+ results_with_intent = []
394
+ progress_bar = st.progress(0)
395
+
396
+ for i, (idx, cross_score, bm25_score) in enumerate(top_20_with_bm25):
397
+ intent_score = self.analyze_intent(resume_texts[idx], job_description)
398
+ results_with_intent.append((idx, cross_score, bm25_score, intent_score))
399
+ progress_bar.progress((i + 1) / len(top_20_with_bm25))
400
+ print(f"[add_intent_scores] Intent analyzed for candidate {i+1}")
401
+
402
+ progress_bar.empty()
403
+ print("[add_intent_scores] All intents analyzed.")
404
+ st.text("LLM Intent: Analysis complete.")
405
+ return results_with_intent
406
 
407
  def analyze_intent(self, resume_text, job_description):
408
+ """Analyze candidate's intent using Qwen3-1.7B LLM with thinking enabled."""
409
+ print(f"[analyze_intent] Analyzing intent for one resume (Qwen3-1.7B)...")
410
+ st.text("LLM Intent: Analyzing intent (Qwen3-1.7B)...")
411
  try:
412
+ resume_snippet = resume_text[:15000]
413
+ job_snippet = job_description[:5000]
 
414
 
415
+ prompt = f"""You are given a job description and a candidate's resume.\nAnalyze the candidate's resume in detail against the job description to determine if they are genuinely seeking this specific job, or if their profile is a more general fit or perhaps a mismatch.\nProvide a step-by-step thought process for your decision.\nFinally, clearly answer: \"Is the candidate likely seeking THIS SPECIFIC job? Respond with 'Yes', 'Maybe', or 'No' and give a brief justification based on your thought process.\"\n\nJob Description:\n{job_snippet}\n\nCandidate Resume:\n{resume_snippet}\n\nResponse format:\n<think>\n[Your detailed step-by-step thought process comparing resume to JD, noting specific alignments or mismatches that indicate intent. Be thorough.]\n</think>\nIntent: [Yes/Maybe/No]\nReason: [Brief justification based on your thought process]"""
 
 
 
 
 
 
 
 
 
 
 
416
 
417
+ response_text = generate_qwen3_response(
418
  prompt,
419
+ st.session_state.qwen3_1_7b_tokenizer,
420
+ st.session_state.qwen3_1_7b_model,
421
+ max_new_tokens=20000
422
  )
423
+ print(f"[analyze_intent] Qwen3-1.7B full response (first 100 chars): {response_text[:100]}...")
424
+
425
+ thinking_content = "No detailed thought process extracted."
426
+ intent_decision_part = response_text
427
+
428
+ think_start_tag = "<think>"
429
+ think_end_tag = "</think>"
430
 
431
+ start_index = response_text.find(think_start_tag)
432
+ end_index = response_text.rfind(think_end_tag)
433
+
434
+ if start_index != -1 and end_index != -1 and start_index < end_index:
435
+ thinking_content = response_text[start_index + len(think_start_tag):end_index].strip()
436
+ intent_decision_part = response_text[end_index + len(think_end_tag):].strip()
437
+ print(f"[analyze_intent] Thinking content extracted (first 50 chars): {thinking_content[:50]}...")
438
  else:
439
+ print("[analyze_intent] <think> block not found or malformed in response.")
440
+
441
+ response_lower = intent_decision_part.lower()
442
+ intent_score = 0.1
443
+ if 'intent: yes' in response_lower or 'intent:yes' in response_lower:
444
+ intent_score = 0.3
445
+ elif 'intent: no' in response_lower or 'intent:no' in response_lower:
446
+ intent_score = 0.0
447
+
448
+ print(f"[analyze_intent] Parsed Intent: {intent_score}, Decision part: {intent_decision_part[:100]}...")
449
+ return intent_score
450
+
451
  except Exception as e:
452
+ st.warning(f"Error analyzing intent with Qwen3-1.7B: {str(e)}")
453
+ print(f"[analyze_intent] EXCEPTION: {str(e)}")
454
+ return 0.1
455
 
456
  def calculate_final_scores(self, results_with_all_scores):
457
  """Stage 5: Calculate final combined scores"""
458
+ print("[calculate_final_scores] Method started.")
459
+ st.text("Final Ranking: Calculating combined scores...")
460
  try:
461
  final_results = []
462
 
 
478
  # Sort by final score
479
  final_results.sort(key=lambda x: x['final_score'], reverse=True)
480
 
481
+ print("[calculate_final_scores] Final scores calculated and sorted.")
482
+ st.text("Final Ranking: Complete.")
483
  return final_results
484
 
485
  except Exception as e:
 
524
  found_skills.append(word)
525
 
526
  return list(set(found_skills))[:15] # Return top 15 unique skills
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
527
 
528
  def create_download_link(df, filename="resume_screening_results.csv"):
529
  """Create download link for results"""
 
533
 
534
  # Main App Interface
535
  st.title("🎯 AI-Powered Resume Screener")
536
+ st.markdown("*Find the perfect candidates using BAAI/bge-large-en-v1.5 embeddings and Qwen3-1.7B for intent analysis*")
537
  st.markdown("---")
538
 
539
  # Initialize screener
 
560
  st.session_state.resume_texts = []
561
  st.session_state.file_names = []
562
  st.session_state.results = []
 
563
  st.session_state.current_job_description = ""
564
  st.rerun()
565
 
 
730
  disabled=not (job_description and st.session_state.resume_texts),
731
  type="primary",
732
  help="Run the complete 5-stage advanced pipeline"):
733
+ print("--- Advanced Pipeline Analysis Button Clicked ---")
734
  if len(st.session_state.resume_texts) == 0:
735
  st.error("❌ Please upload resumes first!")
736
  elif not job_description.strip():
737
  st.error("❌ Please enter a job description!")
738
  else:
739
+ print("[UI Button] Pre-checks passed. Starting spinner and pipeline.")
740
  with st.spinner("πŸš€ Running Advanced Pipeline Analysis..."):
741
+ st.text("Pipeline Initiated: Starting advanced analysis...")
742
  try:
743
  # Run the advanced pipeline
744
  pipeline_results = screener.advanced_pipeline_ranking(
 
765
  'intent_score': result_data['intent_score'],
766
  'skills': skills,
767
  'text': text,
768
+ 'text_preview': text[:500] + "..." if len(text) > 500 else text
 
769
  })
770
 
 
 
 
 
 
 
 
 
 
771
  # Store in session state
772
  st.session_state.results = results
 
773
  st.session_state.current_job_description = job_description
774
 
775
  st.success(f"πŸš€ Advanced pipeline complete! Found top {len(st.session_state.results)} candidates.")
776
+ st.text("Displaying Top Candidates...")
777
 
778
  except Exception as e:
779
  st.error(f"❌ Error during analysis: {str(e)}")
780
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
781
  # Display Results
782
  if st.session_state.results:
783
  st.header("πŸ† Top Candidates")
 
845
  "Intent_Score": result['intent_score'],
846
  "Intent_Analysis": intent_text,
847
  "Skills": "; ".join(result['skills']),
 
848
  "Resume_Preview": result['text_preview']
849
  })
850
 
 
875
  st.write(f"β€’ {skill}")
876
 
877
  with col2:
 
 
 
878
  st.write("**πŸ“„ Resume Preview:**")
879
  st.text_area("", result['text_preview'], height=200, disabled=True, key=f"preview_{result['rank']}")
880
 
 
934
  st.session_state.resume_texts = []
935
  st.session_state.file_names = []
936
  st.session_state.results = []
 
937
  st.session_state.current_job_description = ""
938
  st.success("βœ… Resumes cleared!")
939
  st.rerun()
 
943
  st.session_state.resume_texts = []
944
  st.session_state.file_names = []
945
  st.session_state.results = []
 
946
  st.session_state.current_job_description = ""
947
 
948
  if torch.cuda.is_available():
 
956
  st.markdown(
957
  """
958
  <div style='text-align: center; color: #666;'>
959
+ πŸš€ Powered by BAAI/bge-large-en-v1.5 & Qwen3-1.7B | Built with Streamlit
960
  </div>
961
  """,
962
  unsafe_allow_html=True
requirements.txt CHANGED
@@ -1,6 +1,6 @@
1
  streamlit==1.31.0
2
- transformers==4.52.0
3
- torch==2.6.0
4
  pdfplumber==0.10.1
5
  PyPDF2==3.0.1
6
  python-docx==1.0.1
@@ -8,15 +8,12 @@ nltk==3.8.1
8
  faiss-cpu==1.7.4
9
  rank-bm25==0.2.2
10
  pandas==2.1.3
11
- numpy>=1.24,<2.0
12
  tqdm==4.66.1
13
  huggingface-hub==0.30.0
14
  bitsandbytes==0.44.1
15
  accelerate==0.27.2
16
  datasets==2.18.0
17
  sentence-transformers==2.7.0
18
- tokenizers==0.21.1
19
  plotly==5.18.0
20
- einops
21
- vllm>=0.8.5
22
- openai>=1.0.0
 
1
  streamlit==1.31.0
2
+ transformers>=4.51.0
3
+ torch==2.1.2
4
  pdfplumber==0.10.1
5
  PyPDF2==3.0.1
6
  python-docx==1.0.1
 
8
  faiss-cpu==1.7.4
9
  rank-bm25==0.2.2
10
  pandas==2.1.3
11
+ numpy==1.24.3
12
  tqdm==4.66.1
13
  huggingface-hub==0.30.0
14
  bitsandbytes==0.44.1
15
  accelerate==0.27.2
16
  datasets==2.18.0
17
  sentence-transformers==2.7.0
 
18
  plotly==5.18.0
19
+ einops