michael-agent 1.0.0__py3-none-any.whl → 1.0.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- michael_agent/config/__init__.py +0 -0
- michael_agent/config/settings.py +66 -0
- michael_agent/dashboard/__init__.py +0 -0
- michael_agent/dashboard/app.py +1450 -0
- michael_agent/dashboard/static/__init__.py +0 -0
- michael_agent/dashboard/templates/__init__.py +0 -0
- michael_agent/langgraph_workflow/__init__.py +0 -0
- michael_agent/langgraph_workflow/graph_builder.py +358 -0
- michael_agent/langgraph_workflow/nodes/__init__.py +0 -0
- michael_agent/langgraph_workflow/nodes/assessment_handler.py +177 -0
- michael_agent/langgraph_workflow/nodes/jd_generator.py +139 -0
- michael_agent/langgraph_workflow/nodes/jd_poster.py +156 -0
- michael_agent/langgraph_workflow/nodes/question_generator.py +295 -0
- michael_agent/langgraph_workflow/nodes/recruiter_notifier.py +224 -0
- michael_agent/langgraph_workflow/nodes/resume_analyzer.py +631 -0
- michael_agent/langgraph_workflow/nodes/resume_ingestor.py +225 -0
- michael_agent/langgraph_workflow/nodes/sentiment_analysis.py +309 -0
- michael_agent/utils/__init__.py +0 -0
- michael_agent/utils/email_utils.py +140 -0
- michael_agent/utils/id_mapper.py +14 -0
- michael_agent/utils/jd_utils.py +34 -0
- michael_agent/utils/lms_api.py +226 -0
- michael_agent/utils/logging_utils.py +192 -0
- michael_agent/utils/monitor_utils.py +289 -0
- michael_agent/utils/node_tracer.py +88 -0
- {michael_agent-1.0.0.dist-info → michael_agent-1.0.2.dist-info}/METADATA +2 -2
- michael_agent-1.0.2.dist-info/RECORD +32 -0
- michael_agent-1.0.0.dist-info/RECORD +0 -7
- {michael_agent-1.0.0.dist-info → michael_agent-1.0.2.dist-info}/WHEEL +0 -0
- {michael_agent-1.0.0.dist-info → michael_agent-1.0.2.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,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
|
@@ -0,0 +1,226 @@
|
|
1
|
+
"""
|
2
|
+
LMS (Learning Management System) API integration utilities
|
3
|
+
Supports integration with HackerRank, TestGorilla, or custom LMS APIs
|
4
|
+
"""
|
5
|
+
|
6
|
+
import os
|
7
|
+
import requests
|
8
|
+
import json
|
9
|
+
import logging
|
10
|
+
from typing import Dict, List, Optional, Any
|
11
|
+
|
12
|
+
from config import settings
|
13
|
+
|
14
|
+
logger = logging.getLogger(__name__)
|
15
|
+
|
16
|
+
class LMSIntegration:
|
17
|
+
"""Base class for LMS integrations"""
|
18
|
+
|
19
|
+
def __init__(self):
|
20
|
+
self.api_url = settings.LMS_API_URL
|
21
|
+
self.api_key = settings.LMS_API_KEY
|
22
|
+
|
23
|
+
def send_assessment(self, candidate_email, candidate_name, position_name):
|
24
|
+
"""Send an assessment to a candidate (to be implemented by subclasses)"""
|
25
|
+
raise NotImplementedError("Subclasses must implement this method")
|
26
|
+
|
27
|
+
def get_assessment_results(self, assessment_id):
|
28
|
+
"""Get the results of an assessment (to be implemented by subclasses)"""
|
29
|
+
raise NotImplementedError("Subclasses must implement this method")
|
30
|
+
|
31
|
+
class HackerRankIntegration(LMSIntegration):
|
32
|
+
"""Integration with HackerRank API"""
|
33
|
+
|
34
|
+
def __init__(self):
|
35
|
+
super().__init__()
|
36
|
+
self.headers = {
|
37
|
+
"Content-Type": "application/json",
|
38
|
+
"Authorization": f"Bearer {self.api_key}"
|
39
|
+
}
|
40
|
+
|
41
|
+
def send_assessment(self, candidate_email, candidate_name, position_name):
|
42
|
+
"""Send a HackerRank test to a candidate"""
|
43
|
+
try:
|
44
|
+
# Find test ID based on position name (example - would need to be customized)
|
45
|
+
test_id = self._get_test_id_for_position(position_name)
|
46
|
+
|
47
|
+
# Prepare API payload
|
48
|
+
payload = {
|
49
|
+
"test_id": test_id,
|
50
|
+
"candidates": [
|
51
|
+
{
|
52
|
+
"email": candidate_email,
|
53
|
+
"first_name": candidate_name.split()[0],
|
54
|
+
"last_name": candidate_name.split()[-1] if len(candidate_name.split()) > 1 else ""
|
55
|
+
}
|
56
|
+
],
|
57
|
+
"subject": f"Assessment for {position_name} position",
|
58
|
+
"message": f"Dear {candidate_name.split()[0]},\n\nPlease complete the following assessment for the {position_name} position."
|
59
|
+
}
|
60
|
+
|
61
|
+
# Make API request to send test invitation
|
62
|
+
response = requests.post(
|
63
|
+
f"{self.api_url}/tests/{test_id}/candidates/invite",
|
64
|
+
headers=self.headers,
|
65
|
+
json=payload
|
66
|
+
)
|
67
|
+
|
68
|
+
response.raise_for_status()
|
69
|
+
result = response.json()
|
70
|
+
|
71
|
+
logger.info(f"Assessment sent to {candidate_email} via HackerRank")
|
72
|
+
return {
|
73
|
+
"success": True,
|
74
|
+
"assessment_id": result.get("assessment_id"),
|
75
|
+
"invitation_id": result.get("invitation_id"),
|
76
|
+
"status": "sent"
|
77
|
+
}
|
78
|
+
except requests.exceptions.RequestException as e:
|
79
|
+
logger.error(f"Error sending HackerRank assessment: {str(e)}")
|
80
|
+
return {
|
81
|
+
"success": False,
|
82
|
+
"error": str(e)
|
83
|
+
}
|
84
|
+
|
85
|
+
def get_assessment_results(self, assessment_id):
|
86
|
+
"""Get the results of a HackerRank assessment"""
|
87
|
+
try:
|
88
|
+
response = requests.get(
|
89
|
+
f"{self.api_url}/assessments/{assessment_id}",
|
90
|
+
headers=self.headers
|
91
|
+
)
|
92
|
+
|
93
|
+
response.raise_for_status()
|
94
|
+
result = response.json()
|
95
|
+
|
96
|
+
return {
|
97
|
+
"success": True,
|
98
|
+
"status": result.get("status"),
|
99
|
+
"score": result.get("score"),
|
100
|
+
"completed_at": result.get("completed_at"),
|
101
|
+
"results": result.get("results")
|
102
|
+
}
|
103
|
+
except requests.exceptions.RequestException as e:
|
104
|
+
logger.error(f"Error getting HackerRank assessment results: {str(e)}")
|
105
|
+
return {
|
106
|
+
"success": False,
|
107
|
+
"error": str(e)
|
108
|
+
}
|
109
|
+
|
110
|
+
def _get_test_id_for_position(self, position_name):
|
111
|
+
"""
|
112
|
+
Map position name to test ID
|
113
|
+
This is a simplified example - in practice, you would have a mapping or search functionality
|
114
|
+
"""
|
115
|
+
# Example mapping - would need to be customized
|
116
|
+
position_to_test_map = {
|
117
|
+
"Software Engineer": "12345",
|
118
|
+
"Data Scientist": "23456",
|
119
|
+
"DevOps Engineer": "34567",
|
120
|
+
# Add more mappings as needed
|
121
|
+
}
|
122
|
+
|
123
|
+
# Default to a general assessment if position not found
|
124
|
+
return position_to_test_map.get(position_name, "default_test_id")
|
125
|
+
|
126
|
+
class TestGorillaIntegration(LMSIntegration):
|
127
|
+
"""Integration with TestGorilla API"""
|
128
|
+
|
129
|
+
def __init__(self):
|
130
|
+
super().__init__()
|
131
|
+
self.headers = {
|
132
|
+
"Content-Type": "application/json",
|
133
|
+
"Authorization": f"Token {self.api_key}"
|
134
|
+
}
|
135
|
+
|
136
|
+
def send_assessment(self, candidate_email, candidate_name, position_name):
|
137
|
+
"""Send a TestGorilla assessment to a candidate"""
|
138
|
+
try:
|
139
|
+
# Find assessment ID based on position name
|
140
|
+
assessment_id = self._get_assessment_id_for_position(position_name)
|
141
|
+
|
142
|
+
# Prepare API payload
|
143
|
+
payload = {
|
144
|
+
"email": candidate_email,
|
145
|
+
"first_name": candidate_name.split()[0],
|
146
|
+
"last_name": candidate_name.split()[-1] if len(candidate_name.split()) > 1 else "",
|
147
|
+
"language": "en",
|
148
|
+
"assessment": assessment_id
|
149
|
+
}
|
150
|
+
|
151
|
+
# Make API request to send invitation
|
152
|
+
response = requests.post(
|
153
|
+
f"{self.api_url}/candidates/",
|
154
|
+
headers=self.headers,
|
155
|
+
json=payload
|
156
|
+
)
|
157
|
+
|
158
|
+
response.raise_for_status()
|
159
|
+
result = response.json()
|
160
|
+
|
161
|
+
logger.info(f"Assessment sent to {candidate_email} via TestGorilla")
|
162
|
+
return {
|
163
|
+
"success": True,
|
164
|
+
"candidate_id": result.get("id"),
|
165
|
+
"invitation_url": result.get("invitation_url"),
|
166
|
+
"status": "invited"
|
167
|
+
}
|
168
|
+
except requests.exceptions.RequestException as e:
|
169
|
+
logger.error(f"Error sending TestGorilla assessment: {str(e)}")
|
170
|
+
return {
|
171
|
+
"success": False,
|
172
|
+
"error": str(e)
|
173
|
+
}
|
174
|
+
|
175
|
+
def get_assessment_results(self, candidate_id):
|
176
|
+
"""Get the results of a TestGorilla assessment"""
|
177
|
+
try:
|
178
|
+
response = requests.get(
|
179
|
+
f"{self.api_url}/candidates/{candidate_id}/",
|
180
|
+
headers=self.headers
|
181
|
+
)
|
182
|
+
|
183
|
+
response.raise_for_status()
|
184
|
+
result = response.json()
|
185
|
+
|
186
|
+
return {
|
187
|
+
"success": True,
|
188
|
+
"status": result.get("status"),
|
189
|
+
"score": result.get("score"),
|
190
|
+
"completed_at": result.get("completed_at"),
|
191
|
+
"test_results": result.get("test_results", [])
|
192
|
+
}
|
193
|
+
except requests.exceptions.RequestException as e:
|
194
|
+
logger.error(f"Error getting TestGorilla assessment results: {str(e)}")
|
195
|
+
return {
|
196
|
+
"success": False,
|
197
|
+
"error": str(e)
|
198
|
+
}
|
199
|
+
|
200
|
+
def _get_assessment_id_for_position(self, position_name):
|
201
|
+
"""
|
202
|
+
Map position name to assessment ID
|
203
|
+
This is a simplified example - in practice, you would have a mapping or search functionality
|
204
|
+
"""
|
205
|
+
# Example mapping - would need to be customized
|
206
|
+
position_to_assessment_map = {
|
207
|
+
"Software Engineer": "abc123",
|
208
|
+
"Data Scientist": "def456",
|
209
|
+
"DevOps Engineer": "ghi789",
|
210
|
+
# Add more mappings as needed
|
211
|
+
}
|
212
|
+
|
213
|
+
# Default to a general assessment if position not found
|
214
|
+
return position_to_assessment_map.get(position_name, "default_assessment_id")
|
215
|
+
|
216
|
+
def get_lms_client() -> LMSIntegration:
|
217
|
+
"""Factory function to get the appropriate LMS client based on configuration"""
|
218
|
+
lms_type = settings.LMS_TYPE.lower()
|
219
|
+
|
220
|
+
if lms_type == "hackerrank":
|
221
|
+
return HackerRankIntegration()
|
222
|
+
elif lms_type == "testgorilla":
|
223
|
+
return TestGorillaIntegration()
|
224
|
+
else:
|
225
|
+
logger.warning(f"Unsupported LMS type: {lms_type}, falling back to HackerRank")
|
226
|
+
return HackerRankIntegration()
|
@@ -0,0 +1,192 @@
|
|
1
|
+
"""
|
2
|
+
Logging utilities for SmartRecruitAgent
|
3
|
+
Provides consistent logging configuration and helpers
|
4
|
+
"""
|
5
|
+
|
6
|
+
import os
|
7
|
+
import sys
|
8
|
+
import json
|
9
|
+
import logging
|
10
|
+
import traceback
|
11
|
+
from datetime import datetime
|
12
|
+
from functools import wraps
|
13
|
+
from typing import Dict, Any, Callable, Optional
|
14
|
+
|
15
|
+
# Import config
|
16
|
+
from config import settings
|
17
|
+
|
18
|
+
# Configure the base logging system
|
19
|
+
def setup_logger(name: str, log_file: Optional[str] = None, level: int = logging.INFO) -> logging.Logger:
|
20
|
+
"""Set up and configure a logger instance"""
|
21
|
+
logger = logging.getLogger(name)
|
22
|
+
logger.setLevel(level)
|
23
|
+
|
24
|
+
# Create formatter
|
25
|
+
formatter = logging.Formatter(
|
26
|
+
'%(asctime)s | %(levelname)s | %(name)s | %(message)s',
|
27
|
+
datefmt='%Y-%m-%d %H:%M:%S'
|
28
|
+
)
|
29
|
+
|
30
|
+
# Add console handler
|
31
|
+
console_handler = logging.StreamHandler(sys.stdout)
|
32
|
+
console_handler.setFormatter(formatter)
|
33
|
+
logger.addHandler(console_handler)
|
34
|
+
|
35
|
+
# Add file handler if log file is specified
|
36
|
+
if log_file:
|
37
|
+
os.makedirs(os.path.dirname(log_file), exist_ok=True)
|
38
|
+
file_handler = logging.FileHandler(log_file)
|
39
|
+
file_handler.setFormatter(formatter)
|
40
|
+
logger.addHandler(file_handler)
|
41
|
+
|
42
|
+
return logger
|
43
|
+
|
44
|
+
# Create the workflow logger
|
45
|
+
workflow_logger = setup_logger(
|
46
|
+
'workflow',
|
47
|
+
os.path.join(settings.LOG_DIR, 'workflow.log')
|
48
|
+
)
|
49
|
+
|
50
|
+
# Create the state transitions logger
|
51
|
+
state_logger = setup_logger(
|
52
|
+
'state_transitions',
|
53
|
+
os.path.join(settings.LOG_DIR, 'state_transitions.log')
|
54
|
+
)
|
55
|
+
|
56
|
+
def log_state_transition(before_state: Dict[str, Any], after_state: Dict[str, Any], step_name: str) -> None:
|
57
|
+
"""Log state transitions between workflow steps"""
|
58
|
+
# Create a simplified log of what changed
|
59
|
+
changes = {}
|
60
|
+
|
61
|
+
# Track only the keys that changed
|
62
|
+
for key in after_state:
|
63
|
+
if key not in before_state or before_state[key] != after_state[key]:
|
64
|
+
# For complex objects, just note they changed rather than logging entire content
|
65
|
+
if isinstance(after_state[key], dict) or isinstance(after_state[key], list):
|
66
|
+
changes[key] = f"{type(after_state[key]).__name__} modified"
|
67
|
+
else:
|
68
|
+
changes[key] = after_state[key]
|
69
|
+
|
70
|
+
# Record any errors added in this step
|
71
|
+
errors = []
|
72
|
+
if "errors" in after_state and isinstance(after_state["errors"], list):
|
73
|
+
before_errors_count = len(before_state.get("errors", []))
|
74
|
+
if len(after_state["errors"]) > before_errors_count:
|
75
|
+
errors = after_state["errors"][before_errors_count:]
|
76
|
+
|
77
|
+
# Create the log entry
|
78
|
+
log_entry = {
|
79
|
+
"timestamp": datetime.now().isoformat(),
|
80
|
+
"step": step_name,
|
81
|
+
"job_id": after_state.get("job_id", "unknown"),
|
82
|
+
"candidate": after_state.get("candidate_name", "unknown"),
|
83
|
+
"status": after_state.get("status", "unknown"),
|
84
|
+
"changes": changes,
|
85
|
+
"errors": errors
|
86
|
+
}
|
87
|
+
|
88
|
+
# Add a JSON serialization helper function
|
89
|
+
def json_serializable(obj):
|
90
|
+
if isinstance(obj, (str, int, float, bool, type(None))):
|
91
|
+
return obj
|
92
|
+
elif isinstance(obj, (list, tuple)):
|
93
|
+
return [json_serializable(item) for item in obj]
|
94
|
+
elif isinstance(obj, dict):
|
95
|
+
return {k: json_serializable(v) for k, v in obj.items() if isinstance(k, str)}
|
96
|
+
return str(obj) # Convert non-serializable objects to strings
|
97
|
+
|
98
|
+
# Modify the log_entry or use the helper
|
99
|
+
serializable_log_entry = json_serializable(log_entry)
|
100
|
+
# Log to file
|
101
|
+
state_logger.info(json.dumps(serializable_log_entry))
|
102
|
+
|
103
|
+
def log_step(func: Callable) -> Callable:
|
104
|
+
"""Decorator for logging workflow step execution with detailed tracking"""
|
105
|
+
@wraps(func)
|
106
|
+
def wrapper(state: Dict[str, Any], *args, **kwargs) -> Dict[str, Any]:
|
107
|
+
step_name = func.__name__
|
108
|
+
job_id = state.get("job_id", "unknown")
|
109
|
+
|
110
|
+
# Log step start
|
111
|
+
workflow_logger.info(f"Starting step: {step_name} for job {job_id}")
|
112
|
+
|
113
|
+
# Store the state before execution for comparison
|
114
|
+
before_state = {**state}
|
115
|
+
|
116
|
+
try:
|
117
|
+
# Execute the step
|
118
|
+
result_state = func(state, *args, **kwargs)
|
119
|
+
|
120
|
+
# Handle case when function returns None instead of state
|
121
|
+
if result_state is None:
|
122
|
+
workflow_logger.debug(f"Step {step_name} returned None; using original state.")
|
123
|
+
result_state = before_state
|
124
|
+
|
125
|
+
# Update status to reflect step completion
|
126
|
+
if isinstance(result_state, dict) and "status" in result_state:
|
127
|
+
result_state["status"] = f"completed_{step_name}"
|
128
|
+
|
129
|
+
# Log successful completion
|
130
|
+
workflow_logger.info(f"Completed step: {step_name} for job {job_id}")
|
131
|
+
|
132
|
+
# Log state transitions
|
133
|
+
log_state_transition(before_state, result_state, step_name)
|
134
|
+
|
135
|
+
return result_state
|
136
|
+
|
137
|
+
except Exception as e:
|
138
|
+
# Log the exception with traceback
|
139
|
+
error_msg = f"Error in step {step_name}: {str(e)}"
|
140
|
+
workflow_logger.error(error_msg)
|
141
|
+
workflow_logger.error(traceback.format_exc())
|
142
|
+
|
143
|
+
# Update state with error information
|
144
|
+
if "errors" not in state:
|
145
|
+
state["errors"] = []
|
146
|
+
|
147
|
+
state["errors"].append({
|
148
|
+
"step": step_name,
|
149
|
+
"timestamp": datetime.now().isoformat(),
|
150
|
+
"error": str(e),
|
151
|
+
"traceback": traceback.format_exc()
|
152
|
+
})
|
153
|
+
|
154
|
+
if "status" in state:
|
155
|
+
state["status"] = f"error_in_{step_name}"
|
156
|
+
|
157
|
+
# Log state transition with error
|
158
|
+
log_state_transition(before_state, state, f"{step_name}_error")
|
159
|
+
|
160
|
+
return state
|
161
|
+
|
162
|
+
return wrapper
|
163
|
+
|
164
|
+
def save_state_snapshot(state: Dict[str, Any], step: str) -> None:
|
165
|
+
"""Save a snapshot of the current state to a JSON file"""
|
166
|
+
try:
|
167
|
+
# Create a timestamped filename
|
168
|
+
timestamp = datetime.now().strftime("%Y%m%d%H%M%S")
|
169
|
+
job_id = state.get("job_id", "unknown")
|
170
|
+
filename = f"{timestamp}_{job_id}_{step}.json"
|
171
|
+
|
172
|
+
# Create the snapshots directory
|
173
|
+
snapshots_dir = os.path.join(settings.LOG_DIR, "snapshots")
|
174
|
+
os.makedirs(snapshots_dir, exist_ok=True)
|
175
|
+
|
176
|
+
# Create a clean copy of the state (remove any objects that can't be serialized)
|
177
|
+
clean_state = {}
|
178
|
+
for key, value in state.items():
|
179
|
+
try:
|
180
|
+
# Test if it can be serialized
|
181
|
+
json.dumps({key: value})
|
182
|
+
clean_state[key] = value
|
183
|
+
except (TypeError, OverflowError):
|
184
|
+
clean_state[key] = f"<non-serializable: {type(value).__name__}>"
|
185
|
+
|
186
|
+
# Write the state to file
|
187
|
+
with open(os.path.join(snapshots_dir, filename), 'w') as f:
|
188
|
+
json.dump(clean_state, f, indent=2, default=str)
|
189
|
+
|
190
|
+
workflow_logger.debug(f"State snapshot saved: {filename}")
|
191
|
+
except Exception as e:
|
192
|
+
workflow_logger.error(f"Failed to save state snapshot: {str(e)}")
|