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.
Files changed (30) hide show
  1. michael_agent/config/__init__.py +0 -0
  2. michael_agent/config/settings.py +66 -0
  3. michael_agent/dashboard/__init__.py +0 -0
  4. michael_agent/dashboard/app.py +1450 -0
  5. michael_agent/dashboard/static/__init__.py +0 -0
  6. michael_agent/dashboard/templates/__init__.py +0 -0
  7. michael_agent/langgraph_workflow/__init__.py +0 -0
  8. michael_agent/langgraph_workflow/graph_builder.py +358 -0
  9. michael_agent/langgraph_workflow/nodes/__init__.py +0 -0
  10. michael_agent/langgraph_workflow/nodes/assessment_handler.py +177 -0
  11. michael_agent/langgraph_workflow/nodes/jd_generator.py +139 -0
  12. michael_agent/langgraph_workflow/nodes/jd_poster.py +156 -0
  13. michael_agent/langgraph_workflow/nodes/question_generator.py +295 -0
  14. michael_agent/langgraph_workflow/nodes/recruiter_notifier.py +224 -0
  15. michael_agent/langgraph_workflow/nodes/resume_analyzer.py +631 -0
  16. michael_agent/langgraph_workflow/nodes/resume_ingestor.py +225 -0
  17. michael_agent/langgraph_workflow/nodes/sentiment_analysis.py +309 -0
  18. michael_agent/utils/__init__.py +0 -0
  19. michael_agent/utils/email_utils.py +140 -0
  20. michael_agent/utils/id_mapper.py +14 -0
  21. michael_agent/utils/jd_utils.py +34 -0
  22. michael_agent/utils/lms_api.py +226 -0
  23. michael_agent/utils/logging_utils.py +192 -0
  24. michael_agent/utils/monitor_utils.py +289 -0
  25. michael_agent/utils/node_tracer.py +88 -0
  26. {michael_agent-1.0.0.dist-info → michael_agent-1.0.2.dist-info}/METADATA +2 -2
  27. michael_agent-1.0.2.dist-info/RECORD +32 -0
  28. michael_agent-1.0.0.dist-info/RECORD +0 -7
  29. {michael_agent-1.0.0.dist-info → michael_agent-1.0.2.dist-info}/WHEEL +0 -0
  30. {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())