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.
- 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/langgraph_workflow/__init__.py +0 -0
- michael_agent/langgraph_workflow/graph_builder.py +358 -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.1.dist-info}/METADATA +2 -2
- michael_agent-1.0.1.dist-info/RECORD +21 -0
- michael_agent-1.0.0.dist-info/RECORD +0 -7
- {michael_agent-1.0.0.dist-info → michael_agent-1.0.1.dist-info}/WHEEL +0 -0
- {michael_agent-1.0.0.dist-info → michael_agent-1.0.1.dist-info}/top_level.txt +0 -0
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
|