umarch commited on
Commit
0ad22ad
·
verified ·
1 Parent(s): 1afa28e

Upload 2 files

Browse files
Files changed (2) hide show
  1. app.py +285 -0
  2. rag_utils_updated.py +185 -0
app.py ADDED
@@ -0,0 +1,285 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import streamlit as st
2
+ import pandas as pd
3
+ import os
4
+ import logging
5
+ import re
6
+ import uuid
7
+ from chromadb import PersistentClient
8
+ from sentence_transformers import SentenceTransformer
9
+ from langchain_groq import ChatGroq
10
+ from rag_utils_updated import extract_text, preprocess_text, get_embeddings, is_image_pdf, assess_cv, extract_job_requirements
11
+ import plotly.graph_objects as go
12
+ from dotenv import load_dotenv
13
+
14
+ # Logging setup
15
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s")
16
+ logger = logging.getLogger(__name__)
17
+
18
+ load_dotenv()
19
+
20
+ if os.environ.get("LLM_PROMPT") is None:
21
+ st.error("LLM_PROMPT is missing. Check your .env file!")
22
+ if os.environ.get("ADMIN_PASSWORD") is None:
23
+ st.error("ADMIN_PASSWORD is missing. Check your .env file!")
24
+
25
+ st.title("CV Assessment and Ranking App")
26
+
27
+ # Generate a unique session ID for temporary sessions
28
+ if "session_id" not in st.session_state:
29
+ st.session_state.session_id = str(uuid.uuid4())[:8] # Short unique session ID
30
+
31
+ # Initialize session state variables
32
+ for key in ["job_description", "requirements", "detailed_assessments", "cvs", "job_description_embedding"]:
33
+ if key not in st.session_state:
34
+ st.session_state[key] = None if key in ["job_description", "requirements", "job_description_embedding"] else {}
35
+ if "assessment_completed" not in st.session_state:
36
+ st.session_state.assessment_completed = False
37
+ if "admin_logged_in" not in st.session_state:
38
+ st.session_state.admin_logged_in = False
39
+
40
+ # Persistent Storage for Embeddings
41
+ PERMANENT_DB_PATH = "./cv_db"
42
+ db_client = PersistentClient(path=PERMANENT_DB_PATH)
43
+ st.session_state.collection = db_client.get_or_create_collection(f"cv_embeddings_{st.session_state.session_id}")
44
+
45
+ if "embedding_model" not in st.session_state:
46
+ st.session_state.embedding_model = SentenceTransformer('all-mpnet-base-v2')
47
+ if "groq_client" not in st.session_state:
48
+ st.session_state.groq_client = ChatGroq(api_key=os.environ.get("GROQ_API_KEY"))
49
+
50
+ def clear_chroma_db():
51
+ """Clears only the current session's embeddings."""
52
+ try:
53
+ st.session_state.collection.delete(where={"session_id": st.session_state.session_id}) # Delete only this session's embeddings
54
+ st.info("Session-specific embeddings cleared. Starting fresh!")
55
+ except Exception as e:
56
+ st.error(f"Error clearing session embeddings: {e}")
57
+ st.stop()
58
+
59
+ # Ensure the session clears its own embeddings on startup
60
+ clear_chroma_db()
61
+
62
+ import shutil
63
+
64
+ def clear_all_sessions_data():
65
+ """Admin function to delete old session embeddings and reclaim disk space while keeping active sessions."""
66
+ try:
67
+ global db_client
68
+ existing_collections = db_client.list_collections()
69
+
70
+ # Identify active sessions (all currently running session IDs)
71
+ active_sessions = [f"cv_embeddings_{st.session_state.session_id}"]
72
+
73
+ # Delete all collections except currently active ones
74
+ for collection_name in existing_collections:
75
+ if collection_name not in active_sessions:
76
+ db_client.delete_collection(collection_name) # Delete only old session data
77
+
78
+ # Force database compaction to free up space
79
+ db_client = None # Close database connection
80
+ shutil.rmtree(PERMANENT_DB_PATH) # Delete database folder
81
+ os.makedirs(PERMANENT_DB_PATH, exist_ok=True) # Recreate empty database
82
+
83
+ db_client = PersistentClient(path=PERMANENT_DB_PATH) # Reinitialize database
84
+
85
+ st.success("Old session embeddings deleted. Active sessions retained. Database size optimized.")
86
+ except Exception as e:
87
+ st.error(f"Error deleting old session data: {e}")
88
+
89
+
90
+ # Admin Panel for Clearing Old Data
91
+ with st.sidebar:
92
+ st.subheader("Admin Login")
93
+ admin_user = st.text_input("Username", key="admin_user")
94
+ admin_pass = st.text_input("Password", type="password", key="admin_pass")
95
+ if st.button("Login as Admin"):
96
+ if admin_user == "admin" and admin_pass == os.environ.get("ADMIN_PASSWORD"):
97
+ st.session_state.admin_logged_in = True
98
+ st.success("Admin login successful!")
99
+ else:
100
+ st.error("Invalid credentials. Access denied.")
101
+
102
+ if st.session_state.admin_logged_in:
103
+ st.subheader("Admin Actions")
104
+ if st.button("Clear All Stored Embeddings"):
105
+ clear_all_sessions_data()
106
+
107
+ def process_cv(uploaded_file):
108
+ """Processes a single CV file: extracts text, preprocesses, and stores embeddings with a session ID."""
109
+ filename = uploaded_file.name
110
+ session_filename = f"{st.session_state.session_id}_{filename}" # Unique per session
111
+
112
+ try:
113
+ if is_image_pdf(uploaded_file):
114
+ st.warning(f"{filename} appears to be an image-based PDF and cannot be processed.")
115
+ return None
116
+
117
+ text = extract_text(uploaded_file)
118
+ preprocessed_text = preprocess_text(text)
119
+ embedding = get_embeddings(preprocessed_text, st.session_state.embedding_model)
120
+
121
+ st.session_state.collection.add(
122
+ embeddings=[embedding],
123
+ documents=[preprocessed_text],
124
+ ids=[session_filename], # Store session-unique ID
125
+ metadatas=[{"session_id": st.session_state.session_id, "filename": filename}]
126
+ )
127
+ return {"text": preprocessed_text, "embedding": embedding, "session_filename": session_filename}
128
+ except Exception as e:
129
+ st.error(f"Failed to process {filename}: {e}")
130
+ return None
131
+
132
+ def parse_assessment(raw_response, requirements):
133
+ """Parses the LLM's assessment with robust error handling."""
134
+ matches = {
135
+ "technical_lead": "Not Found",
136
+ "hr_specialist": "Not Found",
137
+ "project_manager": "Not Found",
138
+ "final_assessment": "Not Found",
139
+ "recommendation": "Not Found",
140
+ "technical_lead_score": "Not Found",
141
+ "hr_specialist_score": "Not Found",
142
+ "project_manager_score": "Not Found",
143
+ "final_assessment_score": "Not Found",
144
+ }
145
+
146
+ try:
147
+ technical_lead_match = re.search(r"Technical Lead Assessment:\s*(.*?)\s*Technical Lead Score:\s*(\d+)", raw_response, re.IGNORECASE | re.DOTALL)
148
+ if technical_lead_match:
149
+ matches["technical_lead"] = technical_lead_match.group(1).strip()
150
+ matches["technical_lead_score"] = technical_lead_match.group(2)
151
+
152
+ hr_specialist_match = re.search(r"HR Specialist Assessment:\s*(.*?)\s*HR Specialist Score:\s*(\d+)", raw_response, re.IGNORECASE | re.DOTALL)
153
+ if hr_specialist_match:
154
+ matches["hr_specialist"] = hr_specialist_match.group(1).strip()
155
+ matches["hr_specialist_score"] = hr_specialist_match.group(2)
156
+
157
+ project_manager_match = re.search(r"Project Manager Assessment:\s*(.*?)\s*Project Manager Score:\s*(\d+)", raw_response, re.IGNORECASE | re.DOTALL)
158
+ if project_manager_match:
159
+ matches["project_manager"] = project_manager_match.group(1).strip()
160
+ matches["project_manager_score"] = project_manager_match.group(2)
161
+
162
+ final_assessment_match = re.search(r"Final Assessment:\s*(.*?)\s*Final Assessment Score:\s*(\d+)", raw_response, re.IGNORECASE | re.DOTALL)
163
+ if final_assessment_match:
164
+ matches["final_assessment"] = final_assessment_match.group(1).strip()
165
+ matches["final_assessment_score"] = final_assessment_match.group(2)
166
+
167
+ recommendation_match = re.search(r"Recommendation:\s*(.*?)$", raw_response, re.IGNORECASE | re.DOTALL)
168
+ if recommendation_match:
169
+ matches["recommendation"] = recommendation_match.group(1).strip()
170
+ except Exception as e:
171
+ print(f"Error parsing assessment: {e}")
172
+
173
+ return matches
174
+
175
+ # 1. Input Job Description
176
+ st.subheader("Enter Job Description")
177
+ requirements_source = st.radio("Source:", ("File Upload", "Web Page Link", "Text Input"))
178
+
179
+ if requirements_source == "File Upload":
180
+ uploaded_file = st.file_uploader("Upload Job Requirements (PDF/DOCX)", type=["pdf", "docx"])
181
+ if uploaded_file:
182
+ st.session_state.job_description = extract_text(uploaded_file)
183
+ elif requirements_source == "Text Input":
184
+ st.session_state.job_description = st.text_area("Enter Job Requirements", height=200)
185
+
186
+ if st.session_state.job_description:
187
+ st.success("Job description uploaded successfully!")
188
+ if st.session_state.job_description_embedding is None:
189
+ st.session_state.job_description_embedding = get_embeddings(st.session_state.job_description, st.session_state.embedding_model)
190
+ if not st.session_state.requirements:
191
+ st.session_state.requirements = extract_job_requirements(st.session_state.job_description, st.session_state.groq_client)
192
+ if st.session_state.requirements:
193
+ with st.expander("Extracted Job Requirements:"):
194
+ for req in st.session_state.requirements:
195
+ st.write(f"- {req}")
196
+
197
+ # 2. Upload CVs
198
+ st.subheader("Upload CVs (Folder)")
199
+ uploaded_files = st.file_uploader("Choose CV files", accept_multiple_files=True)
200
+
201
+ if uploaded_files and not st.session_state.assessment_completed:
202
+ with st.spinner("Processing uploaded CVs, please wait..."):
203
+ st.write(f"{len(uploaded_files)} CV(s) uploaded.")
204
+ st.session_state.cvs = {}
205
+
206
+ for uploaded_file in uploaded_files:
207
+ result = process_cv(uploaded_file)
208
+ if result:
209
+ st.session_state.cvs[result["session_filename"]] = result
210
+
211
+ st.success("CV embeddings created successfully!")
212
+ st.session_state.assessment_completed = True
213
+
214
+ # Perform detailed assessments automatically
215
+ if st.session_state.assessment_completed:
216
+ st.write("Performing detailed assessments...")
217
+ detailed_assessments = st.session_state.detailed_assessments # Store reference for efficiency
218
+ if not detailed_assessments:
219
+ with st.spinner("Assessing CVs..."):
220
+ for filename, cv_data in st.session_state.cvs.items():
221
+ try:
222
+ assessment = assess_cv(cv_data["text"], st.session_state.requirements, filename, st.session_state.groq_client)
223
+ detailed_assessments[filename] = assessment
224
+ except Exception as e:
225
+ st.error(f"Error assessing {filename}: {e}")
226
+ st.success("Detailed assessments complete!")
227
+
228
+
229
+
230
+ st.subheader("Candidates Assessment and Ranking")
231
+ assessments_df = pd.DataFrame([{**parse_assessment(a["raw_response"], st.session_state.requirements), "filename": f} for f, a in st.session_state.detailed_assessments.items()])
232
+ assessments_df = assessments_df.sort_values(by='final_assessment_score', ascending=False)
233
+ st.dataframe(assessments_df)
234
+
235
+ st.subheader("Detailed Assessment Results")
236
+
237
+ # Iterate through the DataFrame rows to display the UI for each assessment
238
+ for index, row in assessments_df.iterrows():
239
+ st.write(f"**Filename:** {row['filename']}")
240
+ scores = {
241
+ "Technical Lead": int(row["technical_lead_score"]),
242
+ "HR Specialist": int(row["hr_specialist_score"]),
243
+ "Project Manager": int(row["project_manager_score"]),
244
+ "Final Assessment": int(row["final_assessment_score"]),
245
+ }
246
+ scores_df = pd.DataFrame(list(scores.items()), columns=["Expert", "Score"])
247
+
248
+ # Create Plotly bar chart with annotations
249
+ fig = go.Figure(data=[go.Bar(
250
+ x=scores_df["Expert"],
251
+ y=scores_df["Score"],
252
+ text=scores_df["Score"],
253
+ textposition='auto',
254
+ )])
255
+ fig.update_layout(yaxis_range=[0, 100])
256
+
257
+ # Create columns layout
258
+ col1, col2 = st.columns([1, 3])
259
+
260
+ # Display bar chart in the first column with a unique key
261
+ with col1:
262
+ st.plotly_chart(fig, use_container_width=True, key=f"chart_{index}")
263
+
264
+ # Display collapsed panels in the second column
265
+ with col2:
266
+ with st.expander("Technical Lead Assessment"):
267
+ st.write(f"{row['technical_lead']}")
268
+ st.write(f"**Technical Lead Score:** {row['technical_lead_score']}")
269
+
270
+ with st.expander("HR Specialist Assessment"):
271
+ st.write(f"{row['hr_specialist']}")
272
+ st.write(f"**HR Specialist Score:** {row['hr_specialist_score']}")
273
+
274
+ with st.expander("Project Manager Assessment"):
275
+ st.write(f"{row['project_manager']}")
276
+ st.write(f"**Project Manager Score:** {row['project_manager_score']}")
277
+
278
+ with st.expander("Final Assessment"):
279
+ st.write(f"{row['final_assessment']}")
280
+ st.write(f"**Final Assessment Score:** {row['final_assessment_score']}")
281
+
282
+ with st.expander("Recommendation"):
283
+ st.write(f"{row['recommendation']}")
284
+
285
+ st.write("---")
rag_utils_updated.py ADDED
@@ -0,0 +1,185 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import logging
3
+ import requests
4
+ import json
5
+ import PyPDF2
6
+ import docx
7
+ from bs4 import BeautifulSoup
8
+ from chromadb import PersistentClient
9
+ from langchain_groq import ChatGroq
10
+ from langchain.prompts import ChatPromptTemplate
11
+ from langchain.output_parsers import PydanticOutputParser
12
+ from pydantic import BaseModel, Field, ValidationError
13
+ from typing import List
14
+ from sentence_transformers import SentenceTransformer # Import SentenceTransformer
15
+ from dotenv import load_dotenv
16
+
17
+ # Setup logging
18
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s")
19
+ logger = logging.getLogger(__name__)
20
+
21
+ # --- Text Extraction ---
22
+ def extract_text(uploaded_file):
23
+ try:
24
+ if isinstance(uploaded_file, str):
25
+ return extract_text_from_webpage(uploaded_file)
26
+ elif hasattr(uploaded_file, 'type') and uploaded_file.type == "application/pdf":
27
+ if is_image_pdf(uploaded_file):
28
+ logger.warning(f"Image-based PDF detected: {uploaded_file.name}")
29
+ return "" # Skip processing
30
+ return extract_text_from_pdf(uploaded_file)
31
+ elif hasattr(uploaded_file, 'type') and uploaded_file.type == "application/vnd.openxmlformats-officedocument.wordprocessingml.document":
32
+ return extract_text_from_docx(uploaded_file)
33
+ return ""
34
+ except Exception as e:
35
+ logger.error(f"Error extracting text: {e}")
36
+ return ""
37
+
38
+ def is_image_pdf(uploaded_file):
39
+ """Check if a PDF is image-based (contains no selectable text)."""
40
+ try:
41
+ reader = PyPDF2.PdfReader(uploaded_file)
42
+ for page in reader.pages:
43
+ if page.extract_text():
44
+ return False # Text is present, so not an image PDF
45
+ return True # No text detected, likely an image-based PDF
46
+ except Exception as e:
47
+ logger.error(f"Error checking if PDF is image-based: {e}")
48
+ return True # Assume image PDF if error occurs
49
+
50
+ def extract_text_from_pdf(uploaded_file):
51
+ try:
52
+ reader = PyPDF2.PdfReader(uploaded_file)
53
+ return "\n".join([page.extract_text() or "" for page in reader.pages])
54
+ except Exception as e:
55
+ logger.error(f"Error reading PDF {uploaded_file.name}: {e}")
56
+ return ""
57
+
58
+ def extract_text_from_docx(uploaded_file):
59
+ try:
60
+ doc = docx.Document(uploaded_file)
61
+ return "\n".join([para.text for para in doc.paragraphs])
62
+ except Exception as e:
63
+ logger.error(f"Error reading DOCX: {e}")
64
+ return ""
65
+
66
+ def extract_text_from_webpage(url):
67
+ try:
68
+ response = requests.get(url)
69
+ response.raise_for_status()
70
+ soup = BeautifulSoup(response.content, 'html.parser')
71
+ return soup.get_text(separator='\n')
72
+ except requests.exceptions.RequestException as e:
73
+ logger.error(f"Error fetching webpage: {e}")
74
+ return ""
75
+
76
+ def preprocess_text(text):
77
+ return text.lower()
78
+
79
+ def get_embeddings(text, model):
80
+ return model.encode(text)
81
+
82
+ def get_similar_cvs(cvs, job_description_embedding, collection):
83
+ results = collection.query(
84
+ query_embeddings=[job_description_embedding],
85
+ n_results=len(cvs),
86
+ include=["distances", "metadatas"]
87
+ )
88
+
89
+ similar_cvs = []
90
+ for i in range(len(results['metadatas'][0])): # Corrected loop
91
+ metadata = results['metadatas'][0][i]
92
+ if metadata: #Check if metadata exists
93
+ filename = metadata.get('filename') # Use .get to handle missing keys
94
+ if filename: # Check if filename exists in metadata
95
+ similarity_score = 1 - results['distances'][0][i]
96
+ similar_cvs.append({
97
+ "filename": filename,
98
+ "initial_score": similarity_score
99
+ })
100
+ else:
101
+ logger.warning(f"Metadata for CV at index {i} is missing 'filename'.")
102
+ else:
103
+ logger.warning(f"No metadata found for CV at index {i}.")
104
+
105
+
106
+ similar_cvs.sort(key=lambda x: x['initial_score'], reverse=True)
107
+ return similar_cvs
108
+
109
+ # ... (CV Assessment & Ranking functions)
110
+
111
+ class RequirementAssessment(BaseModel):
112
+ requirement: str
113
+ match: str = Field(pattern="^(Yes|No|Partial|Not Applicable)$")
114
+ evidence: str
115
+ justification: str
116
+
117
+ class CandidateAssessment(BaseModel):
118
+ filename: str
119
+ requirements: List[RequirementAssessment]
120
+ overall_assessment: str = Field(pattern="^(Excellent|Good|Fair|Poor)$")
121
+ recommendation: str = Field(pattern="^(Interview|Reject|Maybe)$")
122
+ justification: str
123
+
124
+
125
+ import time
126
+ import requests
127
+ import json
128
+ from pydantic import ValidationError
129
+
130
+
131
+ def assess_cv(cv_text, requirements, filename, groq_client, max_retries=3, retry_delay=2):
132
+ """Assess CV against specific job requirements with Tree-of-Thoughts."""
133
+
134
+ requirements_str = "\n".join([f"- {req}" for req in requirements])
135
+ prompt_template = ChatPromptTemplate.from_template(
136
+
137
+ template = os.environ.get("LLM_PROMPT")
138
+
139
+ )
140
+
141
+ prompt = prompt_template.format_messages(requirements=requirements_str, cv_text=cv_text)
142
+
143
+ # ... (rest of the assess_cv function remains the same)
144
+ for attempt in range(max_retries):
145
+ try:
146
+ response = groq_client.invoke(prompt, timeout=30)
147
+ response_content = response.content
148
+
149
+ return {"filename": filename, "raw_response": response_content}
150
+
151
+ except requests.exceptions.RequestException as e:
152
+ logger.error(f"Network error during Groq API call: {e}")
153
+ if attempt == max_retries - 1:
154
+ return {"filename": filename, "error": "Network error during LLM call"}
155
+ else:
156
+ logger.warning(f"Network error, retrying in {retry_delay} seconds (attempt {attempt+1}/{max_retries}).")
157
+ time.sleep(retry_delay)
158
+ retry_delay *= 2
159
+
160
+ except Exception as e:
161
+ logger.error(f"Groq API error (attempt {attempt + 1}/{max_retries}): {e}")
162
+ if attempt == max_retries - 1:
163
+ return {"filename": filename, "error": "General LLM failure"}
164
+ else:
165
+ logger.warning(f"General LLM error, retrying in {retry_delay} seconds (attempt {attempt+1}/{max_retries}).")
166
+ time.sleep(retry_delay)
167
+ retry_delay *= 2
168
+
169
+ return {"filename": filename, "error": "LLM call failed after multiple retries."}
170
+
171
+ def extract_job_requirements(job_description, groq_client):
172
+ """Extracts job requirements from the job description using the LLM."""
173
+ prompt_template = ChatPromptTemplate.from_template(
174
+ template="Extract the key job requirements from the following job description:\n\n{job_description}\n\nRequirements:"
175
+ )
176
+ prompt = prompt_template.format_messages(job_description=job_description)
177
+
178
+ try:
179
+ response = groq_client.invoke(prompt, timeout=30)
180
+ requirements_text = response.content.strip()
181
+ requirements = [req.strip() for req in requirements_text.split('\n') if req.strip()]
182
+ return requirements
183
+ except Exception as e:
184
+ logger.error(f"Error extracting job requirements: {e}")
185
+ return []