michael-agent 1.0.0__py3-none-any.whl → 1.0.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- michael_agent/config/__init__.py +0 -0
- michael_agent/config/settings.py +66 -0
- michael_agent/dashboard/__init__.py +0 -0
- michael_agent/dashboard/app.py +1450 -0
- michael_agent/dashboard/static/__init__.py +0 -0
- michael_agent/dashboard/templates/__init__.py +0 -0
- michael_agent/langgraph_workflow/__init__.py +0 -0
- michael_agent/langgraph_workflow/graph_builder.py +358 -0
- michael_agent/langgraph_workflow/nodes/__init__.py +0 -0
- michael_agent/langgraph_workflow/nodes/assessment_handler.py +177 -0
- michael_agent/langgraph_workflow/nodes/jd_generator.py +139 -0
- michael_agent/langgraph_workflow/nodes/jd_poster.py +156 -0
- michael_agent/langgraph_workflow/nodes/question_generator.py +295 -0
- michael_agent/langgraph_workflow/nodes/recruiter_notifier.py +224 -0
- michael_agent/langgraph_workflow/nodes/resume_analyzer.py +631 -0
- michael_agent/langgraph_workflow/nodes/resume_ingestor.py +225 -0
- michael_agent/langgraph_workflow/nodes/sentiment_analysis.py +309 -0
- michael_agent/utils/__init__.py +0 -0
- michael_agent/utils/email_utils.py +140 -0
- michael_agent/utils/id_mapper.py +14 -0
- michael_agent/utils/jd_utils.py +34 -0
- michael_agent/utils/lms_api.py +226 -0
- michael_agent/utils/logging_utils.py +192 -0
- michael_agent/utils/monitor_utils.py +289 -0
- michael_agent/utils/node_tracer.py +88 -0
- {michael_agent-1.0.0.dist-info → michael_agent-1.0.2.dist-info}/METADATA +2 -2
- michael_agent-1.0.2.dist-info/RECORD +32 -0
- michael_agent-1.0.0.dist-info/RECORD +0 -7
- {michael_agent-1.0.0.dist-info → michael_agent-1.0.2.dist-info}/WHEEL +0 -0
- {michael_agent-1.0.0.dist-info → michael_agent-1.0.2.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,631 @@
|
|
1
|
+
"""
|
2
|
+
Resume Analyzer Node
|
3
|
+
Combines resume parsing and scoring functionality to:
|
4
|
+
1. Extract text from resume PDFs
|
5
|
+
2. Parse resumes to extract candidate details (name, email, phone, skills, experience, education)
|
6
|
+
3. Score the resume relevance against the job description
|
7
|
+
"""
|
8
|
+
|
9
|
+
import os
|
10
|
+
import logging
|
11
|
+
import json
|
12
|
+
import time
|
13
|
+
import re
|
14
|
+
from typing import Dict, Any, List, Optional
|
15
|
+
import numpy as np
|
16
|
+
from sklearn.metrics.pairwise import cosine_similarity
|
17
|
+
import random
|
18
|
+
# Import OpenAI for embeddings and analysis
|
19
|
+
from langchain_openai import AzureOpenAIEmbeddings, AzureChatOpenAI
|
20
|
+
from langchain_core.messages import HumanMessage
|
21
|
+
|
22
|
+
# PDF parsing libraries
|
23
|
+
try:
|
24
|
+
import PyPDF2
|
25
|
+
PYPDF2_AVAILABLE = True
|
26
|
+
except ImportError:
|
27
|
+
PYPDF2_AVAILABLE = False
|
28
|
+
|
29
|
+
try:
|
30
|
+
import fitz # PyMuPDF
|
31
|
+
PYMUPDF_AVAILABLE = True
|
32
|
+
except ImportError:
|
33
|
+
PYMUPDF_AVAILABLE = False
|
34
|
+
|
35
|
+
# Import config
|
36
|
+
from config import settings
|
37
|
+
|
38
|
+
# Configure logging
|
39
|
+
logging.basicConfig(level=logging.INFO)
|
40
|
+
logger = logging.getLogger(__name__)
|
41
|
+
|
42
|
+
#######################################################
|
43
|
+
# Document Parsing Section
|
44
|
+
#######################################################
|
45
|
+
|
46
|
+
class DocumentParser:
|
47
|
+
"""Base class for document parsers"""
|
48
|
+
|
49
|
+
def extract_text(self, file_path: str) -> str:
|
50
|
+
"""Extract text from a document file"""
|
51
|
+
raise NotImplementedError("Subclasses must implement this method")
|
52
|
+
|
53
|
+
class PyPDF2Parser(DocumentParser):
|
54
|
+
"""Parser using PyPDF2"""
|
55
|
+
|
56
|
+
def extract_text(self, file_path: str) -> str:
|
57
|
+
"""Extract text from a PDF using PyPDF2"""
|
58
|
+
if not file_path.lower().endswith('.pdf'):
|
59
|
+
logger.warning(f"PyPDF2 can only parse PDF files, received: {file_path}")
|
60
|
+
return ""
|
61
|
+
|
62
|
+
try:
|
63
|
+
text = ""
|
64
|
+
with open(file_path, 'rb') as file:
|
65
|
+
try:
|
66
|
+
reader = PyPDF2.PdfReader(file)
|
67
|
+
for page in reader.pages:
|
68
|
+
page_text = page.extract_text()
|
69
|
+
if page_text:
|
70
|
+
text += page_text + "\n"
|
71
|
+
except PyPDF2.errors.PdfReadError as e:
|
72
|
+
logger.error(f"PyPDF2 error reading file {file_path}: {str(e)}")
|
73
|
+
return ""
|
74
|
+
return text
|
75
|
+
except Exception as e:
|
76
|
+
logger.error(f"Error parsing PDF with PyPDF2: {str(e)}")
|
77
|
+
return ""
|
78
|
+
|
79
|
+
class PyMuPDFParser(DocumentParser):
|
80
|
+
"""Parser using PyMuPDF"""
|
81
|
+
|
82
|
+
def extract_text(self, file_path: str) -> str:
|
83
|
+
"""Extract text from a PDF using PyMuPDF"""
|
84
|
+
if not file_path.lower().endswith('.pdf'):
|
85
|
+
logger.warning(f"PyMuPDF can only parse PDF files, received: {file_path}")
|
86
|
+
return ""
|
87
|
+
|
88
|
+
try:
|
89
|
+
text = ""
|
90
|
+
doc = fitz.open(file_path)
|
91
|
+
for page in doc:
|
92
|
+
page_text = page.get_text()
|
93
|
+
if page_text:
|
94
|
+
text += page_text + "\n"
|
95
|
+
doc.close() # Important to close the document to avoid resource leaks
|
96
|
+
return text
|
97
|
+
except Exception as e:
|
98
|
+
logger.error(f"Error parsing PDF with PyMuPDF: {str(e)}")
|
99
|
+
return ""
|
100
|
+
|
101
|
+
class TextFileParser(DocumentParser):
|
102
|
+
"""Parser for text files"""
|
103
|
+
|
104
|
+
def extract_text(self, file_path: str) -> str:
|
105
|
+
"""Extract text from a text file"""
|
106
|
+
try:
|
107
|
+
with open(file_path, 'r', encoding='utf-8', errors='ignore') as file:
|
108
|
+
return file.read()
|
109
|
+
except Exception as e:
|
110
|
+
logger.error(f"Error reading text file {file_path}: {str(e)}")
|
111
|
+
return ""
|
112
|
+
|
113
|
+
def get_parser(file_path: str) -> DocumentParser:
|
114
|
+
"""Get the appropriate parser for the given file path"""
|
115
|
+
if not file_path or not os.path.exists(file_path):
|
116
|
+
logger.error(f"Invalid file path: {file_path}")
|
117
|
+
return TextFileParser() # Default to text parser which will handle the error
|
118
|
+
|
119
|
+
file_extension = os.path.splitext(file_path)[1].lower()
|
120
|
+
|
121
|
+
# Use parsers based on file extension
|
122
|
+
if file_extension == '.pdf':
|
123
|
+
# Try PyMuPDF first, then fall back to PyPDF2 if not available
|
124
|
+
if PYMUPDF_AVAILABLE:
|
125
|
+
logger.info(f"Using PyMuPDF parser for {file_path}")
|
126
|
+
return PyMuPDFParser()
|
127
|
+
elif PYPDF2_AVAILABLE:
|
128
|
+
logger.info(f"Using PyPDF2 parser for {file_path}")
|
129
|
+
return PyPDF2Parser()
|
130
|
+
|
131
|
+
# Default to text file parser
|
132
|
+
return TextFileParser()
|
133
|
+
|
134
|
+
def extract_basic_info(text: str) -> Dict[str, Any]:
|
135
|
+
"""Extract basic information from resume text"""
|
136
|
+
data = {
|
137
|
+
"name": None,
|
138
|
+
"email": None,
|
139
|
+
"phone": None,
|
140
|
+
"skills": [],
|
141
|
+
"education": [],
|
142
|
+
"experience": [],
|
143
|
+
"raw_content": text
|
144
|
+
}
|
145
|
+
|
146
|
+
# Extract email
|
147
|
+
email_pattern = r'\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b'
|
148
|
+
email_matches = re.findall(email_pattern, text)
|
149
|
+
if email_matches:
|
150
|
+
data["email"] = email_matches[0]
|
151
|
+
|
152
|
+
# Extract phone
|
153
|
+
phone_pattern = r'\b(?:\+\d{1,3}[- ]?)?\(?\d{3}\)?[- ]?\d{3}[- ]?\d{4}\b'
|
154
|
+
phone_matches = re.findall(phone_pattern, text)
|
155
|
+
if phone_matches:
|
156
|
+
data["phone"] = phone_matches[0]
|
157
|
+
|
158
|
+
# Attempt to extract name (very basic)
|
159
|
+
lines = text.split('\n')
|
160
|
+
if lines and lines[0].strip():
|
161
|
+
potential_name = lines[0].strip()
|
162
|
+
if len(potential_name.split()) <= 3 and len(potential_name) < 40:
|
163
|
+
data["name"] = potential_name
|
164
|
+
|
165
|
+
# Try to extract additional information using LLM
|
166
|
+
try:
|
167
|
+
data.update(extract_resume_data_with_llm(text))
|
168
|
+
except Exception as e:
|
169
|
+
logger.warning(f"Error extracting data with LLM: {str(e)}")
|
170
|
+
|
171
|
+
return data
|
172
|
+
|
173
|
+
def extract_resume_data_with_llm(text: str) -> Dict[str, Any]:
|
174
|
+
"""Use Azure OpenAI to extract more detailed information from the resume"""
|
175
|
+
if not settings.AZURE_OPENAI_KEY or not settings.AZURE_OPENAI_ENDPOINT:
|
176
|
+
return {}
|
177
|
+
|
178
|
+
try:
|
179
|
+
llm = AzureChatOpenAI(
|
180
|
+
azure_deployment=settings.AZURE_OPENAI_DEPLOYMENT,
|
181
|
+
openai_api_version=settings.AZURE_OPENAI_API_VERSION,
|
182
|
+
azure_endpoint=settings.AZURE_OPENAI_ENDPOINT,
|
183
|
+
api_key=settings.AZURE_OPENAI_KEY
|
184
|
+
)
|
185
|
+
|
186
|
+
system_prompt = """
|
187
|
+
Extract the following information from the resume text:
|
188
|
+
1. A list of skills (technical and soft skills)
|
189
|
+
2. Education history (degree, institution, year)
|
190
|
+
3. Work experience (company, position, duration, responsibilities)
|
191
|
+
|
192
|
+
Format the output as a JSON object with the following structure:
|
193
|
+
{
|
194
|
+
"skills": ["skill1", "skill2", ...],
|
195
|
+
"education": [{"degree": "...", "institution": "...", "year": "..."}],
|
196
|
+
"experience": [{"company": "...", "position": "...", "duration": "...", "responsibilities": "..."}]
|
197
|
+
}
|
198
|
+
"""
|
199
|
+
|
200
|
+
# Truncate text if it's too long
|
201
|
+
max_tokens = 6000
|
202
|
+
text = text[:max_tokens] if len(text) > max_tokens else text
|
203
|
+
|
204
|
+
response = llm.invoke([
|
205
|
+
{"role": "system", "content": system_prompt},
|
206
|
+
{"role": "user", "content": text}
|
207
|
+
])
|
208
|
+
|
209
|
+
# Extract JSON from response
|
210
|
+
content = response.content
|
211
|
+
# Find JSON part in the response
|
212
|
+
start_idx = content.find('{')
|
213
|
+
end_idx = content.rfind('}') + 1
|
214
|
+
|
215
|
+
if start_idx >= 0 and end_idx > start_idx:
|
216
|
+
json_str = content[start_idx:end_idx]
|
217
|
+
return json.loads(json_str)
|
218
|
+
|
219
|
+
return {}
|
220
|
+
except Exception as e:
|
221
|
+
logger.error(f"Error extracting data with LLM: {str(e)}")
|
222
|
+
return {}
|
223
|
+
|
224
|
+
#######################################################
|
225
|
+
# Resume Scoring Section
|
226
|
+
#######################################################
|
227
|
+
|
228
|
+
def create_azure_openai_client():
|
229
|
+
"""Create and configure the Azure OpenAI client for chat completions"""
|
230
|
+
try:
|
231
|
+
# Use the correct parameter names for Azure OpenAI
|
232
|
+
api_key = settings.AZURE_OPENAI_API_KEY or settings.AZURE_OPENAI_KEY or settings.OPENAI_API_KEY
|
233
|
+
endpoint = settings.AZURE_OPENAI_ENDPOINT or settings.OPENAI_API_BASE
|
234
|
+
api_version = settings.AZURE_OPENAI_API_VERSION or settings.OPENAI_API_VERSION
|
235
|
+
deployment = settings.AZURE_OPENAI_DEPLOYMENT
|
236
|
+
|
237
|
+
client = AzureChatOpenAI(
|
238
|
+
temperature=0.3,
|
239
|
+
azure_deployment=deployment,
|
240
|
+
azure_endpoint=endpoint,
|
241
|
+
api_key=api_key,
|
242
|
+
api_version=api_version,
|
243
|
+
)
|
244
|
+
|
245
|
+
logger.info(f"Created Azure OpenAI Chat client with deployment {deployment}")
|
246
|
+
return client
|
247
|
+
except Exception as e:
|
248
|
+
logger.error(f"Error initializing Azure OpenAI client: {str(e)}")
|
249
|
+
return None
|
250
|
+
|
251
|
+
def create_embeddings_client():
|
252
|
+
"""Create embeddings client for similarity calculations"""
|
253
|
+
# This function is kept for backward compatibility but is not used in the LLM-only approach
|
254
|
+
logger.info("Embeddings client creation skipped - using direct LLM approach instead")
|
255
|
+
return None
|
256
|
+
|
257
|
+
def extract_resume_sections(resume_text: str) -> Dict[str, str]:
|
258
|
+
"""Extract key sections from resume text"""
|
259
|
+
sections = {
|
260
|
+
"skills": "",
|
261
|
+
"experience": "",
|
262
|
+
"education": "",
|
263
|
+
"full": resume_text
|
264
|
+
}
|
265
|
+
|
266
|
+
# Very basic section extraction based on common headers
|
267
|
+
lines = resume_text.split('\n')
|
268
|
+
current_section = None
|
269
|
+
|
270
|
+
for i, line in enumerate(lines):
|
271
|
+
line_lower = line.lower().strip()
|
272
|
+
|
273
|
+
# Check for common section headers
|
274
|
+
if "skills" in line_lower or "technical skills" in line_lower or "competencies" in line_lower:
|
275
|
+
current_section = "skills"
|
276
|
+
continue
|
277
|
+
elif "experience" in line_lower or "employment" in line_lower or "work history" in line_lower:
|
278
|
+
current_section = "experience"
|
279
|
+
continue
|
280
|
+
elif "education" in line_lower or "academic" in line_lower or "qualifications" in line_lower:
|
281
|
+
current_section = "education"
|
282
|
+
continue
|
283
|
+
|
284
|
+
# Add content to current section
|
285
|
+
if current_section and current_section in sections:
|
286
|
+
sections[current_section] += line + "\n"
|
287
|
+
|
288
|
+
return sections
|
289
|
+
|
290
|
+
def extract_jd_sections(jd_text: str) -> Dict[str, str]:
|
291
|
+
"""Extract key sections from job description text"""
|
292
|
+
sections = {
|
293
|
+
"responsibilities": "",
|
294
|
+
"requirements": "",
|
295
|
+
"full": jd_text
|
296
|
+
}
|
297
|
+
|
298
|
+
# Very basic section extraction based on common headers
|
299
|
+
lines = jd_text.split('\n')
|
300
|
+
current_section = None
|
301
|
+
|
302
|
+
for i, line in enumerate(lines):
|
303
|
+
line_lower = line.lower().strip()
|
304
|
+
|
305
|
+
# Check for common section headers
|
306
|
+
if "responsibilities" in line_lower or "duties" in line_lower or "what you'll do" in line_lower:
|
307
|
+
current_section = "responsibilities"
|
308
|
+
continue
|
309
|
+
elif "requirements" in line_lower or "qualifications" in line_lower or "skills" in line_lower:
|
310
|
+
current_section = "requirements"
|
311
|
+
continue
|
312
|
+
|
313
|
+
# Add content to current section
|
314
|
+
if current_section and current_section in sections:
|
315
|
+
sections[current_section] += line + "\n"
|
316
|
+
|
317
|
+
return sections
|
318
|
+
|
319
|
+
def compute_similarity_score_llm(client, resume_text: str, jd_text: str) -> float:
|
320
|
+
"""Compute similarity score between resume and job description using LLM"""
|
321
|
+
try:
|
322
|
+
# Truncate texts if they are too long for the LLM context window
|
323
|
+
resume_truncated = resume_text[:2000] # Adjust based on your LLM's limits
|
324
|
+
jd_truncated = jd_text[:2000] # Adjust based on your LLM's limits
|
325
|
+
|
326
|
+
# Create the prompt
|
327
|
+
prompt = f"""
|
328
|
+
Your task is to compare a resume and job description to determine how well the candidate matches the job requirements.
|
329
|
+
|
330
|
+
Job Description:
|
331
|
+
{jd_truncated}
|
332
|
+
|
333
|
+
Resume:
|
334
|
+
{resume_truncated}
|
335
|
+
|
336
|
+
Based on the match between the resume and job description, provide a relevance score between 0.0 and 1.0,
|
337
|
+
where 1.0 means perfect match and 0.0 means no match at all.
|
338
|
+
|
339
|
+
Return only a single float value between 0.0 and 1.0 without any explanation.
|
340
|
+
"""
|
341
|
+
|
342
|
+
# Get the response from LLM
|
343
|
+
response = client.invoke([HumanMessage(content=prompt)])
|
344
|
+
|
345
|
+
# Parse the response to extract the score
|
346
|
+
score_text = response.content.strip()
|
347
|
+
|
348
|
+
# Attempt to parse the score from text
|
349
|
+
try:
|
350
|
+
# Handle if model returns additional text with the score
|
351
|
+
score = float(''.join(c for c in score_text if c.isdigit() or c == '.'))
|
352
|
+
# Ensure score is within bounds
|
353
|
+
score = min(max(score, 0.0), 1.0)
|
354
|
+
return score
|
355
|
+
except ValueError:
|
356
|
+
# If we can't parse a score, use a default value
|
357
|
+
logger.error(f"Failed to parse score from LLM response: {score_text}")
|
358
|
+
return 0.5
|
359
|
+
except Exception as e:
|
360
|
+
logger.error(f"Error computing similarity score with LLM: {str(e)}")
|
361
|
+
return 0.0
|
362
|
+
|
363
|
+
#######################################################
|
364
|
+
# Combined Resume Analyzer Node
|
365
|
+
#######################################################
|
366
|
+
|
367
|
+
def analyze_resume(state: Dict[str, Any]) -> Dict[str, Any]:
|
368
|
+
"""Analyze a resume against job requirements"""
|
369
|
+
logger.info("Starting resume analysis...")
|
370
|
+
try:
|
371
|
+
# Skip if no resume path is provided
|
372
|
+
resume_path = state.get("resume_path")
|
373
|
+
if not resume_path:
|
374
|
+
error_message = "No resume path provided for analysis"
|
375
|
+
logger.error(error_message)
|
376
|
+
|
377
|
+
# Add error to state
|
378
|
+
if "errors" not in state:
|
379
|
+
state["errors"] = []
|
380
|
+
state["errors"].append({
|
381
|
+
"step": "resume_analyzer",
|
382
|
+
"error": error_message
|
383
|
+
})
|
384
|
+
return state
|
385
|
+
|
386
|
+
# Check if resume was already processed
|
387
|
+
resume_text = state.get("resume_text")
|
388
|
+
if not resume_text:
|
389
|
+
# Load resume
|
390
|
+
resume_text = load_resume(resume_path)
|
391
|
+
if not resume_text:
|
392
|
+
error_message = f"Failed to load resume text from {resume_path}"
|
393
|
+
logger.error(error_message)
|
394
|
+
|
395
|
+
# Add error to state
|
396
|
+
if "errors" not in state:
|
397
|
+
state["errors"] = []
|
398
|
+
state["errors"].append({
|
399
|
+
"step": "resume_analyzer",
|
400
|
+
"error": error_message
|
401
|
+
})
|
402
|
+
return state
|
403
|
+
|
404
|
+
# Update state with resume text
|
405
|
+
state["resume_text"] = resume_text
|
406
|
+
|
407
|
+
# Extract basic details from resume
|
408
|
+
if not state.get("resume_data"):
|
409
|
+
state["resume_data"] = extract_resume_details(resume_text)
|
410
|
+
|
411
|
+
# Update candidate info in state
|
412
|
+
if state["resume_data"]:
|
413
|
+
state["candidate_name"] = state["resume_data"].get("name")
|
414
|
+
state["candidate_email"] = state["resume_data"].get("email")
|
415
|
+
logger.info(f"Extracted candidate info: {state['candidate_name']} - {state['candidate_email']}")
|
416
|
+
|
417
|
+
# Load job description using the timestamp-based job ID
|
418
|
+
job_id = state.get("job_id")
|
419
|
+
if not job_id:
|
420
|
+
error_message = "Missing job ID for resume analysis"
|
421
|
+
logger.error(error_message)
|
422
|
+
print("[RESUME_ANALYZER] ERROR: No job ID found in state")
|
423
|
+
|
424
|
+
# Add error to state
|
425
|
+
if "errors" not in state:
|
426
|
+
state["errors"] = []
|
427
|
+
state["errors"].append({
|
428
|
+
"step": "resume_analyzer",
|
429
|
+
"error": error_message
|
430
|
+
})
|
431
|
+
return state
|
432
|
+
|
433
|
+
# Instead of getting JD text from state, read it from file
|
434
|
+
from utils.jd_utils import load_job_description
|
435
|
+
|
436
|
+
job_file_path = os.path.join("job_descriptions", f"{job_id}.json")
|
437
|
+
|
438
|
+
# Print debug information
|
439
|
+
print(f"[RESUME_ANALYZER] Looking for job description file: {job_file_path}")
|
440
|
+
|
441
|
+
# Use the utility function to load job description
|
442
|
+
job_data = load_job_description(job_id=job_id, job_file_path=job_file_path)
|
443
|
+
if job_data:
|
444
|
+
jd_text = job_data.get("job_description", "")
|
445
|
+
# Add debug print statement to verify JD content
|
446
|
+
print(f"[RESUME_ANALYZER] JOB DESCRIPTION LOADED for job_id {job_id}. First 200 chars: {jd_text[:200]}...")
|
447
|
+
logger.info(f"[RESUME_ANALYZER] Successfully loaded job description for job_id {job_id} with length {len(jd_text)}")
|
448
|
+
|
449
|
+
# Update the state with the job description text
|
450
|
+
state["job_description_text"] = jd_text
|
451
|
+
state["job_description"] = job_data
|
452
|
+
else:
|
453
|
+
error_message = f"Missing job description for scoring (job_id: {job_id})"
|
454
|
+
logger.error(error_message)
|
455
|
+
print(f"[RESUME_ANALYZER] ERROR: Could not load job description for job_id {job_id}")
|
456
|
+
|
457
|
+
# Add error to state
|
458
|
+
if "errors" not in state:
|
459
|
+
state["errors"] = []
|
460
|
+
state["errors"].append({
|
461
|
+
"step": "resume_analyzer",
|
462
|
+
"error": error_message
|
463
|
+
})
|
464
|
+
return state
|
465
|
+
|
466
|
+
# Create LLM client for direct scoring
|
467
|
+
chat_client = create_azure_openai_client()
|
468
|
+
if not chat_client:
|
469
|
+
logger.warning("Failed to initialize Azure OpenAI client, falling back to random scoring")
|
470
|
+
# Generate a random score between 0.5 and 0.9 for fallback
|
471
|
+
import random
|
472
|
+
overall_score = random.uniform(0.5, 0.9)
|
473
|
+
else:
|
474
|
+
# Extract sections from resume and JD
|
475
|
+
resume_sections = extract_resume_sections(resume_text)
|
476
|
+
jd_sections = extract_jd_sections(jd_text)
|
477
|
+
|
478
|
+
# Compute overall similarity score using LLM
|
479
|
+
overall_score = compute_similarity_score_llm(
|
480
|
+
chat_client,
|
481
|
+
resume_sections["full"],
|
482
|
+
jd_sections["full"]
|
483
|
+
)
|
484
|
+
|
485
|
+
# Store the score in state
|
486
|
+
state["relevance_score"] = overall_score
|
487
|
+
logger.info(f"Resume analysis complete. Overall relevance score: {overall_score}")
|
488
|
+
|
489
|
+
# Save snapshot of the state after analysis
|
490
|
+
save_snapshot(state)
|
491
|
+
|
492
|
+
return state
|
493
|
+
except Exception as e:
|
494
|
+
logger.error(f"Error in resume analysis: {str(e)}")
|
495
|
+
logger.error(traceback.format_exc())
|
496
|
+
|
497
|
+
# Add error to state
|
498
|
+
if "errors" not in state:
|
499
|
+
state["errors"] = []
|
500
|
+
state["errors"].append({
|
501
|
+
"step": "resume_analyzer",
|
502
|
+
"error": str(e)
|
503
|
+
})
|
504
|
+
return state
|
505
|
+
|
506
|
+
import traceback # Add this import at the top with other imports
|
507
|
+
|
508
|
+
def load_resume(file_path: str) -> str:
|
509
|
+
"""Load and extract text from a resume file"""
|
510
|
+
if not os.path.exists(file_path):
|
511
|
+
logger.error(f"Resume file not found: {file_path}")
|
512
|
+
return ""
|
513
|
+
|
514
|
+
try:
|
515
|
+
# Get appropriate parser based on file type
|
516
|
+
parser = get_parser(file_path)
|
517
|
+
|
518
|
+
# Extract text from the file
|
519
|
+
text = parser.extract_text(file_path)
|
520
|
+
|
521
|
+
if not text:
|
522
|
+
logger.warning(f"Extracted empty text from {file_path}")
|
523
|
+
else:
|
524
|
+
logger.info(f"Successfully extracted {len(text)} characters from {file_path}")
|
525
|
+
|
526
|
+
return text
|
527
|
+
except Exception as e:
|
528
|
+
logger.error(f"Error loading resume {file_path}: {str(e)}")
|
529
|
+
logger.error(traceback.format_exc())
|
530
|
+
return ""
|
531
|
+
|
532
|
+
def extract_resume_details(resume_text: str) -> Dict[str, Any]:
|
533
|
+
"""Extract details from resume text"""
|
534
|
+
if not resume_text:
|
535
|
+
logger.warning("Empty resume text provided for extraction")
|
536
|
+
return {}
|
537
|
+
|
538
|
+
try:
|
539
|
+
# Extract basic information from resume text
|
540
|
+
return extract_basic_info(resume_text)
|
541
|
+
except Exception as e:
|
542
|
+
logger.error(f"Error extracting resume details: {str(e)}")
|
543
|
+
logger.error(traceback.format_exc())
|
544
|
+
return {}
|
545
|
+
|
546
|
+
def save_snapshot(state: Dict[str, Any]) -> None:
|
547
|
+
"""Save a snapshot of the state to a JSON file for dashboard access"""
|
548
|
+
try:
|
549
|
+
job_id = state.get("job_id")
|
550
|
+
if not job_id:
|
551
|
+
logger.warning("No job ID found in state, cannot save snapshot")
|
552
|
+
return
|
553
|
+
|
554
|
+
# Create a timestamped snapshot filename
|
555
|
+
timestamp = time.strftime("%Y%m%d%H%M%S")
|
556
|
+
snapshot_dir = os.path.join(settings.LOG_DIR, "snapshots")
|
557
|
+
os.makedirs(snapshot_dir, exist_ok=True)
|
558
|
+
|
559
|
+
snapshot_path = os.path.join(snapshot_dir, f"{timestamp}_{job_id}_after_resume_analyzer.json")
|
560
|
+
|
561
|
+
# Also save a JSON version of the resume in the incoming_resumes folder for the dashboard
|
562
|
+
resume_json_path = None
|
563
|
+
if "resume_path" in state:
|
564
|
+
resume_path = state.get("resume_path")
|
565
|
+
if resume_path and os.path.exists(resume_path):
|
566
|
+
resume_json_path = resume_path + ".json"
|
567
|
+
|
568
|
+
# Create a clean version of state for saving
|
569
|
+
save_state = {
|
570
|
+
"job_id": job_id,
|
571
|
+
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%S.%f"),
|
572
|
+
"status": state.get("status", "unknown"),
|
573
|
+
"resume_path": state.get("resume_path", ""),
|
574
|
+
"errors": state.get("errors", []),
|
575
|
+
}
|
576
|
+
|
577
|
+
# Add resume text if available
|
578
|
+
if "resume_text" in state:
|
579
|
+
save_state["resume_text"] = state["resume_text"]
|
580
|
+
|
581
|
+
# Add resume data if available
|
582
|
+
if "resume_data" in state:
|
583
|
+
save_state["resume_data"] = state["resume_data"]
|
584
|
+
|
585
|
+
# Add candidate name and email if available
|
586
|
+
if "candidate_name" in state:
|
587
|
+
save_state["candidate_name"] = state["candidate_name"]
|
588
|
+
|
589
|
+
if "candidate_email" in state:
|
590
|
+
save_state["candidate_email"] = state["candidate_email"]
|
591
|
+
|
592
|
+
# Add job description if available
|
593
|
+
if "job_description_text" in state:
|
594
|
+
save_state["job_description_text"] = state["job_description_text"]
|
595
|
+
|
596
|
+
if "job_description" in state:
|
597
|
+
save_state["job_description"] = state["job_description"]
|
598
|
+
|
599
|
+
# Add relevance score if available
|
600
|
+
if "relevance_score" in state:
|
601
|
+
save_state["relevance_score"] = state["relevance_score"]
|
602
|
+
|
603
|
+
# Save the snapshot
|
604
|
+
with open(snapshot_path, 'w') as f:
|
605
|
+
json.dump(save_state, f, indent=2)
|
606
|
+
|
607
|
+
logger.info(f"Saved state snapshot to {snapshot_path}")
|
608
|
+
|
609
|
+
# If we have a resume path, also save a JSON version in the incoming_resumes folder
|
610
|
+
if resume_json_path:
|
611
|
+
# For the resume JSON file, create a stripped down version with just what the dashboard needs
|
612
|
+
resume_json = {
|
613
|
+
"job_id": job_id,
|
614
|
+
"name": save_state.get("candidate_name", "Unknown"),
|
615
|
+
"email": save_state.get("candidate_email", ""),
|
616
|
+
"phone": save_state.get("resume_data", {}).get("phone", ""),
|
617
|
+
"application_date": save_state.get("timestamp", ""),
|
618
|
+
"status": "analyzed",
|
619
|
+
"resume_path": save_state.get("resume_path", ""),
|
620
|
+
"relevance_score": save_state.get("relevance_score", 0),
|
621
|
+
"skills": save_state.get("resume_data", {}).get("skills", [])
|
622
|
+
}
|
623
|
+
|
624
|
+
with open(resume_json_path, 'w') as f:
|
625
|
+
json.dump(resume_json, f, indent=2)
|
626
|
+
|
627
|
+
logger.info(f"Saved resume JSON to {resume_json_path}")
|
628
|
+
|
629
|
+
except Exception as e:
|
630
|
+
logger.error(f"Error saving state snapshot: {str(e)}")
|
631
|
+
logger.error(traceback.format_exc())
|