michael-agent 1.0.3__py3-none-any.whl → 1.0.5__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/dashboard/app.py +69 -29
- michael_agent/dashboard/templates/career_portal.html +79 -6
- michael_agent/dashboard/templates/jd_creation.html +44 -1
- michael_agent/dashboard/templates/resume_scoring.html +44 -0
- michael_agent/langgraph_workflow/graph_builder.py +23 -1
- michael_agent/langgraph_workflow/nodes/jd_generator.py +97 -10
- michael_agent/langgraph_workflow/nodes/question_generator.py +31 -9
- michael_agent/langgraph_workflow/nodes/resume_analyzer.py +183 -5
- michael_agent/langgraph_workflow/nodes/sentiment_analysis.py +29 -5
- michael_agent/main.py +88 -32
- {michael_agent-1.0.3.dist-info → michael_agent-1.0.5.dist-info}/METADATA +1 -1
- {michael_agent-1.0.3.dist-info → michael_agent-1.0.5.dist-info}/RECORD +14 -14
- {michael_agent-1.0.3.dist-info → michael_agent-1.0.5.dist-info}/WHEEL +0 -0
- {michael_agent-1.0.3.dist-info → michael_agent-1.0.5.dist-info}/top_level.txt +0 -0
michael_agent/dashboard/app.py
CHANGED
@@ -267,12 +267,13 @@ def generate_jd():
|
|
267
267
|
"errors": []
|
268
268
|
}
|
269
269
|
|
270
|
-
# Use the JD generator node to create the job description
|
270
|
+
# Use the JD generator node to create the job description and screening questions
|
271
271
|
result_state = jd_generator.generate_job_description(state)
|
272
272
|
|
273
273
|
return jsonify({
|
274
274
|
'success': True,
|
275
|
-
'job_description_text': result_state.get('job_description_text', 'Error generating job description')
|
275
|
+
'job_description_text': result_state.get('job_description_text', 'Error generating job description'),
|
276
|
+
'screening_questions': result_state.get('screening_questions', [])
|
276
277
|
})
|
277
278
|
except Exception as e:
|
278
279
|
logger.error(f"Error generating job description: {str(e)}")
|
@@ -295,7 +296,8 @@ def save_jd():
|
|
295
296
|
"job_id": job_id,
|
296
297
|
"timestamp": datetime.now().isoformat(),
|
297
298
|
"job_data": data.get("metadata", {}),
|
298
|
-
"job_description": data.get("content", "")
|
299
|
+
"job_description": data.get("content", ""),
|
300
|
+
"screening_questions": data.get("screening_questions", [])
|
299
301
|
}
|
300
302
|
|
301
303
|
# Save to job_descriptions directory
|
@@ -521,6 +523,9 @@ def get_job(job_id):
|
|
521
523
|
required_skills = jd_data['job_data'].get('required_skills', [])
|
522
524
|
preferred_skills = jd_data['job_data'].get('preferred_skills', [])
|
523
525
|
|
526
|
+
# Extract screening questions if available
|
527
|
+
screening_questions = jd_data.get('screening_questions', [])
|
528
|
+
|
524
529
|
return jsonify({
|
525
530
|
'id': job_id,
|
526
531
|
'title': title,
|
@@ -530,7 +535,8 @@ def get_job(job_id):
|
|
530
535
|
'employment_type': employment_type,
|
531
536
|
'experience_level': experience_level,
|
532
537
|
'required_skills': required_skills,
|
533
|
-
'preferred_skills': preferred_skills
|
538
|
+
'preferred_skills': preferred_skills,
|
539
|
+
'screening_questions': screening_questions
|
534
540
|
})
|
535
541
|
except Exception as e:
|
536
542
|
logger.error(f"Error getting job details: {str(e)}")
|
@@ -1028,6 +1034,16 @@ def get_candidate(candidate_id):
|
|
1028
1034
|
# Add more detailed debug logging
|
1029
1035
|
logger.info(f"Returning candidate data for {candidate_id}, name={name}, has_experience={len(experience)}, has_education={len(education)}")
|
1030
1036
|
|
1037
|
+
# Extract screening questions and answers if available
|
1038
|
+
screening_responses = None
|
1039
|
+
if 'screening_questions' in candidate_data and 'screening_answers' in candidate_data:
|
1040
|
+
screening_responses = {
|
1041
|
+
'questions': candidate_data['screening_questions'],
|
1042
|
+
'answers': candidate_data['screening_answers']
|
1043
|
+
}
|
1044
|
+
elif 'resume_data' in candidate_data and 'screening_responses' in candidate_data['resume_data']:
|
1045
|
+
screening_responses = candidate_data['resume_data']['screening_responses']
|
1046
|
+
|
1031
1047
|
# Return data with sensible defaults for all fields to prevent frontend errors
|
1032
1048
|
response_data = {
|
1033
1049
|
'id': candidate_id,
|
@@ -1046,6 +1062,7 @@ def get_candidate(candidate_id):
|
|
1046
1062
|
'weaknesses': [],
|
1047
1063
|
'overall': 'No analysis available'
|
1048
1064
|
},
|
1065
|
+
'screening_responses': screening_responses,
|
1049
1066
|
'resume_path': resume_path or '',
|
1050
1067
|
'status': candidate_data.get('status', 'new') or 'new'
|
1051
1068
|
}
|
@@ -1170,6 +1187,7 @@ def apply_for_job():
|
|
1170
1187
|
try:
|
1171
1188
|
# Check if all required fields are present
|
1172
1189
|
if 'resume_file' not in request.files:
|
1190
|
+
logger.error("No resume file provided in request")
|
1173
1191
|
return jsonify({'success': False, 'error': 'No resume file provided'}), 400
|
1174
1192
|
|
1175
1193
|
resume_file = request.files['resume_file']
|
@@ -1178,8 +1196,12 @@ def apply_for_job():
|
|
1178
1196
|
email = request.form.get('email')
|
1179
1197
|
|
1180
1198
|
if not resume_file or not job_id or not name or not email:
|
1199
|
+
logger.error(f"Missing required fields: resume_file={bool(resume_file)}, job_id={job_id}, name={name}, email={email}")
|
1181
1200
|
return jsonify({'success': False, 'error': 'Missing required fields'}), 400
|
1182
1201
|
|
1202
|
+
# Log all form data for debugging
|
1203
|
+
logger.info(f"Received application form data: {request.form}")
|
1204
|
+
|
1183
1205
|
# Check if directory exists and create if not
|
1184
1206
|
import os
|
1185
1207
|
from datetime import datetime
|
@@ -1190,13 +1212,44 @@ def apply_for_job():
|
|
1190
1212
|
|
1191
1213
|
# Generate unique filename with timestamp
|
1192
1214
|
timestamp = datetime.now().strftime('%Y%m%d%H%M%S')
|
1193
|
-
filename = f"{job_id}_{request.form.get('name').replace(' ', '_')}.{resume_file.filename.split('.')[-1]}"
|
1215
|
+
filename = f"{timestamp}_{job_id}_{request.form.get('name').replace(' ', '_')}.{resume_file.filename.split('.')[-1]}"
|
1194
1216
|
filepath = os.path.join(incoming_dir, filename)
|
1195
1217
|
|
1196
1218
|
# Save the file
|
1197
1219
|
resume_file.save(filepath)
|
1198
1220
|
logger.info(f"Saved resume file to {filepath}")
|
1199
1221
|
|
1222
|
+
# Get job screening questions
|
1223
|
+
screening_questions = []
|
1224
|
+
job_file = os.path.join(settings.JOB_DESCRIPTIONS_DIR, f'{job_id}.json')
|
1225
|
+
if os.path.exists(job_file):
|
1226
|
+
try:
|
1227
|
+
with open(job_file, 'r') as f:
|
1228
|
+
job_data = json.load(f)
|
1229
|
+
screening_questions = job_data.get('screening_questions', [])
|
1230
|
+
logger.info(f"Found {len(screening_questions)} screening questions for job {job_id}")
|
1231
|
+
except Exception as e:
|
1232
|
+
logger.error(f"Error reading job file for screening questions: {str(e)}")
|
1233
|
+
|
1234
|
+
# Extract screening answers from form data
|
1235
|
+
# Look for fields named screening_answer_0, screening_answer_1, etc.
|
1236
|
+
screening_responses = []
|
1237
|
+
for i, question in enumerate(screening_questions):
|
1238
|
+
answer_key = f'screening_answer_{i}'
|
1239
|
+
if answer_key in request.form:
|
1240
|
+
answer = request.form.get(answer_key)
|
1241
|
+
logger.info(f"Found screening answer {i}: {answer}")
|
1242
|
+
screening_responses.append({
|
1243
|
+
"question": question,
|
1244
|
+
"answer": answer
|
1245
|
+
})
|
1246
|
+
else:
|
1247
|
+
logger.warning(f"Missing screening answer for question {i}")
|
1248
|
+
screening_responses.append({
|
1249
|
+
"question": question,
|
1250
|
+
"answer": "No answer provided"
|
1251
|
+
})
|
1252
|
+
|
1200
1253
|
# Create metadata file with applicant details
|
1201
1254
|
metadata = {
|
1202
1255
|
'job_id': job_id,
|
@@ -1204,41 +1257,28 @@ def apply_for_job():
|
|
1204
1257
|
'email': email,
|
1205
1258
|
'phone': request.form.get('phone', ''),
|
1206
1259
|
'cover_letter': request.form.get('cover_letter', ''),
|
1260
|
+
'screening_questions': screening_questions,
|
1261
|
+
'screening_responses': screening_responses,
|
1207
1262
|
'application_date': datetime.now().isoformat(),
|
1208
1263
|
'status': 'new',
|
1209
1264
|
'resume_path': filepath
|
1210
1265
|
}
|
1211
1266
|
|
1212
|
-
metadata_path =
|
1267
|
+
metadata_path = filepath.replace('.pdf', '.json')
|
1213
1268
|
with open(metadata_path, 'w') as f:
|
1214
1269
|
json.dump(metadata, f, indent=2)
|
1215
1270
|
|
1271
|
+
logger.info(f"Saved metadata with screening responses to {metadata_path}")
|
1272
|
+
|
1216
1273
|
# In a real implementation, trigger the workflow to process the resume
|
1217
1274
|
from langgraph_workflow.graph_builder import process_new_resume
|
1218
1275
|
|
1219
|
-
#
|
1220
|
-
|
1221
|
-
|
1222
|
-
|
1223
|
-
|
1224
|
-
|
1225
|
-
job_data = json.load(f)
|
1226
|
-
|
1227
|
-
# Extract title from job description using regex
|
1228
|
-
import re
|
1229
|
-
title_match = re.search(r'\*\*Job Title:\s*(.*?)\*\*', job_data.get('job_description', ''))
|
1230
|
-
title = title_match.group(1) if title_match else "Untitled Job"
|
1231
|
-
|
1232
|
-
job_description = {
|
1233
|
-
'id': job_id,
|
1234
|
-
'title': title,
|
1235
|
-
'content': job_data.get('job_description', ''),
|
1236
|
-
'metadata': job_data.get('job_data', {})
|
1237
|
-
}
|
1238
|
-
|
1239
|
-
# We only need to save the file and metadata - the file system watcher will handle the processing
|
1240
|
-
# This prevents duplicate workflow execution
|
1241
|
-
logger.info(f"Resume uploaded through career portal - file system watcher will process it automatically")
|
1276
|
+
# Start processing in a background thread to avoid blocking the response
|
1277
|
+
import threading
|
1278
|
+
thread = threading.Thread(target=process_new_resume, args=(filepath, job_id))
|
1279
|
+
thread.daemon = True
|
1280
|
+
thread.start()
|
1281
|
+
logger.info(f"Started background processing for resume: {filepath}")
|
1242
1282
|
|
1243
1283
|
return jsonify({
|
1244
1284
|
'success': True,
|
@@ -168,6 +168,12 @@
|
|
168
168
|
<label for="cover-letter" class="form-label">Cover Letter</label>
|
169
169
|
<textarea class="form-control" id="cover-letter" name="cover_letter" rows="4"></textarea>
|
170
170
|
</div>
|
171
|
+
|
172
|
+
<!-- Screening Questions Section (will be populated dynamically) -->
|
173
|
+
<div id="screening-questions-container" class="mb-3 d-none">
|
174
|
+
<h5 class="mb-3">Screening Questions</h5>
|
175
|
+
<div id="screening-questions-list"></div>
|
176
|
+
</div>
|
171
177
|
</form>
|
172
178
|
</div>
|
173
179
|
<div class="modal-footer">
|
@@ -222,20 +228,65 @@
|
|
222
228
|
|
223
229
|
// Apply button in job modal
|
224
230
|
document.getElementById('apply-button').addEventListener('click', function() {
|
225
|
-
// Get job details from the current modal
|
226
231
|
const jobId = this.getAttribute('data-job-id');
|
227
|
-
|
228
|
-
|
229
|
-
// Set values in application modal
|
232
|
+
if (!jobId) return;
|
233
|
+
// Set job ID in hidden field
|
230
234
|
document.getElementById('application-job-id').value = jobId;
|
235
|
+
|
236
|
+
// Get job title
|
237
|
+
const jobTitle = document.getElementById('modal-job-title').textContent;
|
231
238
|
document.getElementById('application-job-title').textContent = jobTitle;
|
232
239
|
|
240
|
+
console.log('Fetching job details for screening questions, job ID:', jobId);
|
241
|
+
|
242
|
+
// Fetch job details to get screening questions
|
243
|
+
fetch(`/api/jobs/${jobId}`)
|
244
|
+
.then(response => response.json())
|
245
|
+
.then(jobDetails => {
|
246
|
+
console.log('Job details received:', jobDetails);
|
247
|
+
// Show screening questions if available
|
248
|
+
if (jobDetails.screening_questions && jobDetails.screening_questions.length > 0) {
|
249
|
+
console.log('Screening questions found:', jobDetails.screening_questions);
|
250
|
+
const questionsContainer = document.getElementById('screening-questions-container');
|
251
|
+
const questionsList = document.getElementById('screening-questions-list');
|
252
|
+
|
253
|
+
// Show the container
|
254
|
+
questionsContainer.classList.remove('d-none');
|
255
|
+
|
256
|
+
// Clear previous questions
|
257
|
+
questionsList.innerHTML = '';
|
258
|
+
|
259
|
+
// Add each question
|
260
|
+
jobDetails.screening_questions.forEach((question, index) => {
|
261
|
+
const questionDiv = document.createElement('div');
|
262
|
+
questionDiv.className = 'mb-3';
|
263
|
+
questionDiv.innerHTML = `
|
264
|
+
<label class="form-label fw-bold">${index + 1}. ${question}</label>
|
265
|
+
<textarea class="form-control" name="screening_answer_${index}" rows="2" required></textarea>
|
266
|
+
`;
|
267
|
+
questionsList.appendChild(questionDiv);
|
268
|
+
});
|
269
|
+
} else {
|
270
|
+
console.log('No screening questions found for this job');
|
271
|
+
// Hide the container if no questions
|
272
|
+
document.getElementById('screening-questions-container').classList.add('d-none');
|
273
|
+
}
|
274
|
+
})
|
275
|
+
.catch(error => {
|
276
|
+
console.error('Error fetching job details:', error);
|
277
|
+
document.getElementById('screening-questions-container').classList.add('d-none');
|
278
|
+
});
|
279
|
+
|
233
280
|
// Hide job details modal and show application modal
|
234
281
|
const jobModal = bootstrap.Modal.getInstance(document.getElementById('job-details-modal'));
|
235
282
|
jobModal.hide();
|
236
283
|
|
237
|
-
|
238
|
-
|
284
|
+
// Show application modal after a short delay to ensure the previous modal is closed
|
285
|
+
setTimeout(() => {
|
286
|
+
const applicationModal = new bootstrap.Modal(document.getElementById('application-modal'));
|
287
|
+
applicationModal.show();
|
288
|
+
console.log('Application modal shown');
|
289
|
+
}, 300);
|
239
290
|
});
|
240
291
|
|
241
292
|
// Submit application
|
@@ -259,6 +310,24 @@
|
|
259
310
|
}
|
260
311
|
formData.append('resume_file', resumeFile);
|
261
312
|
|
313
|
+
// Collect screening question answers
|
314
|
+
const screeningAnswers = [];
|
315
|
+
document.querySelectorAll('[name^="screening_answer_"]').forEach(input => {
|
316
|
+
// Check if the answer field is empty
|
317
|
+
if (!input.value.trim() && input.required) {
|
318
|
+
alert('Please answer all screening questions');
|
319
|
+
valid = false;
|
320
|
+
return;
|
321
|
+
}
|
322
|
+
screeningAnswers.push(input.value);
|
323
|
+
});
|
324
|
+
|
325
|
+
// Add screening answers as JSON
|
326
|
+
if (screeningAnswers.length > 0) {
|
327
|
+
console.log('Adding screening answers to form data:', screeningAnswers);
|
328
|
+
formData.append('screening_answers', JSON.stringify(screeningAnswers));
|
329
|
+
}
|
330
|
+
|
262
331
|
// Show spinner
|
263
332
|
this.disabled = true;
|
264
333
|
document.getElementById('submit-spinner').classList.remove('d-none');
|
@@ -281,6 +350,10 @@
|
|
281
350
|
|
282
351
|
// Reset form
|
283
352
|
form.reset();
|
353
|
+
|
354
|
+
// Clear screening questions
|
355
|
+
document.getElementById('screening-questions-container').classList.add('d-none');
|
356
|
+
document.getElementById('screening-questions-list').innerHTML = '';
|
284
357
|
} else {
|
285
358
|
alert('Error submitting application: ' + data.error);
|
286
359
|
}
|
@@ -105,6 +105,11 @@
|
|
105
105
|
<label for="application_deadline" class="form-label">Application Deadline (Optional)</label>
|
106
106
|
<input type="date" class="form-control" id="application_deadline" name="application_deadline">
|
107
107
|
</div>
|
108
|
+
|
109
|
+
<div class="alert alert-info mb-3">
|
110
|
+
<h5><i class="bi bi-info-circle"></i> Screening Questions</h5>
|
111
|
+
<p>When you generate a job description, 5 relevant screening questions will be automatically created based on the job details. These questions will be presented to candidates when they apply.</p>
|
112
|
+
</div>
|
108
113
|
|
109
114
|
<div class="text-center">
|
110
115
|
<button type="submit" class="btn btn-primary" id="generate-btn">
|
@@ -128,6 +133,15 @@
|
|
128
133
|
<div id="jd-sections">
|
129
134
|
<h3 id="jd-title" class="mb-3"></h3>
|
130
135
|
<div id="jd-content"></div>
|
136
|
+
|
137
|
+
<hr class="my-4">
|
138
|
+
<div id="screening-questions-section" class="mt-4 p-4 bg-light border rounded">
|
139
|
+
<h4 class="mb-3"><i class="bi bi-question-circle"></i> Screening Questions</h4>
|
140
|
+
<div class="alert alert-info">
|
141
|
+
<strong>Important:</strong> These questions will be presented to candidates when they apply for this position.
|
142
|
+
</div>
|
143
|
+
<ol id="screening-questions-list" class="fw-bold"></ol>
|
144
|
+
</div>
|
131
145
|
</div>
|
132
146
|
</div>
|
133
147
|
</div>
|
@@ -240,6 +254,25 @@
|
|
240
254
|
// Display the generated JD
|
241
255
|
document.getElementById('jd-title').textContent = jobData.position;
|
242
256
|
document.getElementById('jd-content').innerHTML = data.job_description_text.replace(/\n/g, '<br>');
|
257
|
+
|
258
|
+
// Display screening questions
|
259
|
+
const screeningQuestionsList = document.getElementById('screening-questions-list');
|
260
|
+
screeningQuestionsList.innerHTML = ''; // Clear previous questions
|
261
|
+
|
262
|
+
if (data.screening_questions && data.screening_questions.length > 0) {
|
263
|
+
data.screening_questions.forEach(question => {
|
264
|
+
const li = document.createElement('li');
|
265
|
+
li.className = 'mb-2';
|
266
|
+
li.textContent = question;
|
267
|
+
screeningQuestionsList.appendChild(li);
|
268
|
+
});
|
269
|
+
// Store screening questions for saving
|
270
|
+
window.screeningQuestions = data.screening_questions;
|
271
|
+
} else {
|
272
|
+
screeningQuestionsList.innerHTML = '<div class="alert alert-warning">No screening questions were generated.</div>';
|
273
|
+
window.screeningQuestions = [];
|
274
|
+
}
|
275
|
+
|
243
276
|
document.getElementById('result-card').style.display = 'block';
|
244
277
|
|
245
278
|
// Scroll to result
|
@@ -264,7 +297,16 @@
|
|
264
297
|
const jdText = document.getElementById('jd-content').innerText;
|
265
298
|
const title = document.getElementById('jd-title').innerText;
|
266
299
|
|
267
|
-
|
300
|
+
// Prepare screening questions text
|
301
|
+
let screeningQuestionsText = '';
|
302
|
+
if (window.screeningQuestions && window.screeningQuestions.length > 0) {
|
303
|
+
screeningQuestionsText = '\n\nScreening Questions:\n';
|
304
|
+
window.screeningQuestions.forEach((question, index) => {
|
305
|
+
screeningQuestionsText += `${index + 1}. ${question}\n`;
|
306
|
+
});
|
307
|
+
}
|
308
|
+
|
309
|
+
navigator.clipboard.writeText(title + '\n\n' + jdText + screeningQuestionsText)
|
268
310
|
.then(() => {
|
269
311
|
const originalText = this.textContent;
|
270
312
|
this.textContent = 'Copied!';
|
@@ -291,6 +333,7 @@
|
|
291
333
|
body: JSON.stringify({
|
292
334
|
title: title,
|
293
335
|
content: jdText,
|
336
|
+
screening_questions: window.screeningQuestions || [],
|
294
337
|
metadata: {
|
295
338
|
position: document.getElementById('position').value,
|
296
339
|
location: document.getElementById('location').value,
|
@@ -177,6 +177,15 @@
|
|
177
177
|
</div>
|
178
178
|
</div>
|
179
179
|
|
180
|
+
<div class="card border-primary mb-3">
|
181
|
+
<div class="card-header bg-light">Screening Responses</div>
|
182
|
+
<div class="card-body">
|
183
|
+
<div id="screening-responses-container">
|
184
|
+
<p class="text-muted">No screening responses available</p>
|
185
|
+
</div>
|
186
|
+
</div>
|
187
|
+
</div>
|
188
|
+
|
180
189
|
<div class="card border-success">
|
181
190
|
<div class="card-header bg-light">Actions</div>
|
182
191
|
<div class="card-body">
|
@@ -482,6 +491,9 @@
|
|
482
491
|
|
483
492
|
// AI Analysis - make sure we don't pass undefined
|
484
493
|
renderAIAnalysis(candidate.ai_analysis || {});
|
494
|
+
|
495
|
+
// Screening Responses - make sure we don't pass undefined
|
496
|
+
renderScreeningResponses(candidate.screening_responses || {});
|
485
497
|
} catch (error) {
|
486
498
|
console.error('Error processing candidate data:', error);
|
487
499
|
document.getElementById('resume-details-card').style.display = 'block';
|
@@ -906,6 +918,38 @@
|
|
906
918
|
}
|
907
919
|
}
|
908
920
|
|
921
|
+
// Render screening questions and answers
|
922
|
+
function renderScreeningResponses(screeningData) {
|
923
|
+
const container = document.getElementById('screening-responses-container');
|
924
|
+
container.innerHTML = '';
|
925
|
+
|
926
|
+
if (!screeningData || !screeningData.questions || !screeningData.answers ||
|
927
|
+
screeningData.questions.length === 0 || screeningData.answers.length === 0) {
|
928
|
+
container.innerHTML = '<p class="text-muted">No screening responses available</p>';
|
929
|
+
return;
|
930
|
+
}
|
931
|
+
|
932
|
+
let html = '<div class="list-group">';
|
933
|
+
|
934
|
+
// Loop through questions and answers
|
935
|
+
for (let i = 0; i < Math.min(screeningData.questions.length, screeningData.answers.length); i++) {
|
936
|
+
const question = screeningData.questions[i];
|
937
|
+
const answer = screeningData.answers[i];
|
938
|
+
|
939
|
+
html += `
|
940
|
+
<div class="list-group-item">
|
941
|
+
<div class="d-flex w-100 justify-content-between">
|
942
|
+
<h6 class="mb-2">Q${i+1}: ${question}</h6>
|
943
|
+
</div>
|
944
|
+
<p class="mb-1"><strong>Answer:</strong> ${answer}</p>
|
945
|
+
</div>
|
946
|
+
`;
|
947
|
+
}
|
948
|
+
|
949
|
+
html += '</div>';
|
950
|
+
container.innerHTML = html;
|
951
|
+
}
|
952
|
+
|
909
953
|
// Update statistics
|
910
954
|
function updateStatistics(stats) {
|
911
955
|
document.getElementById('total-resumes').textContent = stats.total;
|
@@ -269,7 +269,7 @@ def start_workflow():
|
|
269
269
|
logger.error(traceback.format_exc())
|
270
270
|
raise
|
271
271
|
|
272
|
-
def process_new_resume(resume_path
|
272
|
+
def process_new_resume(resume_path, job_id=None):
|
273
273
|
"""Process a new resume through the workflow"""
|
274
274
|
try:
|
275
275
|
# Extract job ID from filename (e.g., 20250626225207_Michael_Jone.pdf)
|
@@ -280,6 +280,28 @@ def process_new_resume(resume_path: str, job_description: Dict[str, Any] = None)
|
|
280
280
|
# Create initial state with extracted job ID
|
281
281
|
initial_state = create_initial_state(resume_path=resume_path, job_id=job_id)
|
282
282
|
|
283
|
+
# Check for metadata file with screening questions/answers
|
284
|
+
metadata_path = f"{resume_path}.json"
|
285
|
+
if os.path.exists(metadata_path):
|
286
|
+
try:
|
287
|
+
with open(metadata_path, 'r') as f:
|
288
|
+
metadata = json.load(f)
|
289
|
+
|
290
|
+
# Extract screening questions and answers
|
291
|
+
if 'screening_questions' in metadata and 'screening_answers' in metadata:
|
292
|
+
logger.info(f"Found screening questions/answers for resume {resume_path}")
|
293
|
+
initial_state['screening_questions'] = metadata['screening_questions']
|
294
|
+
initial_state['screening_answers'] = metadata['screening_answers']
|
295
|
+
|
296
|
+
# Also extract candidate name and email if available
|
297
|
+
if 'name' in metadata:
|
298
|
+
initial_state['candidate_name'] = metadata['name']
|
299
|
+
if 'email' in metadata:
|
300
|
+
initial_state['candidate_email'] = metadata['email']
|
301
|
+
|
302
|
+
except Exception as e:
|
303
|
+
logger.error(f"Error loading metadata file: {str(e)}")
|
304
|
+
|
283
305
|
# Start workflow with initial state
|
284
306
|
workflow = build_workflow()
|
285
307
|
logger.info(f"[Job {job_id}] Starting workflow processing with job ID {job_id}")
|
@@ -70,6 +70,35 @@ def get_jd_prompt(job_data: Dict[str, Any]) -> str:
|
|
70
70
|
proposition for potential candidates.
|
71
71
|
"""
|
72
72
|
|
73
|
+
def get_screening_questions_prompt(job_data: Dict[str, Any]) -> str:
|
74
|
+
"""Generate the prompt for screening questions creation"""
|
75
|
+
position = job_data.get('position', 'Software Engineer')
|
76
|
+
return f"""
|
77
|
+
You are an HR specialist creating initial screening questions for job applicants.
|
78
|
+
Create 5 simple screening questions for a {position} role.
|
79
|
+
|
80
|
+
These questions should:
|
81
|
+
1. Be simple and quick to answer (yes/no or short responses)
|
82
|
+
2. Relate directly to the role's basic requirements
|
83
|
+
3. Help filter candidates early in the process
|
84
|
+
4. Be easy to understand without technical jargon
|
85
|
+
5. Assess key role-related experience or skills
|
86
|
+
|
87
|
+
Job details:
|
88
|
+
Position: {job_data.get('position', 'Software Engineer')}
|
89
|
+
Required skills: {', '.join(job_data.get('required_skills', []))}
|
90
|
+
Experience level: {job_data.get('experience_level', 'Not specified')}
|
91
|
+
|
92
|
+
Format your response as a JSON array with exactly 5 questions:
|
93
|
+
[
|
94
|
+
"Question 1",
|
95
|
+
"Question 2",
|
96
|
+
"Question 3",
|
97
|
+
"Question 4",
|
98
|
+
"Question 5"
|
99
|
+
]
|
100
|
+
"""
|
101
|
+
|
73
102
|
def generate_job_description(state: Dict[str, Any]) -> Dict[str, Any]:
|
74
103
|
"""
|
75
104
|
LangGraph node to generate a job description
|
@@ -83,8 +112,8 @@ def generate_job_description(state: Dict[str, Any]) -> Dict[str, Any]:
|
|
83
112
|
logger.info("Starting job description generation")
|
84
113
|
|
85
114
|
# Check if job description already exists in state
|
86
|
-
if state.get("job_description") and state.get("job_description_text"):
|
87
|
-
logger.info("Job description already
|
115
|
+
if state.get("job_description") and state.get("job_description_text") and state.get("screening_questions"):
|
116
|
+
logger.info("Job description and screening questions already exist, skipping generation")
|
88
117
|
return state
|
89
118
|
|
90
119
|
try:
|
@@ -108,25 +137,73 @@ def generate_job_description(state: Dict[str, Any]) -> Dict[str, Any]:
|
|
108
137
|
"preferred_skills": ["Azure", "CI/CD", "TypeScript"]
|
109
138
|
}
|
110
139
|
|
111
|
-
# Generate the prompt
|
112
|
-
|
140
|
+
# Generate the prompt for job description
|
141
|
+
jd_prompt = get_jd_prompt(job_data)
|
142
|
+
|
143
|
+
# Generate the prompt for screening questions
|
144
|
+
sq_prompt = get_screening_questions_prompt(job_data)
|
145
|
+
|
146
|
+
# Invoke the language model for job description
|
147
|
+
jd_response = llm.invoke(jd_prompt)
|
148
|
+
generated_jd_text = jd_response.content
|
149
|
+
|
150
|
+
# Invoke the language model for screening questions
|
151
|
+
sq_response = llm.invoke(sq_prompt)
|
152
|
+
generated_sq_text = sq_response.content
|
153
|
+
|
154
|
+
# Extract questions from the response (handling JSON format)
|
155
|
+
screening_questions = []
|
156
|
+
try:
|
157
|
+
import json
|
158
|
+
import re
|
159
|
+
|
160
|
+
# Try to parse JSON directly
|
161
|
+
try:
|
162
|
+
screening_questions = json.loads(generated_sq_text)
|
163
|
+
except json.JSONDecodeError:
|
164
|
+
# Extract JSON array using regex if direct parsing fails
|
165
|
+
json_match = re.search(r'\[\s*".*"\s*\]', generated_sq_text, re.DOTALL)
|
166
|
+
if json_match:
|
167
|
+
screening_questions = json.loads(json_match.group(0))
|
168
|
+
else:
|
169
|
+
# Simple line-by-line extraction as fallback
|
170
|
+
lines = generated_sq_text.strip().split('\n')
|
171
|
+
for line in lines:
|
172
|
+
question = line.strip().strip('"[]').strip('"').strip()
|
173
|
+
if question and not question.startswith('#') and not question.startswith('//'):
|
174
|
+
screening_questions.append(question)
|
175
|
+
except Exception as e:
|
176
|
+
logger.error(f"Error parsing screening questions: {str(e)}")
|
177
|
+
# Fallback questions
|
178
|
+
screening_questions = [
|
179
|
+
f"Do you have experience as a {job_data.get('position', 'professional')}?",
|
180
|
+
f"Are you familiar with {', '.join(job_data.get('required_skills', ['the required skills']))}?",
|
181
|
+
f"Can you work in {job_data.get('location', 'this location')}?",
|
182
|
+
f"Are you available for {job_data.get('employment_type', 'this position type')}?",
|
183
|
+
"How many years of relevant experience do you have?"
|
184
|
+
]
|
113
185
|
|
114
|
-
#
|
115
|
-
|
116
|
-
|
186
|
+
# Ensure we have exactly 5 questions
|
187
|
+
if len(screening_questions) > 5:
|
188
|
+
screening_questions = screening_questions[:5]
|
189
|
+
while len(screening_questions) < 5:
|
190
|
+
screening_questions.append(f"Do you have experience with {job_data.get('preferred_skills', ['the preferred skills'])[0] if job_data.get('preferred_skills') else 'the relevant technologies'}?")
|
117
191
|
|
118
|
-
# Update the state with the generated job description
|
192
|
+
# Update the state with the generated job description and screening questions
|
119
193
|
state["job_description"] = job_data
|
120
|
-
state["job_description_text"] =
|
194
|
+
state["job_description_text"] = generated_jd_text
|
195
|
+
state["screening_questions"] = screening_questions
|
121
196
|
state["status"] = "jd_generated"
|
122
197
|
|
123
|
-
logger.info("Job description generated successfully")
|
198
|
+
logger.info("Job description and screening questions generated successfully")
|
124
199
|
|
125
200
|
except Exception as e:
|
126
201
|
error_message = f"Error generating job description: {str(e)}"
|
127
202
|
logger.error(error_message)
|
128
203
|
|
129
204
|
# Add error to state
|
205
|
+
if "errors" not in state:
|
206
|
+
state["errors"] = []
|
130
207
|
state["errors"].append({
|
131
208
|
"step": "jd_generator",
|
132
209
|
"error": error_message
|
@@ -135,5 +212,15 @@ def generate_job_description(state: Dict[str, Any]) -> Dict[str, Any]:
|
|
135
212
|
# Set fallback job description text if needed
|
136
213
|
if not state.get("job_description_text"):
|
137
214
|
state["job_description_text"] = "Default job description text for fallback purposes."
|
215
|
+
|
216
|
+
# Set fallback screening questions if needed
|
217
|
+
if not state.get("screening_questions"):
|
218
|
+
state["screening_questions"] = [
|
219
|
+
"Do you have experience with this role?",
|
220
|
+
"Are you familiar with the required skills?",
|
221
|
+
"Can you work in the specified location?",
|
222
|
+
"Are you available for the listed employment type?",
|
223
|
+
"How many years of relevant experience do you have?"
|
224
|
+
]
|
138
225
|
|
139
226
|
return state
|
@@ -27,9 +27,9 @@ def create_llm():
|
|
27
27
|
logger.error(f"Error initializing Azure OpenAI: {str(e)}")
|
28
28
|
return None
|
29
29
|
|
30
|
-
def get_question_generation_prompt(resume_text: str, jd_text: str, relevance_score: float) -> str:
|
30
|
+
def get_question_generation_prompt(resume_text: str, jd_text: str, relevance_score: float, screening_data=None) -> str:
|
31
31
|
"""Generate the prompt for interview question generation"""
|
32
|
-
|
32
|
+
base_prompt = f"""
|
33
33
|
You are an expert technical interviewer and recruiter.
|
34
34
|
|
35
35
|
Generate a set of interview questions for a candidate based on their resume and the job description.
|
@@ -42,34 +42,47 @@ def get_question_generation_prompt(resume_text: str, jd_text: str, relevance_sco
|
|
42
42
|
{jd_text[:1000]}
|
43
43
|
|
44
44
|
The candidate's resume relevance score is {relevance_score:.2f} out of 1.0.
|
45
|
+
"""
|
45
46
|
|
47
|
+
# Add screening questions and answers if available
|
48
|
+
if screening_data and 'questions' in screening_data and 'answers' in screening_data:
|
49
|
+
screening_prompt = "\nThe candidate has already answered these initial screening questions:\n"
|
50
|
+
for i, (q, a) in enumerate(zip(screening_data['questions'], screening_data['answers'])):
|
51
|
+
screening_prompt += f"Question {i+1}: {q}\nAnswer {i+1}: {a}\n\n"
|
52
|
+
base_prompt += screening_prompt
|
53
|
+
|
54
|
+
# Add format instructions for the response
|
55
|
+
result_prompt = base_prompt + """
|
46
56
|
Generate 10 questions in the following JSON format:
|
47
57
|
```json
|
48
|
-
{
|
58
|
+
{
|
49
59
|
"technical_questions": [
|
50
|
-
{
|
60
|
+
{
|
51
61
|
"question": "string",
|
52
62
|
"difficulty": "easy|medium|hard",
|
53
63
|
"category": "technical_skill|domain_knowledge|problem_solving",
|
54
64
|
"purpose": "brief explanation of what this question assesses"
|
55
|
-
}
|
65
|
+
},
|
56
66
|
// 4 more technical questions...
|
57
67
|
],
|
58
68
|
"behavioral_questions": [
|
59
|
-
{
|
69
|
+
{
|
60
70
|
"question": "string",
|
61
71
|
"category": "teamwork|leadership|conflict_resolution|problem_solving|adaptability",
|
62
72
|
"purpose": "brief explanation of what this question assesses"
|
63
|
-
}
|
73
|
+
},
|
64
74
|
// 4 more behavioral questions...
|
65
75
|
],
|
66
76
|
"follow_up_areas": ["Area 1", "Area 2", "Area 3"] // Important areas to explore further based on resume gaps
|
67
|
-
}
|
77
|
+
}
|
68
78
|
```
|
69
79
|
|
70
80
|
Focus on questions that will reveal the candidate's true abilities related to the job requirements.
|
71
81
|
Include questions that address any potential gaps between the resume and job description.
|
82
|
+
Also, do not repeat questions that were already asked in the screening process.
|
72
83
|
"""
|
84
|
+
|
85
|
+
return result_prompt
|
73
86
|
|
74
87
|
def generate_interview_questions(state: Dict[str, Any]) -> Dict[str, Any]:
|
75
88
|
"""Generate interview questions based on resume and job description."""
|
@@ -162,8 +175,17 @@ def generate_interview_questions(state: Dict[str, Any]) -> Dict[str, Any]:
|
|
162
175
|
# Get resume score from state (default to 0.5 if missing)
|
163
176
|
relevance_score = state.get("relevance_score", 0.5)
|
164
177
|
|
178
|
+
# Prepare screening data if available
|
179
|
+
screening_data = None
|
180
|
+
if state.get("screening_questions") and state.get("screening_answers"):
|
181
|
+
screening_data = {
|
182
|
+
"questions": state.get("screening_questions"),
|
183
|
+
"answers": state.get("screening_answers")
|
184
|
+
}
|
185
|
+
logger.info("Including screening questions and answers in question generation")
|
186
|
+
|
165
187
|
# Generate the prompt
|
166
|
-
prompt = get_question_generation_prompt(resume_text, jd_text, relevance_score)
|
188
|
+
prompt = get_question_generation_prompt(resume_text, jd_text, relevance_score, screening_data)
|
167
189
|
print(f"[QUESTION_GENERATOR] Created prompt with resume length {len(resume_text)} and JD length {len(jd_text)}")
|
168
190
|
|
169
191
|
# Invoke the language model
|
@@ -316,15 +316,15 @@ def extract_jd_sections(jd_text: str) -> Dict[str, str]:
|
|
316
316
|
|
317
317
|
return sections
|
318
318
|
|
319
|
-
def compute_similarity_score_llm(client, resume_text: str, jd_text: str) -> float:
|
319
|
+
def compute_similarity_score_llm(client, resume_text: str, jd_text: str, screening_data: Dict[str, Any] = None) -> float:
|
320
320
|
"""Compute similarity score between resume and job description using LLM"""
|
321
321
|
try:
|
322
322
|
# Truncate texts if they are too long for the LLM context window
|
323
323
|
resume_truncated = resume_text[:2000] # Adjust based on your LLM's limits
|
324
324
|
jd_truncated = jd_text[:2000] # Adjust based on your LLM's limits
|
325
325
|
|
326
|
-
#
|
327
|
-
|
326
|
+
# Start building the prompt
|
327
|
+
base_prompt = f"""
|
328
328
|
Your task is to compare a resume and job description to determine how well the candidate matches the job requirements.
|
329
329
|
|
330
330
|
Job Description:
|
@@ -332,7 +332,17 @@ def compute_similarity_score_llm(client, resume_text: str, jd_text: str) -> floa
|
|
332
332
|
|
333
333
|
Resume:
|
334
334
|
{resume_truncated}
|
335
|
+
"""
|
335
336
|
|
337
|
+
# Add screening questions and answers if available
|
338
|
+
if screening_data and 'questions' in screening_data and 'answers' in screening_data:
|
339
|
+
screening_context = "\nCandidate's screening question responses:\n"
|
340
|
+
for i, (q, a) in enumerate(zip(screening_data['questions'], screening_data['answers'])):
|
341
|
+
screening_context += f"Question {i+1}: {q}\nAnswer {i+1}: {a}\n\n"
|
342
|
+
base_prompt += screening_context
|
343
|
+
|
344
|
+
# Complete the prompt with instructions
|
345
|
+
prompt = base_prompt + """
|
336
346
|
Based on the match between the resume and job description, provide a relevance score between 0.0 and 1.0,
|
337
347
|
where 1.0 means perfect match and 0.0 means no match at all.
|
338
348
|
|
@@ -475,11 +485,21 @@ def analyze_resume(state: Dict[str, Any]) -> Dict[str, Any]:
|
|
475
485
|
resume_sections = extract_resume_sections(resume_text)
|
476
486
|
jd_sections = extract_jd_sections(jd_text)
|
477
487
|
|
488
|
+
# Prepare screening data if available
|
489
|
+
screening_data = None
|
490
|
+
if state.get("screening_questions") and state.get("screening_answers"):
|
491
|
+
screening_data = {
|
492
|
+
"questions": state.get("screening_questions"),
|
493
|
+
"answers": state.get("screening_answers")
|
494
|
+
}
|
495
|
+
logger.info("Including screening questions and answers in similarity scoring")
|
496
|
+
|
478
497
|
# Compute overall similarity score using LLM
|
479
498
|
overall_score = compute_similarity_score_llm(
|
480
499
|
chat_client,
|
481
500
|
resume_sections["full"],
|
482
|
-
jd_sections["full"]
|
501
|
+
jd_sections["full"],
|
502
|
+
screening_data
|
483
503
|
)
|
484
504
|
|
485
505
|
# Store the score in state
|
@@ -596,6 +616,13 @@ def save_snapshot(state: Dict[str, Any]) -> None:
|
|
596
616
|
if "job_description" in state:
|
597
617
|
save_state["job_description"] = state["job_description"]
|
598
618
|
|
619
|
+
# Add screening questions and answers if available
|
620
|
+
if "screening_questions" in state:
|
621
|
+
save_state["screening_questions"] = state["screening_questions"]
|
622
|
+
|
623
|
+
if "screening_answers" in state:
|
624
|
+
save_state["screening_answers"] = state["screening_answers"]
|
625
|
+
|
599
626
|
# Add relevance score if available
|
600
627
|
if "relevance_score" in state:
|
601
628
|
save_state["relevance_score"] = state["relevance_score"]
|
@@ -628,4 +655,155 @@ def save_snapshot(state: Dict[str, Any]) -> None:
|
|
628
655
|
|
629
656
|
except Exception as e:
|
630
657
|
logger.error(f"Error saving state snapshot: {str(e)}")
|
631
|
-
logger.error(traceback.format_exc())
|
658
|
+
logger.error(traceback.format_exc())
|
659
|
+
|
660
|
+
def analyze_resume_with_llm(resume_text: str, jd_text: str, llm, screening_data: Dict[str, Any] = None) -> Dict[str, Any]:
|
661
|
+
"""
|
662
|
+
Analyze the resume using Azure OpenAI, comparing to job description
|
663
|
+
|
664
|
+
Args:
|
665
|
+
resume_text: The text of the resume
|
666
|
+
jd_text: The text of the job description
|
667
|
+
llm: The LLM client to use
|
668
|
+
screening_data: Dictionary containing screening questions and answers
|
669
|
+
|
670
|
+
Returns:
|
671
|
+
Analysis results
|
672
|
+
"""
|
673
|
+
|
674
|
+
# Create the system prompt
|
675
|
+
system_prompt = f"""
|
676
|
+
You are an expert resume analyzer and HR professional.
|
677
|
+
Your task is to analyze a resume against a job description and provide
|
678
|
+
a relevance score (0.0-1.0) and detailed feedback.
|
679
|
+
|
680
|
+
Resume text:
|
681
|
+
{resume_text[:3000] if resume_text else "No resume text provided"}
|
682
|
+
|
683
|
+
Job Description:
|
684
|
+
{jd_text[:1500] if jd_text else "No job description provided"}
|
685
|
+
"""
|
686
|
+
|
687
|
+
# Add screening questions and answers if available
|
688
|
+
if screening_data and 'questions' in screening_data and 'answers' in screening_data:
|
689
|
+
screening_context = "\n\nCandidate's screening question responses:\n"
|
690
|
+
for i, (q, a) in enumerate(zip(screening_data['questions'], screening_data['answers'])):
|
691
|
+
screening_context += f"Question {i+1}: {q}\nAnswer {i+1}: {a}\n\n"
|
692
|
+
system_prompt += screening_context
|
693
|
+
|
694
|
+
# Complete the prompt with instructions
|
695
|
+
system_prompt += """
|
696
|
+
Analyze the resume against the job description and provide your assessment in the following JSON format:
|
697
|
+
{
|
698
|
+
"relevance_score": 0.85, // A float from 0.0 to 1.0 indicating overall fit
|
699
|
+
"skill_match": {
|
700
|
+
"matched_skills": ["skill1", "skill2"], // Skills found in both resume and JD
|
701
|
+
"missing_skills": ["skill3", "skill4"] // Important JD skills not found in resume
|
702
|
+
},
|
703
|
+
"experience_match": {
|
704
|
+
"score": 0.7, // 0.0 to 1.0
|
705
|
+
"feedback": "Candidate has relevant experience in..."
|
706
|
+
},
|
707
|
+
"education_match": {
|
708
|
+
"score": 0.8,
|
709
|
+
"feedback": "Candidate has appropriate education..."
|
710
|
+
},
|
711
|
+
"overall_feedback": "The candidate is a strong fit because..."
|
712
|
+
}
|
713
|
+
|
714
|
+
Please ensure the JSON is valid and properly formatted.
|
715
|
+
"""
|
716
|
+
|
717
|
+
try:
|
718
|
+
# Truncate text if it's too long to avoid token limits
|
719
|
+
truncated_resume = resume_text[:3000] if resume_text and len(resume_text) > 3000 else resume_text
|
720
|
+
truncated_jd = jd_text[:1500] if jd_text and len(jd_text) > 1500 else jd_text
|
721
|
+
|
722
|
+
# Create the user message
|
723
|
+
user_message = f"""
|
724
|
+
Resume: {truncated_resume}
|
725
|
+
|
726
|
+
Job Description: {truncated_jd}
|
727
|
+
|
728
|
+
Provide a detailed analysis of this resume against the job description in the JSON format specified.
|
729
|
+
"""
|
730
|
+
|
731
|
+
# Get the response
|
732
|
+
response = llm.invoke([
|
733
|
+
{"role": "system", "content": system_prompt},
|
734
|
+
{"role": "user", "content": user_message}
|
735
|
+
])
|
736
|
+
|
737
|
+
# Extract and parse the JSON response
|
738
|
+
import json
|
739
|
+
import re
|
740
|
+
|
741
|
+
content = response.content
|
742
|
+
|
743
|
+
# Try to find a JSON object in the response
|
744
|
+
json_match = re.search(r'\{[\s\S]*\}', content)
|
745
|
+
if json_match:
|
746
|
+
try:
|
747
|
+
return json.loads(json_match.group(0))
|
748
|
+
except json.JSONDecodeError:
|
749
|
+
return parse_analysis_response(content)
|
750
|
+
else:
|
751
|
+
return parse_analysis_response(content)
|
752
|
+
except Exception as e:
|
753
|
+
logger.error(f"Error analyzing resume with LLM: {str(e)}")
|
754
|
+
return {
|
755
|
+
"relevance_score": 0.5,
|
756
|
+
"skill_match": {"matched_skills": [], "missing_skills": []},
|
757
|
+
"experience_match": {"score": 0.5, "feedback": "Error analyzing experience"},
|
758
|
+
"education_match": {"score": 0.5, "feedback": "Error analyzing education"},
|
759
|
+
"overall_feedback": f"Error analyzing resume: {str(e)}"
|
760
|
+
}
|
761
|
+
|
762
|
+
def parse_analysis_response(content: str) -> Dict[str, Any]:
|
763
|
+
"""
|
764
|
+
Parse the LLM's response to extract analysis information when JSON parsing fails
|
765
|
+
|
766
|
+
Args:
|
767
|
+
content: The text response from the LLM
|
768
|
+
|
769
|
+
Returns:
|
770
|
+
Dictionary with analysis results extracted from the text
|
771
|
+
"""
|
772
|
+
# Default values
|
773
|
+
result = {
|
774
|
+
"relevance_score": 0.5,
|
775
|
+
"skill_match": {"matched_skills": [], "missing_skills": []},
|
776
|
+
"experience_match": {"score": 0.5, "feedback": "Unable to extract from response"},
|
777
|
+
"education_match": {"score": 0.5, "feedback": "Unable to extract from response"},
|
778
|
+
"overall_feedback": "Analysis completed with limited structure"
|
779
|
+
}
|
780
|
+
|
781
|
+
# Extract relevance score using regex
|
782
|
+
import re
|
783
|
+
score_match = re.search(r'relevance_score["\s:]+([0-9\.]+)', content)
|
784
|
+
if score_match:
|
785
|
+
try:
|
786
|
+
result["relevance_score"] = float(score_match.group(1))
|
787
|
+
except ValueError:
|
788
|
+
pass
|
789
|
+
|
790
|
+
# Extract matched skills
|
791
|
+
matched_skills_match = re.search(r'matched_skills["\s:]+\[(.*?)\]', content, re.DOTALL)
|
792
|
+
if matched_skills_match:
|
793
|
+
skills_text = matched_skills_match.group(1)
|
794
|
+
skills = [s.strip().strip('"\'').strip() for s in skills_text.split(',')]
|
795
|
+
result["skill_match"]["matched_skills"] = [s for s in skills if s]
|
796
|
+
|
797
|
+
# Extract missing skills
|
798
|
+
missing_skills_match = re.search(r'missing_skills["\s:]+\[(.*?)\]', content, re.DOTALL)
|
799
|
+
if missing_skills_match:
|
800
|
+
skills_text = missing_skills_match.group(1)
|
801
|
+
skills = [s.strip().strip('"\'').strip() for s in skills_text.split(',')]
|
802
|
+
result["skill_match"]["missing_skills"] = [s for s in skills if s]
|
803
|
+
|
804
|
+
# Extract overall feedback
|
805
|
+
feedback_match = re.search(r'overall_feedback["\s:]+["\'](.*?)["\']', content, re.DOTALL)
|
806
|
+
if feedback_match:
|
807
|
+
result["overall_feedback"] = feedback_match.group(1).strip()
|
808
|
+
|
809
|
+
return result
|
@@ -74,19 +74,33 @@ def extract_cover_letter(resume_text: str) -> Optional[str]:
|
|
74
74
|
|
75
75
|
return cover_letter_text
|
76
76
|
|
77
|
-
def analyze_text_sentiment_openai_chat(client, text: str) -> Dict[str, Any]:
|
77
|
+
def analyze_text_sentiment_openai_chat(client, text: str, screening_data: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
78
78
|
"""
|
79
79
|
Analyze text sentiment using Azure OpenAI Chat
|
80
80
|
Args:
|
81
81
|
client: Azure OpenAI client
|
82
82
|
text: Text to analyze
|
83
|
+
screening_data: Dictionary containing screening questions and answers for additional context
|
83
84
|
Returns:
|
84
85
|
Sentiment analysis result
|
85
86
|
"""
|
86
87
|
try:
|
87
|
-
# Create the prompt
|
88
|
-
|
89
|
-
Analyze the sentiment of the following text.
|
88
|
+
# Create the base prompt
|
89
|
+
base_prompt = f"""
|
90
|
+
Analyze the sentiment of the following text.
|
91
|
+
"""
|
92
|
+
|
93
|
+
# Add screening questions and answers context if available
|
94
|
+
screening_context = ""
|
95
|
+
if screening_data and 'questions' in screening_data and 'answers' in screening_data:
|
96
|
+
screening_context = "\n\nCandidate's screening question responses:\n"
|
97
|
+
for i, (q, a) in enumerate(zip(screening_data['questions'], screening_data['answers'])):
|
98
|
+
screening_context += f"Question {i+1}: {q}\nAnswer {i+1}: {a}\n\n"
|
99
|
+
base_prompt += f"\nConsider the following screening question responses as additional context for sentiment analysis:{screening_context}"
|
100
|
+
|
101
|
+
# Complete the prompt
|
102
|
+
prompt = base_prompt + f"""
|
103
|
+
Respond with a JSON object containing:
|
90
104
|
- sentiment: either "positive", "neutral", or "negative"
|
91
105
|
- positive_score: a float between 0 and 1
|
92
106
|
- neutral_score: a float between 0 and 1
|
@@ -256,6 +270,15 @@ def analyze_sentiment(state: Dict[str, Any]) -> Dict[str, Any]:
|
|
256
270
|
# If no cover letter found, use the first 1000 characters of resume as a sample
|
257
271
|
sample_text = cover_letter if cover_letter else resume_text[:1000]
|
258
272
|
|
273
|
+
# Prepare screening data if available
|
274
|
+
screening_data = None
|
275
|
+
if state.get("screening_questions") and state.get("screening_answers"):
|
276
|
+
screening_data = {
|
277
|
+
"questions": state.get("screening_questions"),
|
278
|
+
"answers": state.get("screening_answers")
|
279
|
+
}
|
280
|
+
logger.info(f"Screening data prepared: {screening_data}")
|
281
|
+
|
259
282
|
# Initialize clients and results
|
260
283
|
sentiment_result = None
|
261
284
|
chat_client = None
|
@@ -268,7 +291,7 @@ def analyze_sentiment(state: Dict[str, Any]) -> Dict[str, Any]:
|
|
268
291
|
# Only use chat-based or basic sentiment analysis
|
269
292
|
if chat_client:
|
270
293
|
# Method 1: Use Azure OpenAI Chat for sentiment analysis
|
271
|
-
sentiment_result = analyze_text_sentiment_openai_chat(chat_client, sample_text)
|
294
|
+
sentiment_result = analyze_text_sentiment_openai_chat(chat_client, sample_text, screening_data)
|
272
295
|
logger.info("Used Azure OpenAI Chat for sentiment analysis")
|
273
296
|
else:
|
274
297
|
# Fallback: Basic sentiment analysis
|
@@ -279,6 +302,7 @@ def analyze_sentiment(state: Dict[str, Any]) -> Dict[str, Any]:
|
|
279
302
|
sentiment_result["cover_letter_available"] = True if cover_letter else False
|
280
303
|
sentiment_result["text_used"] = "cover_letter" if cover_letter else "resume_sample"
|
281
304
|
sentiment_result["available"] = True
|
305
|
+
sentiment_result["screening_data_used"] = screening_data is not None
|
282
306
|
|
283
307
|
# Update state
|
284
308
|
state["sentiment_score"] = sentiment_result
|
michael_agent/main.py
CHANGED
@@ -16,39 +16,41 @@ from dotenv import load_dotenv
|
|
16
16
|
load_dotenv()
|
17
17
|
|
18
18
|
# Then import settings which may depend on environment variables
|
19
|
-
from
|
19
|
+
from config import settings
|
20
20
|
|
21
21
|
# Import our logging utilities early to set up logging
|
22
|
-
from
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
22
|
+
from utils.logging_utils import workflow_logger as logger
|
23
|
+
|
24
|
+
# Ensure all required directories exist
|
25
|
+
required_directories = [
|
26
|
+
settings.RESUME_WATCH_DIR,
|
27
|
+
settings.OUTPUT_DIR,
|
28
|
+
settings.LOG_DIR,
|
29
|
+
os.path.join(settings.LOG_DIR, "snapshots"),
|
30
|
+
settings.LANGGRAPH_CHECKPOINT_DIR,
|
31
|
+
settings.JOB_DESCRIPTIONS_DIR
|
32
|
+
]
|
33
|
+
|
34
|
+
for directory in required_directories:
|
35
|
+
os.makedirs(directory, exist_ok=True)
|
36
|
+
logger.info(f"Ensured directory exists: {directory}")
|
37
|
+
|
38
|
+
# Import our node tracer to apply logging to all workflow nodes
|
39
|
+
# This must be imported after directories are created but before the workflow starts
|
40
|
+
import utils.node_tracer
|
41
|
+
from utils.id_mapper import get_timestamp_id, get_all_ids_for_timestamp
|
41
42
|
|
42
43
|
def start_langgraph():
|
43
44
|
"""Start the LangGraph workflow in a separate thread"""
|
45
|
+
global langgraph_app # Add a global reference to store the app
|
44
46
|
try:
|
45
|
-
from
|
47
|
+
from langgraph_workflow.graph_builder import start_workflow
|
46
48
|
logger.info("Starting LangGraph workflow engine...")
|
47
|
-
langgraph_app = start_workflow()
|
49
|
+
langgraph_app = start_workflow() # Store the returned app
|
48
50
|
|
49
51
|
# Keep the thread alive with a simple loop
|
50
52
|
while True:
|
51
|
-
time.sleep(10)
|
53
|
+
time.sleep(10) # Sleep to avoid high CPU usage
|
52
54
|
except Exception as e:
|
53
55
|
logger.error(f"Error starting LangGraph workflow: {str(e)}")
|
54
56
|
logger.error(traceback.format_exc())
|
@@ -56,14 +58,22 @@ def start_langgraph():
|
|
56
58
|
def start_flask_dashboard():
|
57
59
|
"""Start the Flask dashboard in a separate thread"""
|
58
60
|
try:
|
59
|
-
from
|
60
|
-
|
61
|
-
|
62
|
-
|
61
|
+
from dashboard.app import app, socketio
|
62
|
+
# Use threading instead of eventlet for the background tasks
|
63
|
+
import threading
|
64
|
+
|
65
|
+
# Configure Socket.IO to not use eventlet
|
66
|
+
socketio.init_app(app, async_mode='threading')
|
67
|
+
|
68
|
+
port = int(os.getenv("FLASK_PORT", 8080))
|
69
|
+
logger.info(f"Starting Flask dashboard on http://localhost:{port}")
|
70
|
+
# Start the Flask app with threading
|
71
|
+
socketio.run(app, host='0.0.0.0', port=port, debug=os.getenv("FLASK_ENV") == "development", use_reloader=False, allow_unsafe_werkzeug=True)
|
63
72
|
except Exception as e:
|
64
73
|
logger.error(f"Error starting Flask dashboard: {str(e)}")
|
65
74
|
logger.error(traceback.format_exc())
|
66
75
|
|
76
|
+
|
67
77
|
def main():
|
68
78
|
"""Main entry point for the application"""
|
69
79
|
logger.info("=" * 50)
|
@@ -71,9 +81,7 @@ def main():
|
|
71
81
|
logger.info(f"Date/Time: {datetime.now().isoformat()}")
|
72
82
|
logger.info("=" * 50)
|
73
83
|
|
74
|
-
|
75
|
-
|
76
|
-
port = int(os.getenv("FLASK_PORT", 5000))
|
84
|
+
port = int(os.getenv("FLASK_PORT", 8080))
|
77
85
|
logger.info(f"Dashboard will be available at: http://localhost:{port}")
|
78
86
|
|
79
87
|
try:
|
@@ -84,10 +92,15 @@ def main():
|
|
84
92
|
|
85
93
|
# Give LangGraph more time to initialize
|
86
94
|
logger.info("Waiting for LangGraph to initialize...")
|
87
|
-
time.sleep(5)
|
95
|
+
time.sleep(5) # Increased from 2 to 5 seconds
|
88
96
|
|
89
97
|
logger.info("LangGraph initialization complete")
|
90
98
|
|
99
|
+
# Verify job description directory exists and is accessible
|
100
|
+
job_description_dir = "job_descriptions"
|
101
|
+
os.makedirs(job_description_dir, exist_ok=True)
|
102
|
+
logger.info(f"Job description directory verified: {job_description_dir}")
|
103
|
+
|
91
104
|
# Start Flask dashboard in the main thread
|
92
105
|
logger.info("Starting Flask dashboard in main thread...")
|
93
106
|
start_flask_dashboard()
|
@@ -98,6 +111,49 @@ def main():
|
|
98
111
|
logger.error(traceback.format_exc())
|
99
112
|
finally:
|
100
113
|
logger.info("SmartRecruitAgent shutting down...")
|
101
|
-
|
114
|
+
|
102
115
|
if __name__ == "__main__":
|
103
116
|
main()
|
117
|
+
|
118
|
+
def on_created(self, event):
|
119
|
+
"""Process newly created resume files"""
|
120
|
+
# Skip temporary files and directories
|
121
|
+
if event.is_directory or event.src_path.endswith('.tmp'):
|
122
|
+
return
|
123
|
+
|
124
|
+
# Check if the file matches resume extensions
|
125
|
+
file_ext = os.path.splitext(event.src_path)[1].lower()
|
126
|
+
if file_ext not in self.valid_extensions:
|
127
|
+
return
|
128
|
+
|
129
|
+
try:
|
130
|
+
# Create timestamped filename if needed
|
131
|
+
filename = os.path.basename(event.src_path)
|
132
|
+
if not self.has_timestamp_prefix(filename):
|
133
|
+
# Add timestamp as job ID prefix
|
134
|
+
timestamp = datetime.now().strftime('%Y%m%d%H%M%S')
|
135
|
+
new_filename = f"{timestamp}_{filename}"
|
136
|
+
new_path = os.path.join(os.path.dirname(event.src_path), new_filename)
|
137
|
+
os.rename(event.src_path, new_path)
|
138
|
+
file_path = new_path
|
139
|
+
else:
|
140
|
+
file_path = event.src_path
|
141
|
+
|
142
|
+
logger.info(f"New resume detected: {file_path}")
|
143
|
+
|
144
|
+
# Process the resume through the workflow
|
145
|
+
self.processor_func(file_path)
|
146
|
+
except Exception as e:
|
147
|
+
logger.error(f"Error processing new resume {event.src_path}: {str(e)}")
|
148
|
+
logger.error(traceback.format_exc())
|
149
|
+
|
150
|
+
def has_timestamp_prefix(self, filename):
|
151
|
+
"""Check if filename has a timestamp prefix in format YYYYMMDDHHMMSS_"""
|
152
|
+
# Extract the first part before underscore
|
153
|
+
parts = filename.split('_', 1)
|
154
|
+
if len(parts) < 2:
|
155
|
+
return False
|
156
|
+
|
157
|
+
prefix = parts[0]
|
158
|
+
# Check if prefix is a 14-digit number (YYYYMMDDHHMMSS)
|
159
|
+
return len(prefix) == 14 and prefix.isdigit()
|
@@ -1,29 +1,29 @@
|
|
1
1
|
michael_agent/__init__.py,sha256=prEeI3mdpO8R5QTpniRR_Tl21uqF7pGJHwBidQ9JIKQ,179
|
2
|
-
michael_agent/main.py,sha256=
|
2
|
+
michael_agent/main.py,sha256=a_sUmlS2fSZ0dfU0f0ukjhr36cmWESfr_7DG6lot0-s,5790
|
3
3
|
michael_agent/monitor.py,sha256=RThpdPW7lf5zI3ilMShVeDf4Vao5Yq0E4Rao9uuS9XY,2473
|
4
4
|
michael_agent/config/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
5
5
|
michael_agent/config/settings.py,sha256=_4uvWQnMscK01Sd0zT5wesVW5uN0njtKYRMsjMQXEOY,3180
|
6
6
|
michael_agent/dashboard/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
7
|
-
michael_agent/dashboard/app.py,sha256=
|
7
|
+
michael_agent/dashboard/app.py,sha256=kArwbsiYR1FzAcxOA_Xzf3uDlFuXj4uDFGvMN9QWuuw,64161
|
8
8
|
michael_agent/dashboard/static/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
9
9
|
michael_agent/dashboard/static/styles.css,sha256=vSG1saAHIYxw_7sH5z9NipzvkRx3x4kMEjhgx0aUKow,6030
|
10
10
|
michael_agent/dashboard/templates/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
11
|
-
michael_agent/dashboard/templates/career_portal.html,sha256=
|
11
|
+
michael_agent/dashboard/templates/career_portal.html,sha256=7hUcKQvAh_U8S0hdnJdzKDzZ-a3vuz3T-hJdp6CqvIU,28419
|
12
12
|
michael_agent/dashboard/templates/dashboard.html,sha256=VepZKTvsaPQaCVR-Nxw00gx3ueiBZMxlIIIxr__6nKU,39877
|
13
|
-
michael_agent/dashboard/templates/jd_creation.html,sha256=
|
14
|
-
michael_agent/dashboard/templates/resume_scoring.html,sha256=
|
13
|
+
michael_agent/dashboard/templates/jd_creation.html,sha256=mACZFlDmPZVvDCibV4xhnlLXMSkNjdFnrAyRLPFKEmk,18725
|
14
|
+
michael_agent/dashboard/templates/resume_scoring.html,sha256=m7piNDOs_tKuH9BSGl3DZpsaN8wGVAfnLaUTsd4ByBE,57187
|
15
15
|
michael_agent/dashboard/templates/upload_resume.html,sha256=mG-92r1L3A4N-tEdaSRd35ez92fx6Wg7y9gnvhObc_w,19995
|
16
16
|
michael_agent/langgraph_workflow/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
17
|
-
michael_agent/langgraph_workflow/graph_builder.py,sha256=
|
17
|
+
michael_agent/langgraph_workflow/graph_builder.py,sha256=G7bhOxi6QyCCGkF-8D2Q2TkAidT0hoMKGN52lUM8whg,15658
|
18
18
|
michael_agent/langgraph_workflow/nodes/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
19
19
|
michael_agent/langgraph_workflow/nodes/assessment_handler.py,sha256=qgMB8fJdyA2CP8VWY2m-7-418LcY302S-SR_JHamSTE,6401
|
20
|
-
michael_agent/langgraph_workflow/nodes/jd_generator.py,sha256=
|
20
|
+
michael_agent/langgraph_workflow/nodes/jd_generator.py,sha256=h1seH2JHgvDEJA03oTzzBs7fC63CVuavLuDwFYv3k9M,9364
|
21
21
|
michael_agent/langgraph_workflow/nodes/jd_poster.py,sha256=6F1jQRG_IoiopIOpIDjSpuCE3I6_A7-ZEMkV8FtKXQs,4550
|
22
|
-
michael_agent/langgraph_workflow/nodes/question_generator.py,sha256=
|
22
|
+
michael_agent/langgraph_workflow/nodes/question_generator.py,sha256=bXCrq6lxeabimp7uAL9ndiLVS3t8pzwVXcaz6lepqqI,12813
|
23
23
|
michael_agent/langgraph_workflow/nodes/recruiter_notifier.py,sha256=xLVhRP1I-QIcO_b0lYLuMnMTpGHAFakG-luPJrhkN6Y,8522
|
24
|
-
michael_agent/langgraph_workflow/nodes/resume_analyzer.py,sha256=
|
24
|
+
michael_agent/langgraph_workflow/nodes/resume_analyzer.py,sha256=z5X5Mo2dAT1F33dFY_JN_PSKpQYrb0dgV4jOeUMCYD0,31403
|
25
25
|
michael_agent/langgraph_workflow/nodes/resume_ingestor.py,sha256=h14J4AcFk22BWoFHCPRkK3HpzY8RvwGW6_jjqBxLXNU,9279
|
26
|
-
michael_agent/langgraph_workflow/nodes/sentiment_analysis.py,sha256=
|
26
|
+
michael_agent/langgraph_workflow/nodes/sentiment_analysis.py,sha256=St2opmz3naiCw9Z0b4CVMWXOdYCDpdV55LMaigZOJj4,13017
|
27
27
|
michael_agent/utils/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
28
28
|
michael_agent/utils/email_utils.py,sha256=PsL3QTQuV_iVou_2Y3o_Dohz7tN9YNp9FPxsTKkDRv0,4989
|
29
29
|
michael_agent/utils/id_mapper.py,sha256=GzYRuAhGWf2BUAb9hVMS3KR8bmYExnmXRWkQ_j-kWaw,397
|
@@ -32,7 +32,7 @@ michael_agent/utils/lms_api.py,sha256=tmntU6tjyAdMLak_vfoxBkWNIPUKvejeEwb2t6yQBU
|
|
32
32
|
michael_agent/utils/logging_utils.py,sha256=Ld7fs2uuCOM0bx-totxHzKzKHl5lfAe3TXeH1QYJBjw,7179
|
33
33
|
michael_agent/utils/monitor_utils.py,sha256=1Ig6C79bQ_OOLKhgFNmm0ybntQavqzyJ3zsxD0iZxxw,11069
|
34
34
|
michael_agent/utils/node_tracer.py,sha256=N1MWly4qfzh87Fo1xRS5hpefoAvfSyZIPvMOegPrtBY,3411
|
35
|
-
michael_agent-1.0.
|
36
|
-
michael_agent-1.0.
|
37
|
-
michael_agent-1.0.
|
38
|
-
michael_agent-1.0.
|
35
|
+
michael_agent-1.0.5.dist-info/METADATA,sha256=hUavXJcuPMKXoDvQ6PBuXgNgcW2p21Lkjf_Xts1SrzY,1340
|
36
|
+
michael_agent-1.0.5.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
37
|
+
michael_agent-1.0.5.dist-info/top_level.txt,sha256=-r35JOIHnK3RsMhJ77tDKfWtmfGDr_iT2642k-suUDo,14
|
38
|
+
michael_agent-1.0.5.dist-info/RECORD,,
|
File without changes
|
File without changes
|