michael-agent 1.0.0__py3-none-any.whl → 1.0.1__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.
File without changes
@@ -0,0 +1,358 @@
1
+ """
2
+ LangGraph workflow builder and orchestrator
3
+ """
4
+
5
+ import os
6
+ import json
7
+ import logging
8
+ import sys
9
+ import traceback
10
+ from typing import Dict, List, Any, Optional, TypedDict, Literal
11
+ from datetime import datetime
12
+
13
+
14
+ # LangGraph and LangChain imports
15
+ from langgraph.graph import StateGraph, END, MessagesState
16
+ from langgraph.prebuilt.tool_node import ToolNode
17
+ from langgraph.checkpoint.memory import InMemorySaver
18
+
19
+ # Import nodes (workflow steps)
20
+ from .nodes.jd_generator import generate_job_description
21
+ from .nodes.jd_poster import post_job_description
22
+ from .nodes.resume_ingestor import ingest_resume
23
+ from .nodes.resume_analyzer import analyze_resume
24
+ from .nodes.sentiment_analysis import analyze_sentiment
25
+ from .nodes.assessment_handler import handle_assessment
26
+ from .nodes.question_generator import generate_interview_questions
27
+ from .nodes.recruiter_notifier import notify_recruiter
28
+
29
+ # Import config
30
+ from config import settings
31
+
32
+ # Import custom logging utilities
33
+ from utils.logging_utils import workflow_logger as logger
34
+ from utils.logging_utils import save_state_snapshot
35
+
36
+ # Define helper function to get project root
37
+ def get_project_root():
38
+ """Get the absolute path to the project root directory"""
39
+ # Assuming this file is in project_root/smart_recruit_agent/langgraph_workflow/
40
+ current_dir = os.path.dirname(os.path.abspath(__file__))
41
+ return os.path.abspath(os.path.join(current_dir, "../.."))
42
+
43
+ # Define the workflow state
44
+ class WorkflowState(TypedDict):
45
+ """Type definition for the workflow state that gets passed between nodes"""
46
+ job_id: str
47
+ timestamp: str
48
+ status: str
49
+ candidate_name: Optional[str]
50
+ candidate_email: Optional[str]
51
+ resume_path: Optional[str]
52
+ resume_text: Optional[str]
53
+ resume_data: Optional[Dict[str, Any]]
54
+ job_description: Optional[Dict[str, Any]]
55
+ job_description_text: Optional[str]
56
+ relevance_score: Optional[float]
57
+ sentiment_score: Optional[Dict[str, Any]]
58
+ assessment: Optional[Dict[str, Any]]
59
+ interview_questions: Optional[List[Dict[str, Any]]]
60
+ notification_status: Optional[Dict[str, Any]]
61
+ errors: List[Dict[str, Any]]
62
+
63
+ def create_initial_state(resume_path: str = None, job_id: str = None) -> Dict[str, Any]:
64
+ """Create initial state for the workflow"""
65
+
66
+ # If no job_id is provided, use timestamp
67
+ if not job_id:
68
+ # Create a timestamp-based job ID instead of UUID
69
+ job_id = datetime.now().strftime('%Y%m%d%H%M%S')
70
+
71
+ logger.info(f"Creating initial state with job ID: {job_id}")
72
+
73
+ # Create initial state
74
+ initial_state = {
75
+ "job_id": job_id,
76
+ "status": "initialized",
77
+ "timestamp": datetime.now().isoformat(),
78
+ "errors": []
79
+ }
80
+
81
+ # Add resume path if provided
82
+ if resume_path:
83
+ initial_state["resume_path"] = resume_path
84
+
85
+ return initial_state
86
+
87
+ def should_send_assessment(state: WorkflowState) -> Literal["send_assessment", "skip_assessment"]:
88
+ """Conditional routing to determine if assessment should be sent"""
89
+ # Log the decision point
90
+ logger.info(f"[Job {state.get('job_id')}] Evaluating whether to send assessment")
91
+
92
+ # Check if automatic assessment sending is enabled
93
+ if not settings.AUTOMATIC_TEST_SENDING:
94
+ logger.info(f"[Job {state.get('job_id')}] Automatic assessment sending is disabled")
95
+ return "skip_assessment"
96
+
97
+ # Check if candidate email is available
98
+ if not state.get("candidate_email"):
99
+ logger.warning(f"[Job {state.get('job_id')}] No candidate email available for sending assessment")
100
+ return "skip_assessment"
101
+
102
+ # Check if score meets threshold
103
+ score = state.get("relevance_score", 0)
104
+ if score is None or score < settings.MINIMAL_SCORE_THRESHOLD:
105
+ logger.info(f"[Job {state.get('job_id')}] Score {score} below threshold {settings.MINIMAL_SCORE_THRESHOLD}, skipping assessment")
106
+ return "skip_assessment"
107
+
108
+ logger.info(f"[Job {state.get('job_id')}] Assessment will be sent to {state.get('candidate_email')}")
109
+ return "send_assessment"
110
+
111
+ def should_notify_recruiter(state: WorkflowState) -> Literal["notify", "end"]:
112
+ """Conditional routing to determine if recruiter should be notified"""
113
+ # Log the decision point
114
+ logger.info(f"[Job {state.get('job_id')}] Evaluating whether to notify recruiter")
115
+
116
+ # Check if automatic notification is enabled
117
+ if not settings.AUTOMATIC_RECRUITER_NOTIFICATION:
118
+ logger.info(f"[Job {state.get('job_id')}] Automatic recruiter notification is disabled")
119
+ return "end"
120
+
121
+ # Check if we have resume data to send
122
+ if not state.get("resume_data"):
123
+ error_msg = "No resume data available for notification"
124
+ logger.warning(f"[Job {state.get('job_id')}] {error_msg}")
125
+ state["errors"].append({
126
+ "step": "notification_routing",
127
+ "error": error_msg,
128
+ "timestamp": datetime.now().isoformat()
129
+ })
130
+ return "end"
131
+
132
+ logger.info(f"[Job {state.get('job_id')}] Recruiter will be notified about candidate {state.get('candidate_name')}")
133
+ return "notify"
134
+
135
+ def build_workflow() -> StateGraph:
136
+ """Build the LangGraph workflow"""
137
+ # Create a new workflow graph
138
+ logger.info("Building LangGraph workflow")
139
+ workflow = StateGraph(WorkflowState)
140
+
141
+ # Add nodes (workflow steps)
142
+ workflow.add_node("jd_generator", generate_job_description)
143
+ workflow.add_node("jd_poster", post_job_description)
144
+ workflow.add_node("resume_ingestor", ingest_resume)
145
+ workflow.add_node("resume_analyzer", analyze_resume)
146
+ workflow.add_node("sentiment_analysis", analyze_sentiment)
147
+ workflow.add_node("assessment_handler", handle_assessment)
148
+ workflow.add_node("question_generator", generate_interview_questions)
149
+ workflow.add_node("recruiter_notifier", notify_recruiter)
150
+
151
+ # Define the workflow edges (flow)
152
+ logger.debug("Defining workflow edges")
153
+ workflow.add_edge("jd_generator", "jd_poster")
154
+ workflow.add_edge("jd_poster", "resume_ingestor")
155
+ workflow.add_edge("resume_ingestor", "resume_analyzer")
156
+ workflow.add_edge("resume_analyzer", "sentiment_analysis")
157
+
158
+ # After sentiment analysis, decide whether to send assessment
159
+ workflow.add_conditional_edges(
160
+ "sentiment_analysis",
161
+ should_send_assessment,
162
+ {
163
+ "send_assessment": "assessment_handler",
164
+ "skip_assessment": "question_generator"
165
+ }
166
+ )
167
+
168
+ workflow.add_edge("assessment_handler", "question_generator")
169
+
170
+ # After question generation, decide whether to notify recruiter
171
+ workflow.add_conditional_edges(
172
+ "question_generator",
173
+ should_notify_recruiter,
174
+ {
175
+ "notify": "recruiter_notifier",
176
+ "end": END
177
+ }
178
+ )
179
+
180
+ workflow.add_edge("recruiter_notifier", END)
181
+
182
+ # Replace the multiple entry points with a conditional router
183
+
184
+ def workflow_entry_router(state: WorkflowState) -> Dict[str, Any]:
185
+ """Route to the appropriate entry point based on available data"""
186
+ logger.info(f"[Job {state.get('job_id')}] Routing workflow to appropriate entry point")
187
+
188
+ # Create a copy of the state to avoid mutating the input
189
+ new_state = state.copy()
190
+
191
+ if state.get("resume_path") and not state.get("job_description"):
192
+ logger.info(f"[Job {state.get('job_id')}] Starting with resume processing")
193
+ new_state["entry_router"] = "resume_ingestor"
194
+ elif state.get("job_description") and not state.get("resume_path"):
195
+ logger.info(f"[Job {state.get('job_id')}] Starting with job description")
196
+ new_state["entry_router"] = "jd_generator"
197
+ elif state.get("resume_text") and not state.get("resume_data"):
198
+ logger.info(f"[Job {state.get('job_id')}] Starting with resume analysis")
199
+ new_state["entry_router"] = "resume_analyzer"
200
+ else:
201
+ # Default path
202
+ logger.info(f"[Job {state.get('job_id')}] Using default entry point (job description generator)")
203
+ new_state["entry_router"] = "jd_generator"
204
+
205
+ return new_state
206
+
207
+ # Replace the multiple START edges with a single entry and conditional router
208
+ workflow.add_node("entry_router", workflow_entry_router)
209
+ from langgraph.graph import START
210
+ workflow.add_edge(START, "entry_router")
211
+ workflow.add_conditional_edges(
212
+ "entry_router",
213
+ lambda x: x["entry_router"],
214
+ {
215
+ "jd_generator": "jd_generator",
216
+ "resume_ingestor": "resume_ingestor",
217
+ "resume_analyzer": "resume_analyzer"
218
+ }
219
+ )
220
+
221
+ logger.info("Workflow graph built successfully")
222
+ return workflow
223
+
224
+ def start_workflow():
225
+ """Start the LangGraph workflow"""
226
+ try:
227
+ # Create all required directories
228
+ for directory in [
229
+ settings.RESUME_WATCH_DIR,
230
+ settings.OUTPUT_DIR,
231
+ settings.LOG_DIR,
232
+ settings.LANGGRAPH_CHECKPOINT_DIR,
233
+ os.path.join(settings.LOG_DIR, "snapshots")
234
+ ]:
235
+ os.makedirs(directory, exist_ok=True)
236
+ logger.info(f"Ensured directory exists: {directory}")
237
+
238
+ # Build the workflow graph
239
+ workflow = build_workflow()
240
+
241
+ # Setup in-memory checkpointer
242
+ checkpointer = InMemorySaver()
243
+
244
+ # Compile the workflow
245
+ logger.info("Compiling workflow graph")
246
+ app = workflow.compile(checkpointer=checkpointer)
247
+
248
+ # Setup the file watcher to automatically process new resumes
249
+ logger.info("Setting up resume watcher...")
250
+ state = create_initial_state() # Use proper initial state type
251
+
252
+ # Set up the resume watcher without running the job description generator
253
+ # We're just initializing the watcher functionality
254
+ try:
255
+ # Call ingest_resume with a special flag to indicate it's just for setup
256
+ state["setup_only"] = True
257
+ ingest_resume(state) # This sets up the watcher
258
+ logger.info("Resume watcher set up successfully")
259
+ except Exception as e:
260
+ logger.error(f"Error setting up resume watcher: {str(e)}")
261
+ # Continue anyway - we still want to return the app
262
+
263
+ logger.info("Workflow engine successfully started")
264
+ logger.info("Listening for new resumes...")
265
+
266
+ return app
267
+ except Exception as e:
268
+ logger.error(f"Failed to start workflow engine: {str(e)}")
269
+ logger.error(traceback.format_exc())
270
+ raise
271
+
272
+ def process_new_resume(resume_path: str, job_description: Dict[str, Any] = None):
273
+ """Process a new resume through the workflow"""
274
+ try:
275
+ # Extract job ID from filename (e.g., 20250626225207_Michael_Jone.pdf)
276
+ filename = os.path.basename(resume_path)
277
+ job_id = filename.split('_')[0]
278
+ logger.info(f"Extracted job ID from resume filename: {job_id}")
279
+
280
+ # Create initial state with extracted job ID
281
+ initial_state = create_initial_state(resume_path=resume_path, job_id=job_id)
282
+
283
+ # Start workflow with initial state
284
+ workflow = build_workflow()
285
+ logger.info(f"[Job {job_id}] Starting workflow processing with job ID {job_id}")
286
+ save_state_snapshot(initial_state, "initial_state")
287
+
288
+ # Configure the workflow with in-memory checkpoints
289
+ checkpointer = InMemorySaver()
290
+
291
+ # Compile the workflow with checkpoints
292
+ logger.info(f"[Job {job_id}] Compiling workflow graph")
293
+ app = workflow.compile(checkpointer=checkpointer)
294
+
295
+ # Execute the workflow
296
+ try:
297
+ logger.info(f"[Job {job_id}] Beginning workflow execution")
298
+ event_count = 0
299
+
300
+ # Add thread_id for proper checkpointing
301
+ config = {"configurable": {"thread_id": job_id}}
302
+
303
+ # Correctly process the stream events
304
+ for event in app.stream(initial_state, config=config):
305
+ event_count += 1
306
+
307
+ # Extract the current step from the event
308
+ # The event format depends on LangGraph version, but typically
309
+ # contains the node name as a key
310
+ if isinstance(event, dict) and len(event) > 0:
311
+ current_step = list(event.keys())[0]
312
+ else:
313
+ current_step = "unknown"
314
+
315
+ # Get the current state if available in the event
316
+ current_state = None
317
+ if current_step in event and isinstance(event[current_step], dict):
318
+ current_state = event[current_step]
319
+
320
+ # Save state snapshot after each significant step
321
+ if current_state:
322
+ save_state_snapshot(current_state, f"after_{current_step}")
323
+
324
+ # Update status in state if possible
325
+ if "status" in current_state:
326
+ current_state["status"] = f"completed_{current_step}"
327
+
328
+ # Log progress with job id for tracking
329
+ logger.info(f"[Job {job_id}] Step {event_count} completed: {current_step}")
330
+
331
+ except Exception as e:
332
+ logger.error(f"[Job {job_id}] Error in workflow execution: {str(e)}")
333
+ logger.error(traceback.format_exc())
334
+
335
+ # Create error state for logging
336
+ error_state = {**initial_state}
337
+ if "errors" not in error_state:
338
+ error_state["errors"] = []
339
+
340
+ error_state["errors"].append({
341
+ "step": "workflow_execution",
342
+ "timestamp": datetime.now().isoformat(),
343
+ "error": str(e),
344
+ "traceback": traceback.format_exc()
345
+ })
346
+
347
+ error_state["status"] = "workflow_execution_error"
348
+ save_state_snapshot(error_state, "workflow_error")
349
+
350
+ logger.info(f"[Job {job_id}] Workflow completed for resume {resume_path}")
351
+ except Exception as e:
352
+ logger.error(f"Failed to process new resume {resume_path}: {str(e)}")
353
+ logger.error(traceback.format_exc())
354
+ raise
355
+
356
+ if __name__ == "__main__":
357
+ # For testing the workflow directly
358
+ start_workflow()
File without changes
@@ -0,0 +1,140 @@
1
+ """
2
+ Email utilities for sending notifications using SMTP or Azure Communication Services
3
+ """
4
+
5
+ import os
6
+ import smtplib
7
+ import logging
8
+ from email.mime.text import MIMEText
9
+ from email.mime.multipart import MIMEMultipart
10
+ from email.mime.application import MIMEApplication
11
+
12
+ # Try to import Azure Communication Services
13
+ try:
14
+ from azure.communication.email import EmailClient
15
+ AZURE_EMAIL_AVAILABLE = True
16
+ except ImportError:
17
+ AZURE_EMAIL_AVAILABLE = False
18
+
19
+ from config import settings
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+ def send_email_smtp(recipient_email, subject, body, html_content=None, attachments=None):
24
+ """
25
+ Send an email using SMTP protocol
26
+
27
+ Args:
28
+ recipient_email: The recipient's email address
29
+ subject: Email subject
30
+ body: Plain text email body
31
+ html_content: Optional HTML content
32
+ attachments: List of file paths to attach
33
+
34
+ Returns:
35
+ True if sent successfully, False otherwise
36
+ """
37
+ try:
38
+ # Create a multipart message
39
+ msg = MIMEMultipart('alternative')
40
+ msg['Subject'] = subject
41
+ msg['From'] = settings.EMAIL_SENDER
42
+ msg['To'] = recipient_email
43
+
44
+ # Add plain text content
45
+ msg.attach(MIMEText(body, 'plain'))
46
+
47
+ # Add HTML content if provided
48
+ if html_content:
49
+ msg.attach(MIMEText(html_content, 'html'))
50
+
51
+ # Add attachments if provided
52
+ if attachments:
53
+ for file_path in attachments:
54
+ with open(file_path, 'rb') as file:
55
+ part = MIMEApplication(file.read(), Name=os.path.basename(file_path))
56
+ part['Content-Disposition'] = f'attachment; filename="{os.path.basename(file_path)}"'
57
+ msg.attach(part)
58
+
59
+ # Connect to SMTP server and send email
60
+ with smtplib.SMTP(settings.SMTP_SERVER, settings.SMTP_PORT) as server:
61
+ server.ehlo()
62
+ server.starttls()
63
+ server.login(settings.SMTP_USERNAME, settings.SMTP_PASSWORD)
64
+ server.send_message(msg)
65
+
66
+ logger.info(f"Email sent to {recipient_email} with subject '{subject}'")
67
+ return True
68
+ except Exception as e:
69
+ logger.error(f"Error sending email via SMTP: {str(e)}")
70
+ return False
71
+
72
+ def send_email_azure(recipient_email, subject, body, html_content=None):
73
+ """
74
+ Send an email using Azure Communication Services
75
+
76
+ Args:
77
+ recipient_email: The recipient's email address
78
+ subject: Email subject
79
+ body: Plain text email body
80
+ html_content: Optional HTML content
81
+
82
+ Returns:
83
+ True if sent successfully, False otherwise
84
+ """
85
+ if not AZURE_EMAIL_AVAILABLE:
86
+ logger.error("Azure Communication Services not available. Install with 'pip install azure-communication-email'")
87
+ return False
88
+
89
+ try:
90
+ # Create the email client
91
+ email_client = EmailClient.from_connection_string(settings.AZURE_COMMUNICATION_CONNECTION_STRING)
92
+
93
+ # Use HTML content if provided, otherwise use plain text
94
+ content = html_content if html_content else body
95
+ content_type = "html" if html_content else "plainText"
96
+
97
+ # Create the email message
98
+ message = {
99
+ "senderAddress": settings.AZURE_COMMUNICATION_SENDER_EMAIL,
100
+ "recipients": {
101
+ "to": [{"address": recipient_email}]
102
+ },
103
+ "content": {
104
+ "subject": subject,
105
+ "plainText": body,
106
+ "html": content if content_type == "html" else None
107
+ }
108
+ }
109
+
110
+ # Send the email
111
+ poller = email_client.begin_send(message)
112
+ result = poller.result()
113
+
114
+ logger.info(f"Email sent to {recipient_email} with subject '{subject}' via Azure Communication Services")
115
+ return True
116
+ except Exception as e:
117
+ logger.error(f"Error sending email via Azure Communication Services: {str(e)}")
118
+ return False
119
+
120
+ def send_email(recipient_email, subject, body, html_content=None, attachments=None, use_azure=False):
121
+ """
122
+ Send an email using the preferred method (SMTP or Azure Communication Services)
123
+
124
+ Args:
125
+ recipient_email: The recipient's email address
126
+ subject: Email subject
127
+ body: Plain text email body
128
+ html_content: Optional HTML content
129
+ attachments: List of file paths to attach (only used with SMTP)
130
+ use_azure: Force using Azure Communication Services if available
131
+
132
+ Returns:
133
+ True if sent successfully, False otherwise
134
+ """
135
+ # Use Azure if requested and available
136
+ if use_azure and AZURE_EMAIL_AVAILABLE and settings.AZURE_COMMUNICATION_CONNECTION_STRING:
137
+ return send_email_azure(recipient_email, subject, body, html_content)
138
+
139
+ # Fall back to SMTP
140
+ return send_email_smtp(recipient_email, subject, body, html_content, attachments)
@@ -0,0 +1,14 @@
1
+ import os
2
+ import json
3
+ import logging
4
+ from typing import Optional, List, Dict
5
+
6
+ logger = logging.getLogger(__name__)
7
+
8
+ def get_timestamp_id(job_id: str) -> str:
9
+ """For backward compatibility: return the same job_id"""
10
+ return job_id
11
+
12
+ def get_all_ids_for_timestamp(timestamp_id: str) -> List[str]:
13
+ """For backward compatibility: return list with same job_id"""
14
+ return [timestamp_id]
@@ -0,0 +1,34 @@
1
+ import os
2
+ import json
3
+ import logging
4
+ from typing import Dict, Any, Optional
5
+
6
+ from config import settings
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+ def load_job_description(job_id: str = None, job_file_path: str = None) -> Dict[str, Any]:
11
+ """Load job description from file"""
12
+ try:
13
+ # If path is provided directly, use it
14
+ if job_file_path and os.path.exists(job_file_path):
15
+ with open(job_file_path, 'r') as f:
16
+ job_data = json.load(f)
17
+ return job_data
18
+
19
+ # If job_id is provided, look for the file in job_descriptions directory
20
+ if job_id:
21
+ # Look only in the root job_descriptions directory
22
+ main_path = os.path.join("job_descriptions", f"{job_id}.json")
23
+ if os.path.exists(main_path):
24
+ with open(main_path, 'r') as f:
25
+ job_data = json.load(f)
26
+ return job_data
27
+
28
+ logger.error(f"Job description file not found for job_id {job_id} at {main_path}")
29
+
30
+ logger.error("No valid job ID or file path provided")
31
+ return None
32
+ except Exception as e:
33
+ logger.error(f"Error loading job description: {e}")
34
+ return None