root
commited on
Commit
Β·
baade64
1
Parent(s):
529c12e
ss
Browse files- app.py +165 -282
- 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",
|
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-
|
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 '
|
89 |
-
|
90 |
-
|
91 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|
119 |
-
|
120 |
-
|
121 |
-
|
122 |
-
|
123 |
-
|
124 |
-
|
125 |
-
|
126 |
-
|
|
|
|
|
|
|
127 |
)
|
128 |
-
|
|
|
|
|
129 |
|
130 |
class ResumeScreener:
|
131 |
def __init__(self):
|
132 |
-
|
|
|
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 |
-
|
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 |
-
|
224 |
-
st.
|
|
|
225 |
top_20_results = self.cross_encoder_rerank(resume_texts, job_description, top_50_indices, top_k=20)
|
226 |
-
|
227 |
-
|
228 |
-
st.
|
|
|
229 |
top_20_with_bm25 = self.add_bm25_scores(resume_texts, job_description, top_20_results)
|
230 |
-
|
231 |
-
|
232 |
-
st.
|
|
|
233 |
top_20_with_intent = self.add_intent_scores(resume_texts, job_description, top_20_with_bm25)
|
234 |
-
|
235 |
-
|
236 |
-
st.
|
|
|
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 |
-
|
244 |
-
|
245 |
-
|
246 |
-
|
247 |
-
|
248 |
-
|
249 |
-
|
250 |
-
|
251 |
-
|
252 |
-
|
253 |
-
|
254 |
-
|
255 |
-
|
256 |
-
|
257 |
-
|
258 |
-
|
259 |
-
|
260 |
-
|
261 |
-
|
262 |
-
|
263 |
-
|
264 |
-
|
265 |
-
|
266 |
-
|
267 |
-
|
268 |
-
|
269 |
-
|
270 |
-
|
271 |
-
|
272 |
-
|
273 |
-
|
274 |
-
|
275 |
-
|
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 |
-
|
364 |
-
|
365 |
-
|
366 |
-
|
367 |
-
|
368 |
-
|
369 |
-
|
370 |
-
|
371 |
-
|
372 |
-
|
373 |
-
|
374 |
-
|
375 |
-
|
376 |
-
|
377 |
-
|
378 |
|
379 |
def analyze_intent(self, resume_text, job_description):
|
380 |
-
"""Analyze candidate's intent using LLM"""
|
|
|
|
|
381 |
try:
|
382 |
-
|
383 |
-
|
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 |
-
|
400 |
prompt,
|
401 |
-
st.session_state.
|
402 |
-
|
|
|
403 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
404 |
|
405 |
-
|
406 |
-
|
407 |
-
|
408 |
-
|
409 |
-
|
410 |
-
|
|
|
411 |
else:
|
412 |
-
|
413 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
414 |
except Exception as e:
|
415 |
-
st.warning(f"Error analyzing intent: {str(e)}")
|
416 |
-
|
|
|
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-
|
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-
|
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
|
3 |
-
torch==2.
|
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
|
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
|
|
|
|