fusesell 1.3.42__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.
- fusesell-1.3.42.dist-info/METADATA +873 -0
- fusesell-1.3.42.dist-info/RECORD +35 -0
- fusesell-1.3.42.dist-info/WHEEL +5 -0
- fusesell-1.3.42.dist-info/entry_points.txt +2 -0
- fusesell-1.3.42.dist-info/licenses/LICENSE +21 -0
- fusesell-1.3.42.dist-info/top_level.txt +2 -0
- fusesell.py +20 -0
- fusesell_local/__init__.py +37 -0
- fusesell_local/api.py +343 -0
- fusesell_local/cli.py +1480 -0
- fusesell_local/config/__init__.py +11 -0
- fusesell_local/config/default_email_templates.json +34 -0
- fusesell_local/config/default_prompts.json +19 -0
- fusesell_local/config/default_scoring_criteria.json +154 -0
- fusesell_local/config/prompts.py +245 -0
- fusesell_local/config/settings.py +277 -0
- fusesell_local/pipeline.py +978 -0
- fusesell_local/stages/__init__.py +19 -0
- fusesell_local/stages/base_stage.py +603 -0
- fusesell_local/stages/data_acquisition.py +1820 -0
- fusesell_local/stages/data_preparation.py +1238 -0
- fusesell_local/stages/follow_up.py +1728 -0
- fusesell_local/stages/initial_outreach.py +2972 -0
- fusesell_local/stages/lead_scoring.py +1452 -0
- fusesell_local/utils/__init__.py +36 -0
- fusesell_local/utils/agent_context.py +552 -0
- fusesell_local/utils/auto_setup.py +361 -0
- fusesell_local/utils/birthday_email_manager.py +467 -0
- fusesell_local/utils/data_manager.py +4857 -0
- fusesell_local/utils/event_scheduler.py +959 -0
- fusesell_local/utils/llm_client.py +342 -0
- fusesell_local/utils/logger.py +203 -0
- fusesell_local/utils/output_helpers.py +2443 -0
- fusesell_local/utils/timezone_detector.py +914 -0
- fusesell_local/utils/validators.py +436 -0
|
@@ -0,0 +1,2972 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Initial Outreach Stage - Generate personalized email drafts with action-based routing
|
|
3
|
+
Converted from gs_148_initial_outreach executor schema
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import uuid
|
|
8
|
+
import requests
|
|
9
|
+
import re
|
|
10
|
+
from typing import Dict, Any, List, Optional
|
|
11
|
+
from datetime import datetime
|
|
12
|
+
from .base_stage import BaseStage
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class InitialOutreachStage(BaseStage):
|
|
16
|
+
"""
|
|
17
|
+
Initial Outreach stage with full server executor schema compliance.
|
|
18
|
+
Supports: draft_write, draft_rewrite, send, close actions.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(self, *args, **kwargs):
|
|
22
|
+
super().__init__(*args, **kwargs)
|
|
23
|
+
self._active_rep_profile: Dict[str, Any] = {}
|
|
24
|
+
|
|
25
|
+
def execute(self, context: Dict[str, Any]) -> Dict[str, Any]:
|
|
26
|
+
"""
|
|
27
|
+
Execute initial outreach stage with action-based routing (matching server executor).
|
|
28
|
+
|
|
29
|
+
Actions supported:
|
|
30
|
+
- draft_write: Generate new email drafts
|
|
31
|
+
- draft_rewrite: Modify existing draft using selected_draft_id
|
|
32
|
+
- send: Send approved draft to recipient_address
|
|
33
|
+
- close: Close outreach when customer feels negative
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
context: Execution context
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
Stage execution result
|
|
40
|
+
"""
|
|
41
|
+
try:
|
|
42
|
+
# Get action from input data (matching server schema)
|
|
43
|
+
input_data = context.get('input_data', {})
|
|
44
|
+
action = input_data.get('action', 'draft_write') # Default to draft_write
|
|
45
|
+
|
|
46
|
+
self.logger.info(f"Executing initial outreach with action: {action}")
|
|
47
|
+
|
|
48
|
+
# Validate required fields based on action
|
|
49
|
+
self._validate_action_input(action, input_data)
|
|
50
|
+
|
|
51
|
+
# Route based on action type (matching server executor schema)
|
|
52
|
+
if action == 'draft_write':
|
|
53
|
+
return self._handle_draft_write(context)
|
|
54
|
+
elif action == 'draft_rewrite':
|
|
55
|
+
return self._handle_draft_rewrite(context)
|
|
56
|
+
elif action == 'send':
|
|
57
|
+
return self._handle_send(context)
|
|
58
|
+
elif action == 'close':
|
|
59
|
+
return self._handle_close(context)
|
|
60
|
+
else:
|
|
61
|
+
raise ValueError(f"Invalid action: {action}. Must be one of: draft_write, draft_rewrite, send, close")
|
|
62
|
+
|
|
63
|
+
except Exception as e:
|
|
64
|
+
self.log_stage_error(context, e)
|
|
65
|
+
return self.handle_stage_error(e, context)
|
|
66
|
+
|
|
67
|
+
def _validate_action_input(self, action: str, input_data: Dict[str, Any]) -> None:
|
|
68
|
+
"""
|
|
69
|
+
Validate required fields based on action type.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
action: Action type
|
|
73
|
+
input_data: Input data
|
|
74
|
+
|
|
75
|
+
Raises:
|
|
76
|
+
ValueError: If required fields are missing
|
|
77
|
+
"""
|
|
78
|
+
if action in ['draft_rewrite', 'send']:
|
|
79
|
+
if not input_data.get('selected_draft_id'):
|
|
80
|
+
raise ValueError(f"selected_draft_id is required for {action} action")
|
|
81
|
+
|
|
82
|
+
if action == 'send':
|
|
83
|
+
if not input_data.get('recipient_address'):
|
|
84
|
+
raise ValueError("recipient_address is required for send action")
|
|
85
|
+
|
|
86
|
+
def _handle_draft_write(self, context: Dict[str, Any]) -> Dict[str, Any]:
|
|
87
|
+
"""
|
|
88
|
+
Handle draft_write action - Generate new email drafts.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
context: Execution context
|
|
92
|
+
|
|
93
|
+
Returns:
|
|
94
|
+
Stage execution result with new drafts
|
|
95
|
+
"""
|
|
96
|
+
# Get data from previous stages
|
|
97
|
+
customer_data = self._get_customer_data(context)
|
|
98
|
+
scoring_data = self._get_scoring_data(context)
|
|
99
|
+
|
|
100
|
+
# Get the best product recommendation
|
|
101
|
+
recommended_product = self._get_recommended_product(scoring_data)
|
|
102
|
+
|
|
103
|
+
if not recommended_product:
|
|
104
|
+
raise ValueError("No product recommendation available for email generation")
|
|
105
|
+
|
|
106
|
+
rep_profile = self._resolve_primary_sales_rep(context)
|
|
107
|
+
self._active_rep_profile = rep_profile or {}
|
|
108
|
+
|
|
109
|
+
try:
|
|
110
|
+
# Generate multiple email drafts
|
|
111
|
+
email_drafts = self._generate_email_drafts(
|
|
112
|
+
customer_data,
|
|
113
|
+
recommended_product,
|
|
114
|
+
scoring_data,
|
|
115
|
+
context,
|
|
116
|
+
rep_profile=self._active_rep_profile
|
|
117
|
+
)
|
|
118
|
+
finally:
|
|
119
|
+
self._active_rep_profile = {}
|
|
120
|
+
|
|
121
|
+
# Save drafts to local files and database
|
|
122
|
+
saved_drafts = self._save_email_drafts(context, email_drafts)
|
|
123
|
+
|
|
124
|
+
schedule_summary = self._schedule_initial_reminder_for_drafts(
|
|
125
|
+
saved_drafts,
|
|
126
|
+
customer_data,
|
|
127
|
+
context
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
# Prepare final output
|
|
131
|
+
outreach_data = {
|
|
132
|
+
'action': 'draft_write',
|
|
133
|
+
'status': 'drafts_generated',
|
|
134
|
+
'email_drafts': saved_drafts,
|
|
135
|
+
'recommended_product': recommended_product,
|
|
136
|
+
'customer_summary': self._create_customer_summary(customer_data),
|
|
137
|
+
'total_drafts_generated': len(saved_drafts),
|
|
138
|
+
'generation_timestamp': datetime.now().isoformat(),
|
|
139
|
+
'customer_id': context.get('execution_id')
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if schedule_summary:
|
|
143
|
+
outreach_data['reminder_schedule'] = schedule_summary
|
|
144
|
+
|
|
145
|
+
# Save to database
|
|
146
|
+
self.save_stage_result(context, outreach_data)
|
|
147
|
+
|
|
148
|
+
result = self.create_success_result(outreach_data, context)
|
|
149
|
+
return result
|
|
150
|
+
|
|
151
|
+
def _handle_draft_rewrite(self, context: Dict[str, Any]) -> Dict[str, Any]:
|
|
152
|
+
"""
|
|
153
|
+
Handle draft_rewrite action - Modify existing draft using selected_draft_id.
|
|
154
|
+
|
|
155
|
+
Args:
|
|
156
|
+
context: Execution context
|
|
157
|
+
|
|
158
|
+
Returns:
|
|
159
|
+
Stage execution result with rewritten draft
|
|
160
|
+
"""
|
|
161
|
+
input_data = context.get('input_data', {})
|
|
162
|
+
selected_draft_id = input_data.get('selected_draft_id')
|
|
163
|
+
reason = input_data.get('reason', 'No reason provided')
|
|
164
|
+
|
|
165
|
+
# Retrieve existing draft
|
|
166
|
+
existing_draft = self._get_draft_by_id(selected_draft_id)
|
|
167
|
+
if not existing_draft:
|
|
168
|
+
raise ValueError(f"Draft not found: {selected_draft_id}")
|
|
169
|
+
|
|
170
|
+
# Get customer data for context
|
|
171
|
+
customer_data = self._get_customer_data(context)
|
|
172
|
+
scoring_data = self._get_scoring_data(context)
|
|
173
|
+
|
|
174
|
+
recipient_identity = self._resolve_recipient_identity(customer_data, context)
|
|
175
|
+
context.setdefault('_recipient_identity', recipient_identity)
|
|
176
|
+
context.setdefault('_recipient_identity', recipient_identity)
|
|
177
|
+
if recipient_identity.get('first_name') and not context.get('customer_first_name'):
|
|
178
|
+
context['customer_first_name'] = recipient_identity['first_name']
|
|
179
|
+
|
|
180
|
+
# Rewrite the draft based on reason
|
|
181
|
+
rewritten_draft = self._rewrite_draft(existing_draft, reason, customer_data, scoring_data, context)
|
|
182
|
+
|
|
183
|
+
# Save the rewritten draft
|
|
184
|
+
saved_draft = self._save_rewritten_draft(context, rewritten_draft, selected_draft_id)
|
|
185
|
+
|
|
186
|
+
# Prepare output
|
|
187
|
+
outreach_data = {
|
|
188
|
+
'action': 'draft_rewrite',
|
|
189
|
+
'status': 'draft_rewritten',
|
|
190
|
+
'original_draft_id': selected_draft_id,
|
|
191
|
+
'rewritten_draft': saved_draft,
|
|
192
|
+
'rewrite_reason': reason,
|
|
193
|
+
'generation_timestamp': datetime.now().isoformat(),
|
|
194
|
+
'customer_id': context.get('execution_id')
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
# Save to database
|
|
198
|
+
self.save_stage_result(context, outreach_data)
|
|
199
|
+
|
|
200
|
+
result = self.create_success_result(outreach_data, context)
|
|
201
|
+
# Logging handled by execute_with_timing wrapper
|
|
202
|
+
|
|
203
|
+
return result
|
|
204
|
+
|
|
205
|
+
def _handle_send(self, context: Dict[str, Any]) -> Dict[str, Any]:
|
|
206
|
+
"""
|
|
207
|
+
Handle send action - Send approved draft to recipient (with optional scheduling).
|
|
208
|
+
|
|
209
|
+
Args:
|
|
210
|
+
context: Execution context
|
|
211
|
+
|
|
212
|
+
Returns:
|
|
213
|
+
Stage execution result with send status
|
|
214
|
+
"""
|
|
215
|
+
input_data = context.get('input_data', {})
|
|
216
|
+
selected_draft_id = input_data.get('selected_draft_id')
|
|
217
|
+
recipient_address = input_data.get('recipient_address')
|
|
218
|
+
recipient_name = input_data.get('recipient_name', 'Dear Customer')
|
|
219
|
+
send_immediately = input_data.get('send_immediately', False) # New parameter for immediate sending
|
|
220
|
+
|
|
221
|
+
# Retrieve the draft to send
|
|
222
|
+
draft_to_send = self._get_draft_by_id(selected_draft_id)
|
|
223
|
+
if not draft_to_send:
|
|
224
|
+
raise ValueError(f"Draft not found: {selected_draft_id}")
|
|
225
|
+
|
|
226
|
+
# Check if we should send immediately or schedule
|
|
227
|
+
if send_immediately:
|
|
228
|
+
# Send immediately
|
|
229
|
+
send_result = self._send_email(draft_to_send, recipient_address, recipient_name, context)
|
|
230
|
+
|
|
231
|
+
outreach_data = {
|
|
232
|
+
'action': 'send',
|
|
233
|
+
'status': 'email_sent' if send_result['success'] else 'send_failed',
|
|
234
|
+
'draft_id': selected_draft_id,
|
|
235
|
+
'recipient_address': recipient_address,
|
|
236
|
+
'recipient_name': recipient_name,
|
|
237
|
+
'send_result': send_result,
|
|
238
|
+
'sent_timestamp': datetime.now().isoformat(),
|
|
239
|
+
'customer_id': context.get('execution_id'),
|
|
240
|
+
'scheduling': 'immediate'
|
|
241
|
+
}
|
|
242
|
+
else:
|
|
243
|
+
# Schedule for optimal time
|
|
244
|
+
schedule_result = self._schedule_email(draft_to_send, recipient_address, recipient_name, context)
|
|
245
|
+
|
|
246
|
+
outreach_data = {
|
|
247
|
+
'action': 'send',
|
|
248
|
+
'status': 'email_scheduled' if schedule_result['success'] else 'schedule_failed',
|
|
249
|
+
'draft_id': selected_draft_id,
|
|
250
|
+
'recipient_address': recipient_address,
|
|
251
|
+
'recipient_name': recipient_name,
|
|
252
|
+
'schedule_result': schedule_result,
|
|
253
|
+
'scheduled_timestamp': datetime.now().isoformat(),
|
|
254
|
+
'customer_id': context.get('execution_id'),
|
|
255
|
+
'scheduling': 'delayed'
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
# Save to database
|
|
259
|
+
self.save_stage_result(context, outreach_data)
|
|
260
|
+
|
|
261
|
+
result = self.create_success_result(outreach_data, context)
|
|
262
|
+
# Logging handled by execute_with_timing wrapper
|
|
263
|
+
|
|
264
|
+
return result
|
|
265
|
+
|
|
266
|
+
def _schedule_email(self, draft: Dict[str, Any], recipient_address: str, recipient_name: str, context: Dict[str, Any]) -> Dict[str, Any]:
|
|
267
|
+
"""
|
|
268
|
+
Schedule email event in database for external app to handle.
|
|
269
|
+
|
|
270
|
+
Args:
|
|
271
|
+
draft: Email draft to send
|
|
272
|
+
recipient_address: Email address of recipient
|
|
273
|
+
recipient_name: Name of recipient
|
|
274
|
+
context: Execution context
|
|
275
|
+
|
|
276
|
+
Returns:
|
|
277
|
+
Scheduling result
|
|
278
|
+
"""
|
|
279
|
+
try:
|
|
280
|
+
from ..utils.event_scheduler import EventScheduler
|
|
281
|
+
|
|
282
|
+
input_data = context.get('input_data', {})
|
|
283
|
+
|
|
284
|
+
# Initialize event scheduler
|
|
285
|
+
scheduler = EventScheduler(self.config.get('data_dir', './fusesell_data'))
|
|
286
|
+
|
|
287
|
+
# Check if immediate sending is requested
|
|
288
|
+
send_immediately = input_data.get('send_immediately', False)
|
|
289
|
+
reminder_context = self._build_initial_reminder_context(
|
|
290
|
+
draft,
|
|
291
|
+
recipient_address,
|
|
292
|
+
recipient_name,
|
|
293
|
+
context
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
# Schedule the email event
|
|
297
|
+
schedule_result = scheduler.schedule_email_event(
|
|
298
|
+
draft_id=draft.get('draft_id'),
|
|
299
|
+
recipient_address=recipient_address,
|
|
300
|
+
recipient_name=recipient_name,
|
|
301
|
+
org_id=input_data.get('org_id', 'default'),
|
|
302
|
+
team_id=input_data.get('team_id'),
|
|
303
|
+
customer_timezone=input_data.get('customer_timezone'),
|
|
304
|
+
email_type='initial',
|
|
305
|
+
send_immediately=send_immediately,
|
|
306
|
+
reminder_context=reminder_context
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
if schedule_result['success']:
|
|
310
|
+
self.logger.info(f"Email event scheduled successfully: {schedule_result['event_id']} for {schedule_result['scheduled_time']}")
|
|
311
|
+
return {
|
|
312
|
+
'success': True,
|
|
313
|
+
'message': f'Email event scheduled for {schedule_result["scheduled_time"]}',
|
|
314
|
+
'event_id': schedule_result['event_id'],
|
|
315
|
+
'scheduled_time': schedule_result['scheduled_time'],
|
|
316
|
+
'follow_up_event_id': schedule_result.get('follow_up_event_id'),
|
|
317
|
+
'service': 'Database Event Scheduler'
|
|
318
|
+
}
|
|
319
|
+
else:
|
|
320
|
+
self.logger.error(f"Email event scheduling failed: {schedule_result.get('error', 'Unknown error')}")
|
|
321
|
+
return {
|
|
322
|
+
'success': False,
|
|
323
|
+
'message': f'Email event scheduling failed: {schedule_result.get("error", "Unknown error")}',
|
|
324
|
+
'error': schedule_result.get('error')
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
except Exception as e:
|
|
328
|
+
self.logger.error(f"Email scheduling failed: {str(e)}")
|
|
329
|
+
return {
|
|
330
|
+
'success': False,
|
|
331
|
+
'message': f'Email scheduling failed: {str(e)}',
|
|
332
|
+
'error': str(e)
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
def _build_initial_reminder_context(
|
|
336
|
+
self,
|
|
337
|
+
draft: Dict[str, Any],
|
|
338
|
+
recipient_address: str,
|
|
339
|
+
recipient_name: str,
|
|
340
|
+
context: Dict[str, Any]
|
|
341
|
+
) -> Dict[str, Any]:
|
|
342
|
+
"""
|
|
343
|
+
Build reminder_task metadata for scheduled initial outreach emails.
|
|
344
|
+
"""
|
|
345
|
+
input_data = context.get('input_data', {})
|
|
346
|
+
org_id = input_data.get('org_id', 'default') or 'default'
|
|
347
|
+
customer_id = input_data.get('customer_id') or context.get('execution_id') or 'unknown'
|
|
348
|
+
task_id = context.get('execution_id') or input_data.get('task_id') or 'unknown_task'
|
|
349
|
+
team_id = input_data.get('team_id')
|
|
350
|
+
team_name = input_data.get('team_name')
|
|
351
|
+
language = input_data.get('language')
|
|
352
|
+
customer_name = input_data.get('customer_name') or input_data.get('recipient_name')
|
|
353
|
+
staff_name = input_data.get('staff_name') or self.config.get('staff_name') or 'Sales Team'
|
|
354
|
+
reminder_room = self.config.get('reminder_room_id') or input_data.get('reminder_room_id')
|
|
355
|
+
draft_id = draft.get('draft_id') or 'unknown_draft'
|
|
356
|
+
|
|
357
|
+
customextra = {
|
|
358
|
+
'reminder_content': 'draft_send',
|
|
359
|
+
'org_id': org_id,
|
|
360
|
+
'customer_id': customer_id,
|
|
361
|
+
'task_id': task_id,
|
|
362
|
+
'customer_name': customer_name,
|
|
363
|
+
'language': language,
|
|
364
|
+
'recipient_address': recipient_address,
|
|
365
|
+
'recipient_name': recipient_name,
|
|
366
|
+
'staff_name': staff_name,
|
|
367
|
+
'team_id': team_id,
|
|
368
|
+
'team_name': team_name,
|
|
369
|
+
'interaction_type': input_data.get('interaction_type'),
|
|
370
|
+
'draft_id': draft_id,
|
|
371
|
+
'import_uuid': f"{org_id}_{customer_id}_{task_id}_{draft_id}"
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
if draft.get('product_name'):
|
|
375
|
+
customextra['product_name'] = draft.get('product_name')
|
|
376
|
+
if draft.get('approach'):
|
|
377
|
+
customextra['approach'] = draft.get('approach')
|
|
378
|
+
if draft.get('mail_tone'):
|
|
379
|
+
customextra['mail_tone'] = draft.get('mail_tone')
|
|
380
|
+
if recipient_address and 'customer_email' not in customextra:
|
|
381
|
+
customextra['customer_email'] = recipient_address
|
|
382
|
+
|
|
383
|
+
return {
|
|
384
|
+
'status': 'published',
|
|
385
|
+
'task': f"FuseSell initial outreach {org_id}_{customer_id} - {task_id}",
|
|
386
|
+
'tags': ['fusesell', 'init-outreach'],
|
|
387
|
+
'room_id': reminder_room,
|
|
388
|
+
'org_id': org_id,
|
|
389
|
+
'customer_id': customer_id,
|
|
390
|
+
'task_id': task_id,
|
|
391
|
+
'team_id': team_id,
|
|
392
|
+
'team_name': team_name,
|
|
393
|
+
'language': language,
|
|
394
|
+
'customer_name': customer_name,
|
|
395
|
+
'customer_email': recipient_address,
|
|
396
|
+
'staff_name': staff_name,
|
|
397
|
+
'customextra': customextra
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
def _schedule_initial_reminder_for_drafts(
|
|
401
|
+
self,
|
|
402
|
+
drafts: List[Dict[str, Any]],
|
|
403
|
+
customer_data: Dict[str, Any],
|
|
404
|
+
context: Dict[str, Any]
|
|
405
|
+
) -> Optional[Dict[str, Any]]:
|
|
406
|
+
"""
|
|
407
|
+
Schedule reminder_task row for the highest-ranked draft after draft generation.
|
|
408
|
+
|
|
409
|
+
Mirrors the server-side behaviour where schedule_auto_run seeds reminder_task
|
|
410
|
+
so RealTimeX automations can pick up pending outreach immediately.
|
|
411
|
+
"""
|
|
412
|
+
if not drafts:
|
|
413
|
+
return None
|
|
414
|
+
|
|
415
|
+
input_data = context.get('input_data', {})
|
|
416
|
+
|
|
417
|
+
if input_data.get('send_immediately'):
|
|
418
|
+
self.logger.debug("Skipping reminder scheduling because send_immediately is True")
|
|
419
|
+
return None
|
|
420
|
+
|
|
421
|
+
contact_info = customer_data.get('primaryContact', {}) or {}
|
|
422
|
+
stage_results = context.get('stage_results', {}) or {}
|
|
423
|
+
data_acquisition = {}
|
|
424
|
+
if isinstance(stage_results, dict):
|
|
425
|
+
data_acquisition = stage_results.get('data_acquisition', {}).get('data', {}) or {}
|
|
426
|
+
|
|
427
|
+
recipient_address = (
|
|
428
|
+
input_data.get('recipient_address')
|
|
429
|
+
or contact_info.get('email')
|
|
430
|
+
or contact_info.get('emailAddress')
|
|
431
|
+
or data_acquisition.get('customer_email')
|
|
432
|
+
or data_acquisition.get('contact_email')
|
|
433
|
+
or input_data.get('customer_email')
|
|
434
|
+
)
|
|
435
|
+
if not recipient_address:
|
|
436
|
+
self.logger.info("Skipping reminder scheduling: recipient email not available")
|
|
437
|
+
return None
|
|
438
|
+
|
|
439
|
+
recipient_name = (
|
|
440
|
+
input_data.get('recipient_name')
|
|
441
|
+
or contact_info.get('name')
|
|
442
|
+
or contact_info.get('fullName')
|
|
443
|
+
or data_acquisition.get('contact_name')
|
|
444
|
+
or data_acquisition.get('customer_name')
|
|
445
|
+
or ''
|
|
446
|
+
)
|
|
447
|
+
|
|
448
|
+
def _draft_sort_key(draft: Dict[str, Any]) -> tuple[int, float]:
|
|
449
|
+
priority = draft.get('priority_order')
|
|
450
|
+
if not isinstance(priority, int) or priority < 1:
|
|
451
|
+
priority = self._get_draft_priority_order(draft)
|
|
452
|
+
draft['priority_order'] = priority
|
|
453
|
+
personalization = draft.get('personalization_score', 0)
|
|
454
|
+
try:
|
|
455
|
+
personalization_value = float(personalization)
|
|
456
|
+
except (TypeError, ValueError):
|
|
457
|
+
personalization_value = 0.0
|
|
458
|
+
return (priority, -personalization_value)
|
|
459
|
+
|
|
460
|
+
ordered_drafts = sorted(drafts, key=_draft_sort_key)
|
|
461
|
+
if not ordered_drafts:
|
|
462
|
+
return None
|
|
463
|
+
|
|
464
|
+
top_draft = ordered_drafts[0]
|
|
465
|
+
|
|
466
|
+
try:
|
|
467
|
+
from ..utils.event_scheduler import EventScheduler
|
|
468
|
+
scheduler = EventScheduler(self.config.get('data_dir', './fusesell_data'))
|
|
469
|
+
except Exception as exc:
|
|
470
|
+
self.logger.warning(
|
|
471
|
+
"Failed to initialise EventScheduler for reminder scheduling: %s",
|
|
472
|
+
exc
|
|
473
|
+
)
|
|
474
|
+
return {'success': False, 'error': str(exc)}
|
|
475
|
+
|
|
476
|
+
reminder_context = self._build_initial_reminder_context(
|
|
477
|
+
top_draft,
|
|
478
|
+
recipient_address,
|
|
479
|
+
recipient_name,
|
|
480
|
+
context
|
|
481
|
+
)
|
|
482
|
+
|
|
483
|
+
try:
|
|
484
|
+
schedule_result = scheduler.schedule_email_event(
|
|
485
|
+
draft_id=top_draft.get('draft_id'),
|
|
486
|
+
recipient_address=recipient_address,
|
|
487
|
+
recipient_name=recipient_name,
|
|
488
|
+
org_id=input_data.get('org_id') or self.config.get('org_id', 'default'),
|
|
489
|
+
team_id=input_data.get('team_id') or self.config.get('team_id'),
|
|
490
|
+
customer_timezone=input_data.get('customer_timezone'),
|
|
491
|
+
email_type='initial',
|
|
492
|
+
send_immediately=False,
|
|
493
|
+
reminder_context=reminder_context
|
|
494
|
+
)
|
|
495
|
+
except Exception as exc:
|
|
496
|
+
self.logger.error(f"Initial reminder scheduling failed: {exc}")
|
|
497
|
+
return {'success': False, 'error': str(exc)}
|
|
498
|
+
|
|
499
|
+
if schedule_result.get('success'):
|
|
500
|
+
self.logger.info(
|
|
501
|
+
"Scheduled initial outreach reminder %s for draft %s",
|
|
502
|
+
schedule_result.get('reminder_task_id'),
|
|
503
|
+
top_draft.get('draft_id')
|
|
504
|
+
)
|
|
505
|
+
else:
|
|
506
|
+
self.logger.warning(
|
|
507
|
+
"Reminder scheduling returned failure for draft %s: %s",
|
|
508
|
+
top_draft.get('draft_id'),
|
|
509
|
+
schedule_result.get('error')
|
|
510
|
+
)
|
|
511
|
+
|
|
512
|
+
return schedule_result
|
|
513
|
+
|
|
514
|
+
def _handle_close(self, context: Dict[str, Any]) -> Dict[str, Any]:
|
|
515
|
+
"""
|
|
516
|
+
Handle close action - Close outreach when customer feels negative.
|
|
517
|
+
|
|
518
|
+
Args:
|
|
519
|
+
context: Execution context
|
|
520
|
+
|
|
521
|
+
Returns:
|
|
522
|
+
Stage execution result with close status
|
|
523
|
+
"""
|
|
524
|
+
input_data = context.get('input_data', {})
|
|
525
|
+
reason = input_data.get('reason', 'Customer not interested')
|
|
526
|
+
|
|
527
|
+
# Prepare output
|
|
528
|
+
outreach_data = {
|
|
529
|
+
'action': 'close',
|
|
530
|
+
'status': 'outreach_closed',
|
|
531
|
+
'close_reason': reason,
|
|
532
|
+
'closed_timestamp': datetime.now().isoformat(),
|
|
533
|
+
'customer_id': context.get('execution_id')
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
# Save to database
|
|
537
|
+
self.save_stage_result(context, outreach_data)
|
|
538
|
+
|
|
539
|
+
result = self.create_success_result(outreach_data, context)
|
|
540
|
+
# Logging handled by execute_with_timing wrapper
|
|
541
|
+
|
|
542
|
+
return result
|
|
543
|
+
|
|
544
|
+
def _get_customer_data(self, context: Dict[str, Any]) -> Dict[str, Any]:
|
|
545
|
+
"""Get customer data from previous stages or input."""
|
|
546
|
+
# Try to get from stage results first
|
|
547
|
+
stage_results = context.get('stage_results', {})
|
|
548
|
+
if 'data_preparation' in stage_results:
|
|
549
|
+
return stage_results['data_preparation'].get('data', {})
|
|
550
|
+
|
|
551
|
+
# Fallback: get from input_data (for server compatibility)
|
|
552
|
+
input_data = context.get('input_data', {})
|
|
553
|
+
return {
|
|
554
|
+
'companyInfo': input_data.get('companyInfo', {}),
|
|
555
|
+
'primaryContact': input_data.get('primaryContact', {}),
|
|
556
|
+
'painPoints': input_data.get('pain_points', [])
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
def _get_scoring_data(self, context: Dict[str, Any]) -> Dict[str, Any]:
|
|
560
|
+
"""Get scoring data from previous stages or input."""
|
|
561
|
+
# Try to get from stage results first
|
|
562
|
+
stage_results = context.get('stage_results', {})
|
|
563
|
+
if 'lead_scoring' in stage_results:
|
|
564
|
+
return stage_results['lead_scoring'].get('data', {})
|
|
565
|
+
|
|
566
|
+
# Fallback: get from input_data (for server compatibility)
|
|
567
|
+
input_data = context.get('input_data', {})
|
|
568
|
+
return {
|
|
569
|
+
'lead_scoring': input_data.get('lead_scoring', [])
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
def _get_recommended_product(self, scoring_data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
|
573
|
+
"""Get recommended product from scoring data."""
|
|
574
|
+
try:
|
|
575
|
+
# Try to get from analysis first
|
|
576
|
+
analysis = scoring_data.get('analysis', {})
|
|
577
|
+
if 'recommended_product' in analysis:
|
|
578
|
+
return analysis['recommended_product']
|
|
579
|
+
|
|
580
|
+
# Fallback: get highest scoring product
|
|
581
|
+
lead_scores = scoring_data.get('lead_scoring', [])
|
|
582
|
+
if lead_scores:
|
|
583
|
+
sorted_scores = sorted(lead_scores, key=lambda x: x.get('total_weighted_score', 0), reverse=True)
|
|
584
|
+
top_score = sorted_scores[0]
|
|
585
|
+
return {
|
|
586
|
+
'product_name': top_score.get('product_name'),
|
|
587
|
+
'product_id': top_score.get('product_id'),
|
|
588
|
+
'score': top_score.get('total_weighted_score')
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
return None
|
|
592
|
+
except Exception as e:
|
|
593
|
+
self.logger.error(f"Failed to get recommended product: {str(e)}")
|
|
594
|
+
return None
|
|
595
|
+
|
|
596
|
+
def _resolve_recipient_identity(
|
|
597
|
+
self,
|
|
598
|
+
customer_data: Dict[str, Any],
|
|
599
|
+
context: Dict[str, Any]
|
|
600
|
+
) -> Dict[str, Optional[str]]:
|
|
601
|
+
"""
|
|
602
|
+
Resolve recipient contact information and derive a safe first name.
|
|
603
|
+
"""
|
|
604
|
+
input_data = context.get('input_data', {}) or {}
|
|
605
|
+
stage_results = context.get('stage_results', {}) or {}
|
|
606
|
+
data_acquisition: Dict[str, Any] = {}
|
|
607
|
+
if isinstance(stage_results, dict):
|
|
608
|
+
data_acquisition = stage_results.get('data_acquisition', {}).get('data', {}) or {}
|
|
609
|
+
|
|
610
|
+
primary_contact = dict(customer_data.get('primaryContact', {}) or {})
|
|
611
|
+
|
|
612
|
+
recipient_email = (
|
|
613
|
+
input_data.get('recipient_address')
|
|
614
|
+
or primary_contact.get('email')
|
|
615
|
+
or primary_contact.get('emailAddress')
|
|
616
|
+
or data_acquisition.get('contact_email')
|
|
617
|
+
or data_acquisition.get('customer_email')
|
|
618
|
+
or input_data.get('customer_email')
|
|
619
|
+
)
|
|
620
|
+
|
|
621
|
+
recipient_name = (
|
|
622
|
+
input_data.get('recipient_name')
|
|
623
|
+
or primary_contact.get('name')
|
|
624
|
+
or primary_contact.get('fullName')
|
|
625
|
+
or data_acquisition.get('contact_name')
|
|
626
|
+
or data_acquisition.get('customer_contact')
|
|
627
|
+
or input_data.get('customer_name')
|
|
628
|
+
)
|
|
629
|
+
|
|
630
|
+
first_name_source = (
|
|
631
|
+
context.get('customer_first_name')
|
|
632
|
+
or input_data.get('customer_first_name')
|
|
633
|
+
or recipient_name
|
|
634
|
+
)
|
|
635
|
+
|
|
636
|
+
first_name = ''
|
|
637
|
+
if isinstance(first_name_source, str) and first_name_source.strip():
|
|
638
|
+
first_name = self._extract_first_name(first_name_source.strip())
|
|
639
|
+
if not first_name and isinstance(recipient_name, str) and recipient_name.strip():
|
|
640
|
+
first_name = self._extract_first_name(recipient_name.strip())
|
|
641
|
+
|
|
642
|
+
if recipient_name and not primary_contact.get('name'):
|
|
643
|
+
primary_contact['name'] = recipient_name
|
|
644
|
+
if recipient_email and not primary_contact.get('email'):
|
|
645
|
+
primary_contact['email'] = recipient_email
|
|
646
|
+
if primary_contact and isinstance(customer_data, dict):
|
|
647
|
+
customer_data['primaryContact'] = primary_contact
|
|
648
|
+
|
|
649
|
+
return {
|
|
650
|
+
'email': recipient_email,
|
|
651
|
+
'full_name': recipient_name,
|
|
652
|
+
'first_name': first_name
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
def _get_auto_interaction_config(self, team_id: str = None) -> Dict[str, Any]:
|
|
656
|
+
"""
|
|
657
|
+
Get auto interaction configuration from team settings.
|
|
658
|
+
|
|
659
|
+
Args:
|
|
660
|
+
team_id: Team ID to get settings for
|
|
661
|
+
|
|
662
|
+
Returns:
|
|
663
|
+
Auto interaction configuration dictionary with from_email, from_name, etc.
|
|
664
|
+
If multiple configs exist, returns the first Email type config.
|
|
665
|
+
"""
|
|
666
|
+
default_config = {
|
|
667
|
+
'from_email': '',
|
|
668
|
+
'from_name': '',
|
|
669
|
+
'from_number': '',
|
|
670
|
+
'tool_type': 'Email',
|
|
671
|
+
'email_cc': '',
|
|
672
|
+
'email_bcc': ''
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
if not team_id:
|
|
676
|
+
return default_config
|
|
677
|
+
|
|
678
|
+
try:
|
|
679
|
+
# Get team settings
|
|
680
|
+
auto_interaction_settings = self.get_team_setting('gs_team_auto_interaction', team_id, [])
|
|
681
|
+
|
|
682
|
+
if not auto_interaction_settings or not isinstance(auto_interaction_settings, list):
|
|
683
|
+
self.logger.debug(f"No auto interaction settings found for team {team_id}, using defaults")
|
|
684
|
+
return default_config
|
|
685
|
+
|
|
686
|
+
# Find Email type configuration (preferred for email sending)
|
|
687
|
+
email_config = None
|
|
688
|
+
for config in auto_interaction_settings:
|
|
689
|
+
if config.get('tool_type') == 'Email':
|
|
690
|
+
email_config = config
|
|
691
|
+
break
|
|
692
|
+
|
|
693
|
+
# If no Email config found, use the first one available
|
|
694
|
+
if not email_config and len(auto_interaction_settings) > 0:
|
|
695
|
+
email_config = auto_interaction_settings[0]
|
|
696
|
+
self.logger.warning(f"No Email tool_type found in auto interaction settings, using first config with tool_type: {email_config.get('tool_type')}")
|
|
697
|
+
|
|
698
|
+
if email_config:
|
|
699
|
+
self.logger.debug(f"Using auto interaction config for team {team_id}: from_name={email_config.get('from_name')}, tool_type={email_config.get('tool_type')}")
|
|
700
|
+
return email_config
|
|
701
|
+
else:
|
|
702
|
+
return default_config
|
|
703
|
+
|
|
704
|
+
except Exception as e:
|
|
705
|
+
self.logger.error(f"Failed to get auto interaction config for team {team_id}: {str(e)}")
|
|
706
|
+
return default_config
|
|
707
|
+
|
|
708
|
+
def _generate_email_drafts(self, customer_data: Dict[str, Any], recommended_product: Dict[str, Any], scoring_data: Dict[str, Any], context: Dict[str, Any], rep_profile: Optional[Dict[str, Any]] = None) -> List[Dict[str, Any]]:
|
|
709
|
+
"""Generate multiple personalized email drafts using LLM."""
|
|
710
|
+
if self.is_dry_run():
|
|
711
|
+
return self._get_mock_email_drafts(customer_data, recommended_product, context)
|
|
712
|
+
|
|
713
|
+
input_data = context.get('input_data', {}) or {}
|
|
714
|
+
rep_profile = rep_profile or {}
|
|
715
|
+
recipient_identity = self._resolve_recipient_identity(customer_data, context)
|
|
716
|
+
if recipient_identity.get('first_name') and not context.get('customer_first_name'):
|
|
717
|
+
context['customer_first_name'] = recipient_identity['first_name']
|
|
718
|
+
context.setdefault('_recipient_identity', recipient_identity)
|
|
719
|
+
if rep_profile:
|
|
720
|
+
primary_name = rep_profile.get('name')
|
|
721
|
+
if primary_name:
|
|
722
|
+
input_data['staff_name'] = primary_name
|
|
723
|
+
self.config['staff_name'] = primary_name
|
|
724
|
+
if rep_profile.get('email'):
|
|
725
|
+
input_data.setdefault('staff_email', rep_profile.get('email'))
|
|
726
|
+
if rep_profile.get('phone') or rep_profile.get('primary_phone'):
|
|
727
|
+
input_data.setdefault('staff_phone', rep_profile.get('phone') or rep_profile.get('primary_phone'))
|
|
728
|
+
if rep_profile.get('position'):
|
|
729
|
+
input_data.setdefault('staff_title', rep_profile.get('position'))
|
|
730
|
+
if rep_profile.get('website'):
|
|
731
|
+
input_data.setdefault('staff_website', rep_profile.get('website'))
|
|
732
|
+
|
|
733
|
+
company_info = customer_data.get('companyInfo', {}) or {}
|
|
734
|
+
contact_info = customer_data.get('primaryContact', {}) or {}
|
|
735
|
+
pain_points = customer_data.get('painPoints', [])
|
|
736
|
+
|
|
737
|
+
prompt_drafts = self._generate_email_drafts_from_prompt(
|
|
738
|
+
customer_data,
|
|
739
|
+
recommended_product,
|
|
740
|
+
scoring_data,
|
|
741
|
+
context
|
|
742
|
+
)
|
|
743
|
+
if not prompt_drafts:
|
|
744
|
+
raise RuntimeError(
|
|
745
|
+
"Email generation prompt template returned no drafts. "
|
|
746
|
+
"Ensure fusesell_data/config/prompts.json contains initial_outreach.email_generation and is accessible via data_dir."
|
|
747
|
+
)
|
|
748
|
+
|
|
749
|
+
self.logger.info("Generated %s email drafts successfully", len(prompt_drafts))
|
|
750
|
+
return prompt_drafts
|
|
751
|
+
|
|
752
|
+
def _generate_email_drafts_from_prompt(self, customer_data: Dict[str, Any], recommended_product: Dict[str, Any], scoring_data: Dict[str, Any], context: Dict[str, Any], template_files: Optional[List[str]] = None) -> List[Dict[str, Any]]:
|
|
753
|
+
"""
|
|
754
|
+
Attempt to generate drafts using configured prompt template.
|
|
755
|
+
|
|
756
|
+
Args:
|
|
757
|
+
customer_data: Customer information
|
|
758
|
+
recommended_product: Recommended product details
|
|
759
|
+
scoring_data: Lead scoring data
|
|
760
|
+
context: Execution context
|
|
761
|
+
template_files: Optional list of template file paths to use as examples/templates
|
|
762
|
+
|
|
763
|
+
Returns:
|
|
764
|
+
List of generated email drafts
|
|
765
|
+
"""
|
|
766
|
+
# Check team settings for template configuration
|
|
767
|
+
team_id = self.config.get('team_id')
|
|
768
|
+
team_settings = None
|
|
769
|
+
use_team_templates = False
|
|
770
|
+
|
|
771
|
+
if team_id:
|
|
772
|
+
team_settings = self.get_team_setting('gs_team_initial_outreach', team_id, None)
|
|
773
|
+
if team_settings and isinstance(team_settings, dict):
|
|
774
|
+
# Check if fewshots mode is enabled
|
|
775
|
+
if team_settings.get('fewshots', False):
|
|
776
|
+
use_team_templates = True
|
|
777
|
+
# Use template files from team settings if not provided as argument
|
|
778
|
+
if not template_files:
|
|
779
|
+
template_files = team_settings.get('fewshots_location', [])
|
|
780
|
+
|
|
781
|
+
self.logger.info(f"Using team template configuration: fewshots={use_team_templates}, strict_follow={team_settings.get('fewshots_strict_follow', False)}, templates={len(template_files) if template_files else 0}")
|
|
782
|
+
|
|
783
|
+
# Get base prompt template
|
|
784
|
+
if use_team_templates and team_settings:
|
|
785
|
+
# Use custom prompt from team settings
|
|
786
|
+
prompt_template = team_settings.get('prompt', '')
|
|
787
|
+
if not prompt_template:
|
|
788
|
+
# Fallback to default
|
|
789
|
+
prompt_template = self.get_prompt_template('email_generation')
|
|
790
|
+
else:
|
|
791
|
+
# Use default prompt template
|
|
792
|
+
prompt_template = self.get_prompt_template('email_generation')
|
|
793
|
+
|
|
794
|
+
if not prompt_template or not prompt_template.strip():
|
|
795
|
+
raise RuntimeError(
|
|
796
|
+
"Email generation prompt template missing for initial_outreach.email_generation. "
|
|
797
|
+
"Provide it in prompts.json under data_dir/config."
|
|
798
|
+
)
|
|
799
|
+
|
|
800
|
+
# Load template examples if provided
|
|
801
|
+
template_examples = []
|
|
802
|
+
if template_files:
|
|
803
|
+
template_examples = self._load_template_files(template_files)
|
|
804
|
+
self.logger.info(f"Loaded {len(template_examples)} template examples from {len(template_files)} files")
|
|
805
|
+
|
|
806
|
+
# Build the final prompt based on mode
|
|
807
|
+
if use_team_templates and team_settings:
|
|
808
|
+
is_strict_mode = team_settings.get('fewshots_strict_follow', False)
|
|
809
|
+
prompt = self._build_template_based_prompt(
|
|
810
|
+
base_prompt=prompt_template,
|
|
811
|
+
template_examples=template_examples,
|
|
812
|
+
customer_data=customer_data,
|
|
813
|
+
recommended_product=recommended_product,
|
|
814
|
+
scoring_data=scoring_data,
|
|
815
|
+
context=context,
|
|
816
|
+
team_settings=team_settings,
|
|
817
|
+
strict_mode=is_strict_mode
|
|
818
|
+
)
|
|
819
|
+
else:
|
|
820
|
+
# Standard prompt generation (backward compatible)
|
|
821
|
+
prompt = self._prepare_email_generation_prompt(
|
|
822
|
+
prompt_template,
|
|
823
|
+
customer_data,
|
|
824
|
+
recommended_product,
|
|
825
|
+
scoring_data,
|
|
826
|
+
context
|
|
827
|
+
)
|
|
828
|
+
|
|
829
|
+
if not prompt or not prompt.strip():
|
|
830
|
+
raise RuntimeError('Email generation prompt resolved to empty content after placeholder replacement')
|
|
831
|
+
|
|
832
|
+
temperature = self.get_stage_config('email_generation_temperature', 0.35)
|
|
833
|
+
max_tokens = self.get_stage_config('email_generation_max_tokens', 3200)
|
|
834
|
+
|
|
835
|
+
response = self.call_llm(
|
|
836
|
+
prompt=prompt,
|
|
837
|
+
temperature=temperature,
|
|
838
|
+
max_tokens=max_tokens
|
|
839
|
+
)
|
|
840
|
+
|
|
841
|
+
parsed_entries = self._parse_prompt_response(response)
|
|
842
|
+
|
|
843
|
+
drafts: List[Dict[str, Any]] = []
|
|
844
|
+
for entry in parsed_entries:
|
|
845
|
+
normalized = self._normalize_prompt_draft_entry(entry, customer_data, recommended_product, context)
|
|
846
|
+
if normalized:
|
|
847
|
+
drafts.append(normalized)
|
|
848
|
+
|
|
849
|
+
if not drafts:
|
|
850
|
+
raise RuntimeError('Prompt-based email generation returned no usable drafts')
|
|
851
|
+
|
|
852
|
+
valid_priority = all(
|
|
853
|
+
isinstance(d.get('priority_order'), int) and d['priority_order'] > 0
|
|
854
|
+
for d in drafts
|
|
855
|
+
)
|
|
856
|
+
|
|
857
|
+
if valid_priority:
|
|
858
|
+
drafts.sort(key=lambda d: d['priority_order'])
|
|
859
|
+
for draft in drafts:
|
|
860
|
+
draft.setdefault('metadata', {})['priority_order'] = draft['priority_order']
|
|
861
|
+
else:
|
|
862
|
+
for idx, draft in enumerate(drafts, start=1):
|
|
863
|
+
draft['priority_order'] = idx
|
|
864
|
+
draft.setdefault('metadata', {})['priority_order'] = idx
|
|
865
|
+
|
|
866
|
+
return drafts
|
|
867
|
+
|
|
868
|
+
def _parse_prompt_response(self, response: str) -> List[Dict[str, Any]]:
|
|
869
|
+
"""Parse LLM response produced by prompt template."""
|
|
870
|
+
cleaned = self._strip_code_fences(response)
|
|
871
|
+
parsed = self._extract_json_array(cleaned)
|
|
872
|
+
|
|
873
|
+
if isinstance(parsed, dict):
|
|
874
|
+
for key in ('emails', 'drafts', 'data', 'results'):
|
|
875
|
+
value = parsed.get(key)
|
|
876
|
+
if isinstance(value, list):
|
|
877
|
+
parsed = value
|
|
878
|
+
break
|
|
879
|
+
else:
|
|
880
|
+
raise ValueError('Prompt response JSON object does not contain an email list')
|
|
881
|
+
|
|
882
|
+
if not isinstance(parsed, list):
|
|
883
|
+
raise ValueError('Prompt response is not a list of drafts')
|
|
884
|
+
|
|
885
|
+
return parsed
|
|
886
|
+
|
|
887
|
+
def _prepare_email_generation_prompt(self, template: str, customer_data: Dict[str, Any], recommended_product: Dict[str, Any], scoring_data: Dict[str, Any], context: Dict[str, Any]) -> str:
|
|
888
|
+
replacements = self._build_prompt_replacements(
|
|
889
|
+
customer_data,
|
|
890
|
+
recommended_product,
|
|
891
|
+
scoring_data,
|
|
892
|
+
context
|
|
893
|
+
)
|
|
894
|
+
|
|
895
|
+
prompt = template
|
|
896
|
+
for placeholder, value in replacements.items():
|
|
897
|
+
prompt = prompt.replace(placeholder, value)
|
|
898
|
+
|
|
899
|
+
return prompt
|
|
900
|
+
|
|
901
|
+
def _build_prompt_replacements(self, customer_data: Dict[str, Any], recommended_product: Dict[str, Any], scoring_data: Dict[str, Any], context: Dict[str, Any]) -> Dict[str, str]:
|
|
902
|
+
input_data = context.get('input_data', {})
|
|
903
|
+
company_info = customer_data.get('companyInfo', {}) or {}
|
|
904
|
+
contact_info = dict(customer_data.get('primaryContact', {}) or {})
|
|
905
|
+
stage_results = context.get('stage_results', {}) or {}
|
|
906
|
+
data_acquisition = {}
|
|
907
|
+
if isinstance(stage_results, dict):
|
|
908
|
+
data_acquisition = stage_results.get('data_acquisition', {}).get('data', {}) or {}
|
|
909
|
+
if not contact_info.get('name'):
|
|
910
|
+
fallback_contact_name = (
|
|
911
|
+
data_acquisition.get('contact_name')
|
|
912
|
+
or data_acquisition.get('customer_contact')
|
|
913
|
+
or input_data.get('recipient_name')
|
|
914
|
+
or input_data.get('customer_name')
|
|
915
|
+
)
|
|
916
|
+
if fallback_contact_name:
|
|
917
|
+
contact_info['name'] = fallback_contact_name
|
|
918
|
+
if not contact_info.get('email'):
|
|
919
|
+
fallback_email = (
|
|
920
|
+
data_acquisition.get('customer_email')
|
|
921
|
+
or data_acquisition.get('contact_email')
|
|
922
|
+
or input_data.get('recipient_address')
|
|
923
|
+
or input_data.get('customer_email')
|
|
924
|
+
)
|
|
925
|
+
if fallback_email:
|
|
926
|
+
contact_info['email'] = fallback_email
|
|
927
|
+
customer_data = dict(customer_data)
|
|
928
|
+
customer_data['primaryContact'] = contact_info
|
|
929
|
+
language = input_data.get('language') or company_info.get('language') or 'English'
|
|
930
|
+
contact_name = contact_info.get('name') or input_data.get('customer_name') or input_data.get('recipient_name') or 'there'
|
|
931
|
+
company_name = company_info.get('name') or input_data.get('company_name') or 'the company'
|
|
932
|
+
staff_name = input_data.get('staff_name') or input_data.get('sender_name') or 'Sales Team'
|
|
933
|
+
org_name = input_data.get('org_name') or 'Our Company'
|
|
934
|
+
selected_product_name = recommended_product.get('product_name') if recommended_product else None
|
|
935
|
+
language_lower = language.lower() if isinstance(language, str) else ''
|
|
936
|
+
name_parts = contact_name.split() if isinstance(contact_name, str) else []
|
|
937
|
+
if name_parts:
|
|
938
|
+
if language_lower in ('vietnamese', 'vi'):
|
|
939
|
+
first_name = name_parts[-1]
|
|
940
|
+
else:
|
|
941
|
+
first_name = name_parts[0]
|
|
942
|
+
else:
|
|
943
|
+
first_name = contact_name or ''
|
|
944
|
+
context.setdefault('customer_first_name', first_name or contact_name or '')
|
|
945
|
+
|
|
946
|
+
action = input_data.get('action', 'draft_write')
|
|
947
|
+
action_labels = {
|
|
948
|
+
'draft_write': 'email drafts',
|
|
949
|
+
'draft_rewrite': 'email rewrites',
|
|
950
|
+
'send': 'email sends',
|
|
951
|
+
'close': 'email workflow'
|
|
952
|
+
}
|
|
953
|
+
action_type = action_labels.get(action, action.replace('_', ' '))
|
|
954
|
+
|
|
955
|
+
company_summary = self._build_company_info_summary(
|
|
956
|
+
company_info,
|
|
957
|
+
contact_info,
|
|
958
|
+
customer_data.get('painPoints', []),
|
|
959
|
+
scoring_data
|
|
960
|
+
)
|
|
961
|
+
product_summary = self._build_product_info_summary(recommended_product)
|
|
962
|
+
first_name_guide = self._build_first_name_guide(language, contact_name)
|
|
963
|
+
|
|
964
|
+
replacements = {
|
|
965
|
+
'##action_type##': action_type,
|
|
966
|
+
'##language##': language.title() if isinstance(language, str) else 'English',
|
|
967
|
+
'##customer_name##': contact_name,
|
|
968
|
+
'##company_name##': company_name,
|
|
969
|
+
'##staff_name##': staff_name,
|
|
970
|
+
'##org_name##': org_name,
|
|
971
|
+
'##first_name_guide##': first_name_guide,
|
|
972
|
+
'##customer_first_name##': first_name or contact_name,
|
|
973
|
+
'##selected_product##': selected_product_name or 'our solution',
|
|
974
|
+
'##company_info##': company_summary,
|
|
975
|
+
'##selected_product_info##': product_summary
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
return {key: (value if value is not None else '') for key, value in replacements.items()}
|
|
979
|
+
|
|
980
|
+
def _load_template_files(self, template_file_paths: List[str]) -> List[str]:
|
|
981
|
+
"""
|
|
982
|
+
Load template files from provided file paths.
|
|
983
|
+
|
|
984
|
+
Args:
|
|
985
|
+
template_file_paths: List of file paths to load templates from
|
|
986
|
+
|
|
987
|
+
Returns:
|
|
988
|
+
List of template contents
|
|
989
|
+
"""
|
|
990
|
+
import os
|
|
991
|
+
templates = []
|
|
992
|
+
|
|
993
|
+
for file_path in template_file_paths:
|
|
994
|
+
try:
|
|
995
|
+
# Handle both absolute and relative paths
|
|
996
|
+
# If relative, assume it's relative to data_dir
|
|
997
|
+
if not os.path.isabs(file_path):
|
|
998
|
+
data_dir = self.config.get('data_dir', './fusesell_data')
|
|
999
|
+
file_path = os.path.join(data_dir, file_path)
|
|
1000
|
+
|
|
1001
|
+
# Check if file exists
|
|
1002
|
+
if not os.path.exists(file_path):
|
|
1003
|
+
self.logger.warning(f"Template file not found: {file_path}")
|
|
1004
|
+
continue
|
|
1005
|
+
|
|
1006
|
+
# Read the template file
|
|
1007
|
+
with open(file_path, 'r', encoding='utf-8') as f:
|
|
1008
|
+
template_content = f.read()
|
|
1009
|
+
if template_content.strip():
|
|
1010
|
+
templates.append(template_content.strip())
|
|
1011
|
+
self.logger.debug(f"Loaded template from: {file_path}")
|
|
1012
|
+
else:
|
|
1013
|
+
self.logger.warning(f"Template file is empty: {file_path}")
|
|
1014
|
+
|
|
1015
|
+
except Exception as e:
|
|
1016
|
+
self.logger.error(f"Failed to load template file {file_path}: {str(e)}")
|
|
1017
|
+
continue
|
|
1018
|
+
|
|
1019
|
+
return templates
|
|
1020
|
+
|
|
1021
|
+
def _build_template_based_prompt(
|
|
1022
|
+
self,
|
|
1023
|
+
base_prompt: str,
|
|
1024
|
+
template_examples: List[str],
|
|
1025
|
+
customer_data: Dict[str, Any],
|
|
1026
|
+
recommended_product: Dict[str, Any],
|
|
1027
|
+
scoring_data: Dict[str, Any],
|
|
1028
|
+
context: Dict[str, Any],
|
|
1029
|
+
team_settings: Dict[str, Any],
|
|
1030
|
+
strict_mode: bool
|
|
1031
|
+
) -> str:
|
|
1032
|
+
"""
|
|
1033
|
+
Build prompt with template examples based on configuration mode.
|
|
1034
|
+
|
|
1035
|
+
Args:
|
|
1036
|
+
base_prompt: Base prompt template
|
|
1037
|
+
template_examples: List of template example contents
|
|
1038
|
+
customer_data: Customer information
|
|
1039
|
+
recommended_product: Recommended product details
|
|
1040
|
+
scoring_data: Lead scoring data
|
|
1041
|
+
context: Execution context
|
|
1042
|
+
team_settings: Team configuration settings
|
|
1043
|
+
strict_mode: Whether to use strict template mode
|
|
1044
|
+
|
|
1045
|
+
Returns:
|
|
1046
|
+
Final prompt string with templates incorporated
|
|
1047
|
+
"""
|
|
1048
|
+
# Get placeholder replacements
|
|
1049
|
+
replacements = self._build_prompt_replacements(
|
|
1050
|
+
customer_data,
|
|
1051
|
+
recommended_product,
|
|
1052
|
+
scoring_data,
|
|
1053
|
+
context
|
|
1054
|
+
)
|
|
1055
|
+
|
|
1056
|
+
# Replace placeholders in base prompt
|
|
1057
|
+
prompt = base_prompt
|
|
1058
|
+
for placeholder, value in replacements.items():
|
|
1059
|
+
prompt = prompt.replace(placeholder, value)
|
|
1060
|
+
|
|
1061
|
+
# Add template-specific instructions
|
|
1062
|
+
prompt_in_template = team_settings.get('prompt_in_template', '')
|
|
1063
|
+
|
|
1064
|
+
if strict_mode:
|
|
1065
|
+
# Strict Template Mode: Use templates as exact examples to mirror
|
|
1066
|
+
if template_examples:
|
|
1067
|
+
self.logger.info("Using STRICT template mode - templates will be mirrored exactly")
|
|
1068
|
+
|
|
1069
|
+
examples_section = "\n\n# TEMPLATE EXAMPLES TO MIRROR EXACTLY\n\n"
|
|
1070
|
+
examples_section += "Mirror the EXACT CONTENT and STRUCTURE of these templates. Only replace placeholders with customer-specific information.\n\n"
|
|
1071
|
+
|
|
1072
|
+
for i, example in enumerate(template_examples, 1):
|
|
1073
|
+
examples_section += f"## Example Template {i}:\n```\n{example}\n```\n\n"
|
|
1074
|
+
|
|
1075
|
+
# Add strict instructions
|
|
1076
|
+
if prompt_in_template:
|
|
1077
|
+
examples_section += f"\n# STRICT INSTRUCTIONS:\n{prompt_in_template}\n\n"
|
|
1078
|
+
else:
|
|
1079
|
+
examples_section += "\n# STRICT INSTRUCTIONS:\n"
|
|
1080
|
+
examples_section += "- Mirror the EXACT CONTENT of provided examples with ZERO wording changes\n"
|
|
1081
|
+
examples_section += "- Only replace recipient to ##customer_name## from company ##company_name##\n"
|
|
1082
|
+
examples_section += "- NO PLACEHOLDERS OR COMPANY NAMES AS GREETINGS\n"
|
|
1083
|
+
examples_section += "- If recipient name is unclear, use 'Hi' or 'Hello' without a name\n"
|
|
1084
|
+
examples_section += "- Never use company name as a greeting\n"
|
|
1085
|
+
examples_section += "- No hyperlinks/attachments unless in original template\n"
|
|
1086
|
+
examples_section += "- No invented information\n\n"
|
|
1087
|
+
|
|
1088
|
+
prompt = prompt + examples_section
|
|
1089
|
+
else:
|
|
1090
|
+
self.logger.warning("Strict mode enabled but no template examples provided")
|
|
1091
|
+
|
|
1092
|
+
else:
|
|
1093
|
+
# AI Enhancement Mode: Use templates as inspiration
|
|
1094
|
+
if template_examples:
|
|
1095
|
+
self.logger.info("Using AI ENHANCEMENT mode - templates as inspiration")
|
|
1096
|
+
|
|
1097
|
+
examples_section = "\n\n# EXAMPLE TEMPLATES FOR INSPIRATION\n\n"
|
|
1098
|
+
examples_section += "Use these examples as inspiration while applying best practices and customization.\n\n"
|
|
1099
|
+
|
|
1100
|
+
for i, example in enumerate(template_examples, 1):
|
|
1101
|
+
examples_section += f"## Example {i}:\n```\n{example}\n```\n\n"
|
|
1102
|
+
|
|
1103
|
+
# Add enhancement instructions
|
|
1104
|
+
if prompt_in_template:
|
|
1105
|
+
examples_section += f"\n# CUSTOMIZATION GUIDANCE:\n{prompt_in_template}\n\n"
|
|
1106
|
+
else:
|
|
1107
|
+
examples_section += "\n# CUSTOMIZATION GUIDANCE:\n"
|
|
1108
|
+
examples_section += "- Use the provided examples as inspiration\n"
|
|
1109
|
+
examples_section += "- Adapt the tone, style, and structure to fit the customer context\n"
|
|
1110
|
+
examples_section += "- Incorporate best practices for email outreach\n"
|
|
1111
|
+
examples_section += "- Ensure personalization and relevance\n\n"
|
|
1112
|
+
|
|
1113
|
+
prompt = prompt + examples_section
|
|
1114
|
+
|
|
1115
|
+
return prompt
|
|
1116
|
+
|
|
1117
|
+
def _build_company_info_summary(self, company_info: Dict[str, Any], contact_info: Dict[str, Any], pain_points: List[Dict[str, Any]], scoring_data: Dict[str, Any]) -> str:
|
|
1118
|
+
lines: List[str] = []
|
|
1119
|
+
|
|
1120
|
+
if company_info.get('name'):
|
|
1121
|
+
lines.append(f"Company: {company_info.get('name')}")
|
|
1122
|
+
if company_info.get('industry'):
|
|
1123
|
+
lines.append(f"Industry: {company_info.get('industry')}")
|
|
1124
|
+
if company_info.get('size'):
|
|
1125
|
+
lines.append(f"Company size: {company_info.get('size')}")
|
|
1126
|
+
if company_info.get('location'):
|
|
1127
|
+
lines.append(f"Location: {company_info.get('location')}")
|
|
1128
|
+
|
|
1129
|
+
if contact_info.get('name'):
|
|
1130
|
+
title = contact_info.get('title')
|
|
1131
|
+
if title:
|
|
1132
|
+
lines.append(f"Primary contact: {contact_info.get('name')} ({title})")
|
|
1133
|
+
else:
|
|
1134
|
+
lines.append(f"Primary contact: {contact_info.get('name')}")
|
|
1135
|
+
if contact_info.get('email'):
|
|
1136
|
+
lines.append(f"Contact email: {contact_info.get('email')}")
|
|
1137
|
+
|
|
1138
|
+
visible_pain_points = [p for p in pain_points if p]
|
|
1139
|
+
if visible_pain_points:
|
|
1140
|
+
lines.append('Top pain points:')
|
|
1141
|
+
for point in visible_pain_points[:5]:
|
|
1142
|
+
description = str(point.get('description', '')).strip()
|
|
1143
|
+
if not description:
|
|
1144
|
+
continue
|
|
1145
|
+
severity = point.get('severity')
|
|
1146
|
+
severity_text = f" (severity: {severity})" if severity else ''
|
|
1147
|
+
lines.append(f"- {description}{severity_text}")
|
|
1148
|
+
|
|
1149
|
+
lead_scores = scoring_data.get('lead_scoring', []) or []
|
|
1150
|
+
if lead_scores:
|
|
1151
|
+
sorted_scores = sorted(lead_scores, key=lambda item: item.get('total_weighted_score', 0), reverse=True)
|
|
1152
|
+
top_score = sorted_scores[0]
|
|
1153
|
+
product_name = top_score.get('product_name')
|
|
1154
|
+
score_value = top_score.get('total_weighted_score')
|
|
1155
|
+
if product_name:
|
|
1156
|
+
if score_value is not None:
|
|
1157
|
+
lines.append(f"Highest scoring product: {product_name} (score {score_value})")
|
|
1158
|
+
else:
|
|
1159
|
+
lines.append(f"Highest scoring product: {product_name}")
|
|
1160
|
+
|
|
1161
|
+
summary = "\n".join(lines).strip()
|
|
1162
|
+
return summary or 'Company details unavailable.'
|
|
1163
|
+
|
|
1164
|
+
def _build_product_info_summary(self, recommended_product: Optional[Dict[str, Any]]) -> str:
|
|
1165
|
+
if not recommended_product:
|
|
1166
|
+
return "No specific product selected. Focus on aligning our solutions with the customer's pain points."
|
|
1167
|
+
|
|
1168
|
+
lines: List[str] = []
|
|
1169
|
+
name = recommended_product.get('product_name')
|
|
1170
|
+
if name:
|
|
1171
|
+
lines.append(f"Product: {name}")
|
|
1172
|
+
description = recommended_product.get('description')
|
|
1173
|
+
if description:
|
|
1174
|
+
lines.append(f"Description: {description}")
|
|
1175
|
+
benefits = recommended_product.get('key_benefits')
|
|
1176
|
+
if isinstance(benefits, list) and benefits:
|
|
1177
|
+
lines.append('Key benefits: ' + ', '.join(str(b) for b in benefits if b))
|
|
1178
|
+
differentiators = recommended_product.get('differentiators')
|
|
1179
|
+
if isinstance(differentiators, list) and differentiators:
|
|
1180
|
+
lines.append('Differentiators: ' + ', '.join(str(d) for d in differentiators if d))
|
|
1181
|
+
score = recommended_product.get('score')
|
|
1182
|
+
if score is not None:
|
|
1183
|
+
lines.append(f"Lead score: {score}")
|
|
1184
|
+
|
|
1185
|
+
summary = "\n".join(lines).strip()
|
|
1186
|
+
return summary or 'Product details unavailable.'
|
|
1187
|
+
|
|
1188
|
+
def _build_first_name_guide(self, language: str, contact_name: str) -> str:
|
|
1189
|
+
if not language:
|
|
1190
|
+
return ''
|
|
1191
|
+
|
|
1192
|
+
language_lower = language.lower()
|
|
1193
|
+
name_parts = contact_name.split() if contact_name else []
|
|
1194
|
+
if language_lower in ('vietnamese', 'vi'):
|
|
1195
|
+
if not contact_name or contact_name.lower() == 'a person':
|
|
1196
|
+
return "If the recipient's name is unknown, use `anh/chi` in the greeting."
|
|
1197
|
+
vn_name = name_parts[-1] if name_parts else contact_name
|
|
1198
|
+
if vn_name:
|
|
1199
|
+
return f"For Vietnamese recipients, use `anh/chi {vn_name}` in the greeting to keep it respectful. Do not use placeholders or omit the honorific."
|
|
1200
|
+
return "For Vietnamese recipients, use `anh/chi` followed by the recipient's first name in the greeting."
|
|
1201
|
+
|
|
1202
|
+
if name_parts:
|
|
1203
|
+
en_name = name_parts[0]
|
|
1204
|
+
return (
|
|
1205
|
+
f'Use only the recipient\'s first name "{en_name}" in the greeting. '
|
|
1206
|
+
f'Start with "Hi {en_name}," or "Hello {en_name}," and do not use the surname or placeholders.'
|
|
1207
|
+
)
|
|
1208
|
+
return 'If the recipient name is unknown, use a neutral greeting like "Hi there," without placeholders.'
|
|
1209
|
+
|
|
1210
|
+
def _resolve_primary_sales_rep(self, context: Dict[str, Any]) -> Dict[str, Any]:
|
|
1211
|
+
team_id = context.get('input_data', {}).get('team_id') or self.config.get('team_id')
|
|
1212
|
+
if not team_id:
|
|
1213
|
+
return {}
|
|
1214
|
+
reps = self.get_team_setting('gs_team_rep', team_id, [])
|
|
1215
|
+
if not isinstance(reps, list):
|
|
1216
|
+
return {}
|
|
1217
|
+
for rep in reps:
|
|
1218
|
+
if rep and rep.get('is_primary'):
|
|
1219
|
+
return rep
|
|
1220
|
+
return reps[0] if reps else {}
|
|
1221
|
+
|
|
1222
|
+
def _sanitize_email_body(self, html: str, staff_name: str, rep_profile: Dict[str, Any], customer_first_name: str) -> str:
|
|
1223
|
+
if not html:
|
|
1224
|
+
return ''
|
|
1225
|
+
|
|
1226
|
+
replacements = {
|
|
1227
|
+
'[Your Name]': rep_profile.get('name') or staff_name,
|
|
1228
|
+
'[Your Email]': rep_profile.get('email'),
|
|
1229
|
+
'[Your Phone Number]': rep_profile.get('phone') or rep_profile.get('primary_phone'),
|
|
1230
|
+
'[Your Phone]': rep_profile.get('phone') or rep_profile.get('primary_phone'),
|
|
1231
|
+
'[Your Title]': rep_profile.get('position'),
|
|
1232
|
+
'[Your LinkedIn Profile]': rep_profile.get('linkedin') or rep_profile.get('linkedin_profile'),
|
|
1233
|
+
'[Your LinkedIn Profile URL]': rep_profile.get('linkedin') or rep_profile.get('linkedin_profile'),
|
|
1234
|
+
'[Your Website]': rep_profile.get('website'),
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
for placeholder, value in replacements.items():
|
|
1238
|
+
if value:
|
|
1239
|
+
html = html.replace(placeholder, str(value))
|
|
1240
|
+
else:
|
|
1241
|
+
html = html.replace(placeholder, '')
|
|
1242
|
+
|
|
1243
|
+
html = re.sub(r'\[Your[^<\]]+\]?', '', html, flags=re.IGNORECASE)
|
|
1244
|
+
|
|
1245
|
+
if '<p' not in html.lower():
|
|
1246
|
+
lines = [line.strip() for line in html.splitlines() if line.strip()]
|
|
1247
|
+
if lines:
|
|
1248
|
+
html = ''.join(f'<p>{line}</p>' for line in lines)
|
|
1249
|
+
|
|
1250
|
+
html = self._remove_tagline_block(html)
|
|
1251
|
+
html = self._deduplicate_greeting(html, customer_first_name or '')
|
|
1252
|
+
html = re.sub(r'(<p>\s*</p>)+', '', html, flags=re.IGNORECASE)
|
|
1253
|
+
return html
|
|
1254
|
+
|
|
1255
|
+
def _remove_tagline_block(self, html: str) -> str:
|
|
1256
|
+
if not html:
|
|
1257
|
+
return ''
|
|
1258
|
+
|
|
1259
|
+
tagline_pattern = re.compile(r'^\s*(tag\s*line|tagline)\b[:\-]?', re.IGNORECASE)
|
|
1260
|
+
paragraphs = re.findall(r'(<p.*?>.*?</p>)', html, flags=re.IGNORECASE | re.DOTALL)
|
|
1261
|
+
if paragraphs:
|
|
1262
|
+
cleaned: List[str] = []
|
|
1263
|
+
removed = False
|
|
1264
|
+
for para in paragraphs:
|
|
1265
|
+
text = self._strip_html_tags(para)
|
|
1266
|
+
if tagline_pattern.match(text):
|
|
1267
|
+
removed = True
|
|
1268
|
+
continue
|
|
1269
|
+
cleaned.append(para)
|
|
1270
|
+
|
|
1271
|
+
if removed:
|
|
1272
|
+
remainder = re.sub(r'(<p.*?>.*?</p>)', '__PARA__', html, flags=re.IGNORECASE | re.DOTALL)
|
|
1273
|
+
rebuilt = ''
|
|
1274
|
+
idx = 0
|
|
1275
|
+
for segment in remainder.split('__PARA__'):
|
|
1276
|
+
rebuilt += segment
|
|
1277
|
+
if idx < len(cleaned):
|
|
1278
|
+
rebuilt += cleaned[idx]
|
|
1279
|
+
idx += 1
|
|
1280
|
+
if idx < len(cleaned):
|
|
1281
|
+
rebuilt += ''.join(cleaned[idx:])
|
|
1282
|
+
return rebuilt
|
|
1283
|
+
|
|
1284
|
+
lines = html.splitlines()
|
|
1285
|
+
filtered = [line for line in lines if not tagline_pattern.match(line)]
|
|
1286
|
+
return '\n'.join(filtered) if len(filtered) != len(lines) else html
|
|
1287
|
+
|
|
1288
|
+
def _deduplicate_greeting(self, html: str, customer_first_name: str) -> str:
|
|
1289
|
+
paragraphs = re.findall(r'(<p.*?>.*?</p>)', html, flags=re.IGNORECASE | re.DOTALL)
|
|
1290
|
+
if not paragraphs:
|
|
1291
|
+
return html
|
|
1292
|
+
|
|
1293
|
+
greeting_seen = False
|
|
1294
|
+
cleaned: List[str] = []
|
|
1295
|
+
for para in paragraphs:
|
|
1296
|
+
text = self._strip_html_tags(para).strip()
|
|
1297
|
+
normalized_para = para
|
|
1298
|
+
if self._looks_like_greeting(text):
|
|
1299
|
+
normalized_para = self._standardize_greeting_paragraph(para, customer_first_name)
|
|
1300
|
+
if greeting_seen:
|
|
1301
|
+
continue
|
|
1302
|
+
greeting_seen = True
|
|
1303
|
+
cleaned.append(normalized_para)
|
|
1304
|
+
|
|
1305
|
+
remainder = re.sub(r'(<p.*?>.*?</p>)', '__PARA__', html, flags=re.IGNORECASE | re.DOTALL)
|
|
1306
|
+
rebuilt = ''
|
|
1307
|
+
idx = 0
|
|
1308
|
+
for segment in remainder.split('__PARA__'):
|
|
1309
|
+
rebuilt += segment
|
|
1310
|
+
if idx < len(cleaned):
|
|
1311
|
+
rebuilt += cleaned[idx]
|
|
1312
|
+
idx += 1
|
|
1313
|
+
if idx < len(cleaned):
|
|
1314
|
+
rebuilt += ''.join(cleaned[idx:])
|
|
1315
|
+
return rebuilt
|
|
1316
|
+
|
|
1317
|
+
def _looks_like_greeting(self, text: str) -> bool:
|
|
1318
|
+
lowered = text.lower().replace('\xa0', ' ').strip()
|
|
1319
|
+
return lowered.startswith(('hi ', 'hello ', 'dear '))
|
|
1320
|
+
|
|
1321
|
+
def _standardize_greeting_paragraph(self, paragraph_html: str, customer_first_name: str) -> str:
|
|
1322
|
+
text = self._strip_html_tags(paragraph_html).strip()
|
|
1323
|
+
lowered = text.lower()
|
|
1324
|
+
first_word = next((candidate.title() for candidate in ('dear', 'hello', 'hi') if lowered.startswith(candidate)), 'Hi')
|
|
1325
|
+
|
|
1326
|
+
if customer_first_name:
|
|
1327
|
+
greeting = f"{first_word} {customer_first_name},"
|
|
1328
|
+
else:
|
|
1329
|
+
greeting = f"{first_word} there,"
|
|
1330
|
+
|
|
1331
|
+
remainder = ''
|
|
1332
|
+
match = re.match(r' *(hi|hello|dear)\b[^,]*,(.*)', text, flags=re.IGNORECASE | re.DOTALL)
|
|
1333
|
+
if match:
|
|
1334
|
+
remainder = match.group(2).lstrip()
|
|
1335
|
+
elif lowered.startswith(('hi', 'hello', 'dear')):
|
|
1336
|
+
parts = text.split(',', 1)
|
|
1337
|
+
if len(parts) > 1:
|
|
1338
|
+
remainder = parts[1].lstrip()
|
|
1339
|
+
else:
|
|
1340
|
+
remainder = text[len(text.split(' ', 1)[0]):].lstrip()
|
|
1341
|
+
|
|
1342
|
+
if remainder:
|
|
1343
|
+
sanitized_text = f"{greeting} {remainder}".strip()
|
|
1344
|
+
else:
|
|
1345
|
+
sanitized_text = greeting
|
|
1346
|
+
|
|
1347
|
+
return re.sub(
|
|
1348
|
+
r'(<p.*?>).*?(</p>)',
|
|
1349
|
+
lambda m: f"{m.group(1)}{sanitized_text}{m.group(2)}",
|
|
1350
|
+
paragraph_html,
|
|
1351
|
+
count=1,
|
|
1352
|
+
flags=re.IGNORECASE | re.DOTALL
|
|
1353
|
+
)
|
|
1354
|
+
|
|
1355
|
+
def _extract_first_name(self, full_name: str) -> str:
|
|
1356
|
+
if not full_name:
|
|
1357
|
+
return ''
|
|
1358
|
+
parts = full_name.strip().split()
|
|
1359
|
+
return parts[-1] if parts else full_name
|
|
1360
|
+
|
|
1361
|
+
def _strip_code_fences(self, text: str) -> str:
|
|
1362
|
+
if not text:
|
|
1363
|
+
return ''
|
|
1364
|
+
cleaned = text.strip()
|
|
1365
|
+
if cleaned.startswith('```'):
|
|
1366
|
+
lines = cleaned.splitlines()
|
|
1367
|
+
normalized = '\n'.join(lines[1:]) if len(lines) > 1 else ''
|
|
1368
|
+
if '```' in normalized:
|
|
1369
|
+
normalized = normalized.rsplit('```', 1)[0]
|
|
1370
|
+
cleaned = normalized
|
|
1371
|
+
return cleaned.strip()
|
|
1372
|
+
|
|
1373
|
+
def _extract_json_array(self, text: str) -> Any:
|
|
1374
|
+
try:
|
|
1375
|
+
return json.loads(text)
|
|
1376
|
+
except json.JSONDecodeError:
|
|
1377
|
+
pass
|
|
1378
|
+
|
|
1379
|
+
start = text.find('[')
|
|
1380
|
+
end = text.rfind(']') + 1
|
|
1381
|
+
if start != -1 and end > start:
|
|
1382
|
+
snippet = text[start:end]
|
|
1383
|
+
try:
|
|
1384
|
+
return json.loads(snippet)
|
|
1385
|
+
except json.JSONDecodeError:
|
|
1386
|
+
pass
|
|
1387
|
+
|
|
1388
|
+
start = text.find('{')
|
|
1389
|
+
end = text.rfind('}') + 1
|
|
1390
|
+
if start != -1 and end > start:
|
|
1391
|
+
snippet = text[start:end]
|
|
1392
|
+
try:
|
|
1393
|
+
return json.loads(snippet)
|
|
1394
|
+
except json.JSONDecodeError:
|
|
1395
|
+
pass
|
|
1396
|
+
|
|
1397
|
+
raise ValueError('Could not parse JSON from prompt response')
|
|
1398
|
+
|
|
1399
|
+
def _normalize_prompt_draft_entry(self, entry: Any, customer_data: Dict[str, Any], recommended_product: Dict[str, Any], context: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
|
1400
|
+
if not isinstance(entry, dict):
|
|
1401
|
+
self.logger.debug('Skipping prompt entry because it is not a dict: %s', entry)
|
|
1402
|
+
return None
|
|
1403
|
+
|
|
1404
|
+
email_body = entry.get('body') or entry.get('content') or ''
|
|
1405
|
+
if isinstance(email_body, dict):
|
|
1406
|
+
email_body = email_body.get('html') or email_body.get('text') or email_body.get('content') or ''
|
|
1407
|
+
email_body = str(email_body).strip()
|
|
1408
|
+
if not email_body:
|
|
1409
|
+
self.logger.debug('Skipping prompt entry because email body is empty: %s', entry)
|
|
1410
|
+
return None
|
|
1411
|
+
|
|
1412
|
+
recipient_identity = self._resolve_recipient_identity(customer_data, context)
|
|
1413
|
+
if recipient_identity.get('first_name') and not context.get('customer_first_name'):
|
|
1414
|
+
context['customer_first_name'] = recipient_identity['first_name']
|
|
1415
|
+
|
|
1416
|
+
subject = entry.get('subject')
|
|
1417
|
+
if isinstance(subject, list):
|
|
1418
|
+
subject = subject[0] if subject else ''
|
|
1419
|
+
subject = str(subject).strip() if subject else ''
|
|
1420
|
+
|
|
1421
|
+
subject_alternatives: List[str] = []
|
|
1422
|
+
for key in ('subject_alternatives', 'subject_variations', 'subject_variants', 'alternative_subjects', 'subjects'):
|
|
1423
|
+
variants = entry.get(key)
|
|
1424
|
+
if isinstance(variants, list):
|
|
1425
|
+
subject_alternatives = [str(item).strip() for item in variants if str(item).strip()]
|
|
1426
|
+
if subject_alternatives:
|
|
1427
|
+
break
|
|
1428
|
+
|
|
1429
|
+
if not subject and subject_alternatives:
|
|
1430
|
+
subject = subject_alternatives[0]
|
|
1431
|
+
|
|
1432
|
+
if not subject:
|
|
1433
|
+
company_name = customer_data.get('companyInfo', {}).get('name', 'your organization')
|
|
1434
|
+
subject = f"Opportunity for {company_name}"
|
|
1435
|
+
|
|
1436
|
+
mail_tone = str(entry.get('mail_tone') or entry.get('tone') or 'custom').strip()
|
|
1437
|
+
approach = str(entry.get('approach') or entry.get('strategy') or 'custom').strip()
|
|
1438
|
+
focus = str(entry.get('focus') or entry.get('value_focus') or 'custom_prompt').strip()
|
|
1439
|
+
|
|
1440
|
+
priority_order = entry.get('priority_order')
|
|
1441
|
+
try:
|
|
1442
|
+
priority_order = int(priority_order)
|
|
1443
|
+
if priority_order < 1:
|
|
1444
|
+
raise ValueError
|
|
1445
|
+
except (TypeError, ValueError):
|
|
1446
|
+
priority_order = None
|
|
1447
|
+
|
|
1448
|
+
product_name = entry.get('product_name') or (recommended_product.get('product_name') if recommended_product else None)
|
|
1449
|
+
product_mention = entry.get('product_mention')
|
|
1450
|
+
if isinstance(product_mention, str):
|
|
1451
|
+
product_mention = product_mention.strip().lower() in ('true', 'yes', '1')
|
|
1452
|
+
elif not isinstance(product_mention, bool):
|
|
1453
|
+
product_mention = bool(product_name)
|
|
1454
|
+
|
|
1455
|
+
tags = entry.get('tags', [])
|
|
1456
|
+
if isinstance(tags, str):
|
|
1457
|
+
tags = [tags]
|
|
1458
|
+
tags = [str(tag).strip() for tag in tags if str(tag).strip()]
|
|
1459
|
+
|
|
1460
|
+
call_to_action = self._extract_call_to_action(email_body)
|
|
1461
|
+
personalization_score = self._calculate_personalization_score(email_body, customer_data)
|
|
1462
|
+
message_type = entry.get('message_type') or 'Email'
|
|
1463
|
+
rep_profile = getattr(self, '_active_rep_profile', {}) or {}
|
|
1464
|
+
staff_name = context.get('input_data', {}).get('staff_name') or self.config.get('staff_name', 'Sales Team')
|
|
1465
|
+
first_name = recipient_identity.get('first_name') or context.get('customer_first_name') or context.get('input_data', {}).get('customer_name') or ''
|
|
1466
|
+
email_body = self._sanitize_email_body(email_body, staff_name, rep_profile, first_name)
|
|
1467
|
+
if '<html' not in email_body.lower():
|
|
1468
|
+
email_body = f"<html><body>{email_body}</body></html>"
|
|
1469
|
+
|
|
1470
|
+
metadata = {
|
|
1471
|
+
'customer_company': customer_data.get('companyInfo', {}).get('name', 'Unknown'),
|
|
1472
|
+
'contact_name': customer_data.get('primaryContact', {}).get('name', 'Unknown'),
|
|
1473
|
+
'recipient_email': recipient_identity.get('email'),
|
|
1474
|
+
'recipient_name': recipient_identity.get('full_name'),
|
|
1475
|
+
'email_format': 'html',
|
|
1476
|
+
'recommended_product': product_name or 'Unknown',
|
|
1477
|
+
'generation_method': 'prompt_template',
|
|
1478
|
+
'tags': tags,
|
|
1479
|
+
'message_type': message_type
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
draft_id = entry.get('draft_id') or f"uuid:{str(uuid.uuid4())}"
|
|
1483
|
+
draft_approach = "prompt"
|
|
1484
|
+
draft_type = "initial"
|
|
1485
|
+
|
|
1486
|
+
return {
|
|
1487
|
+
'draft_id': draft_id,
|
|
1488
|
+
'approach': approach,
|
|
1489
|
+
'tone': mail_tone,
|
|
1490
|
+
'focus': focus,
|
|
1491
|
+
'subject': subject,
|
|
1492
|
+
'subject_alternatives': subject_alternatives,
|
|
1493
|
+
'email_body': email_body,
|
|
1494
|
+
'email_format': 'html',
|
|
1495
|
+
'recipient_email': recipient_identity.get('email'),
|
|
1496
|
+
'recipient_name': recipient_identity.get('full_name'),
|
|
1497
|
+
'customer_first_name': recipient_identity.get('first_name'),
|
|
1498
|
+
'call_to_action': call_to_action,
|
|
1499
|
+
'product_mention': product_mention,
|
|
1500
|
+
'product_name': product_name,
|
|
1501
|
+
'priority_order': priority_order if priority_order is not None else 0,
|
|
1502
|
+
'personalization_score': personalization_score,
|
|
1503
|
+
'generated_at': datetime.now().isoformat(),
|
|
1504
|
+
'status': 'draft',
|
|
1505
|
+
'metadata': metadata
|
|
1506
|
+
}
|
|
1507
|
+
|
|
1508
|
+
def _strip_html_tags(self, html: str) -> str:
|
|
1509
|
+
if not html:
|
|
1510
|
+
return ''
|
|
1511
|
+
|
|
1512
|
+
text = re.sub(r'(?i)<br\s*/?>', '\n', html)
|
|
1513
|
+
text = re.sub(r'(?i)</p>', '\n', text)
|
|
1514
|
+
text = re.sub(r'(?i)<li>', '\n- ', text)
|
|
1515
|
+
text = re.sub(r'<[^>]+>', ' ', text)
|
|
1516
|
+
text = re.sub(r'\s+', ' ', text)
|
|
1517
|
+
return text.strip()
|
|
1518
|
+
|
|
1519
|
+
def _generate_single_email_draft(self, customer_data: Dict[str, Any], recommended_product: Dict[str, Any],
|
|
1520
|
+
scoring_data: Dict[str, Any], approach: Dict[str, Any], context: Dict[str, Any]) -> str:
|
|
1521
|
+
"""Generate a single email draft using LLM with specific approach."""
|
|
1522
|
+
try:
|
|
1523
|
+
input_data = context.get('input_data', {})
|
|
1524
|
+
company_info = customer_data.get('companyInfo', {})
|
|
1525
|
+
contact_info = customer_data.get('primaryContact', {})
|
|
1526
|
+
pain_points = customer_data.get('painPoints', [])
|
|
1527
|
+
|
|
1528
|
+
# Prepare context for LLM
|
|
1529
|
+
customer_context = {
|
|
1530
|
+
'company_name': company_info.get('name', 'the company'),
|
|
1531
|
+
'contact_name': contact_info.get('name', 'there'),
|
|
1532
|
+
'contact_title': contact_info.get('title', ''),
|
|
1533
|
+
'industry': company_info.get('industry', 'technology'),
|
|
1534
|
+
'company_size': company_info.get('size', 'unknown'),
|
|
1535
|
+
'main_pain_points': [p.get('description', '') for p in pain_points[:3]],
|
|
1536
|
+
'recommended_product': recommended_product.get('product_name', 'our solution'),
|
|
1537
|
+
'product_benefits': recommended_product.get('key_benefits', []),
|
|
1538
|
+
'sender_name': input_data.get('staff_name', 'Sales Team'),
|
|
1539
|
+
'sender_company': input_data.get('org_name', 'Our Company'),
|
|
1540
|
+
'approach_tone': approach.get('tone', 'professional'),
|
|
1541
|
+
'approach_focus': approach.get('focus', 'business value'),
|
|
1542
|
+
'approach_length': approach.get('length', 'medium')
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
# Create LLM prompt for email generation
|
|
1546
|
+
prompt = self._create_email_generation_prompt(customer_context, approach)
|
|
1547
|
+
|
|
1548
|
+
# Generate email using LLM
|
|
1549
|
+
email_content = self.call_llm(
|
|
1550
|
+
prompt=prompt,
|
|
1551
|
+
temperature=0.7,
|
|
1552
|
+
max_tokens=800
|
|
1553
|
+
)
|
|
1554
|
+
|
|
1555
|
+
# Clean and validate the generated content
|
|
1556
|
+
cleaned_content = self._clean_email_content(email_content, context)
|
|
1557
|
+
|
|
1558
|
+
return cleaned_content
|
|
1559
|
+
|
|
1560
|
+
except Exception as e:
|
|
1561
|
+
self.logger.error("LLM single draft generation failed for approach %s: %s", approach.get('name'), e)
|
|
1562
|
+
raise RuntimeError(f"Failed to generate draft for approach {approach.get('name')}") from e
|
|
1563
|
+
|
|
1564
|
+
def _create_email_generation_prompt(self, customer_context: Dict[str, Any], approach: Dict[str, Any]) -> str:
|
|
1565
|
+
"""Create LLM prompt for email generation."""
|
|
1566
|
+
|
|
1567
|
+
pain_points_text = ""
|
|
1568
|
+
if customer_context['main_pain_points']:
|
|
1569
|
+
pain_points_text = f"Key challenges they face: {', '.join(customer_context['main_pain_points'])}"
|
|
1570
|
+
|
|
1571
|
+
benefits_text = ""
|
|
1572
|
+
if customer_context['product_benefits']:
|
|
1573
|
+
benefits_text = f"Our solution benefits: {', '.join(customer_context['product_benefits'])}"
|
|
1574
|
+
|
|
1575
|
+
prompt = f"""Generate a personalized outreach email with the following specifications:
|
|
1576
|
+
|
|
1577
|
+
CUSTOMER INFORMATION:
|
|
1578
|
+
- Company: {customer_context['company_name']}
|
|
1579
|
+
- Contact: {customer_context['contact_name']} ({customer_context['contact_title']})
|
|
1580
|
+
- Industry: {customer_context['industry']}
|
|
1581
|
+
- Company Size: {customer_context['company_size']}
|
|
1582
|
+
{pain_points_text}
|
|
1583
|
+
|
|
1584
|
+
OUR OFFERING:
|
|
1585
|
+
- Product/Solution: {customer_context['recommended_product']}
|
|
1586
|
+
{benefits_text}
|
|
1587
|
+
|
|
1588
|
+
SENDER INFORMATION:
|
|
1589
|
+
- Sender: {customer_context['sender_name']}
|
|
1590
|
+
- Company: {customer_context['sender_company']}
|
|
1591
|
+
|
|
1592
|
+
EMAIL APPROACH:
|
|
1593
|
+
- Tone: {customer_context['approach_tone']}
|
|
1594
|
+
- Focus: {customer_context['approach_focus']}
|
|
1595
|
+
- Length: {customer_context['approach_length']}
|
|
1596
|
+
|
|
1597
|
+
REQUIREMENTS:
|
|
1598
|
+
1. Write a complete email from greeting to signature
|
|
1599
|
+
2. Personalize based on their company and industry
|
|
1600
|
+
3. Address their specific pain points naturally
|
|
1601
|
+
4. Present our solution as a potential fit
|
|
1602
|
+
5. Include a clear, specific call-to-action
|
|
1603
|
+
6. Keep the tone {customer_context['approach_tone']}
|
|
1604
|
+
7. Focus on {customer_context['approach_focus']}
|
|
1605
|
+
8. Make it {customer_context['approach_length']} in length
|
|
1606
|
+
|
|
1607
|
+
Generate only the email content, no additional commentary:"""
|
|
1608
|
+
|
|
1609
|
+
return prompt
|
|
1610
|
+
|
|
1611
|
+
def _generate_subject_lines(self, customer_data: Dict[str, Any], recommended_product: Dict[str, Any],
|
|
1612
|
+
approach: Dict[str, Any], context: Dict[str, Any]) -> List[str]:
|
|
1613
|
+
"""Generate multiple subject line variations using LLM."""
|
|
1614
|
+
try:
|
|
1615
|
+
input_data = context.get('input_data', {})
|
|
1616
|
+
company_info = customer_data.get('companyInfo', {})
|
|
1617
|
+
|
|
1618
|
+
prompt = f"""Generate 4 compelling email subject lines for an outreach email to {company_info.get('name', 'a company')} in the {company_info.get('industry', 'technology')} industry.
|
|
1619
|
+
|
|
1620
|
+
CONTEXT:
|
|
1621
|
+
- Target Company: {company_info.get('name', 'the company')}
|
|
1622
|
+
- Industry: {company_info.get('industry', 'technology')}
|
|
1623
|
+
- Our Solution: {recommended_product.get('product_name', 'our solution')}
|
|
1624
|
+
- Sender Company: {input_data.get('org_name', 'our company')}
|
|
1625
|
+
- Approach Tone: {approach.get('tone', 'professional')}
|
|
1626
|
+
|
|
1627
|
+
REQUIREMENTS:
|
|
1628
|
+
1. Keep subject lines under 50 characters
|
|
1629
|
+
2. Make them personalized and specific
|
|
1630
|
+
3. Create urgency or curiosity
|
|
1631
|
+
4. Avoid spam trigger words
|
|
1632
|
+
5. Match the {approach.get('tone', 'professional')} tone
|
|
1633
|
+
|
|
1634
|
+
Generate 4 subject lines, one per line, no numbering or bullets:"""
|
|
1635
|
+
|
|
1636
|
+
response = self.call_llm(
|
|
1637
|
+
prompt=prompt,
|
|
1638
|
+
temperature=0.8,
|
|
1639
|
+
max_tokens=200
|
|
1640
|
+
)
|
|
1641
|
+
|
|
1642
|
+
# Parse subject lines from response
|
|
1643
|
+
subject_lines = [line.strip() for line in response.split('\n') if line.strip()]
|
|
1644
|
+
|
|
1645
|
+
# Ensure we have at least 3 subject lines
|
|
1646
|
+
if len(subject_lines) < 3:
|
|
1647
|
+
subject_lines.extend(self._generate_fallback_subject_lines(customer_data, recommended_product))
|
|
1648
|
+
|
|
1649
|
+
return subject_lines[:4] # Return max 4 subject lines
|
|
1650
|
+
|
|
1651
|
+
except Exception as e:
|
|
1652
|
+
self.logger.warning(f"Failed to generate subject lines: {str(e)}")
|
|
1653
|
+
return self._generate_fallback_subject_lines(customer_data, recommended_product)
|
|
1654
|
+
|
|
1655
|
+
def _generate_fallback_subject_lines(self, customer_data: Dict[str, Any], recommended_product: Dict[str, Any]) -> List[str]:
|
|
1656
|
+
"""Generate fallback subject lines using templates."""
|
|
1657
|
+
company_info = customer_data.get('companyInfo', {})
|
|
1658
|
+
company_name = company_info.get('name', 'Your Company')
|
|
1659
|
+
|
|
1660
|
+
return [
|
|
1661
|
+
f"Quick question about {company_name}",
|
|
1662
|
+
f"Partnership opportunity for {company_name}",
|
|
1663
|
+
f"Helping {company_name} with {company_info.get('industry', 'growth')}",
|
|
1664
|
+
f"5-minute chat about {company_name}?"
|
|
1665
|
+
]
|
|
1666
|
+
|
|
1667
|
+
def _clean_email_content(
|
|
1668
|
+
self,
|
|
1669
|
+
raw_content: str,
|
|
1670
|
+
context: Optional[Dict[str, Any]] = None
|
|
1671
|
+
) -> str:
|
|
1672
|
+
"""
|
|
1673
|
+
Clean and normalize generated email content, returning HTML.
|
|
1674
|
+
"""
|
|
1675
|
+
content = (raw_content or "").replace("\r\n", "\n").strip()
|
|
1676
|
+
|
|
1677
|
+
artifacts_to_remove = (
|
|
1678
|
+
"Here's the email:",
|
|
1679
|
+
"Here is the email:",
|
|
1680
|
+
"Email content:",
|
|
1681
|
+
"Generated email:",
|
|
1682
|
+
"Subject:",
|
|
1683
|
+
"Email:"
|
|
1684
|
+
)
|
|
1685
|
+
|
|
1686
|
+
for artifact in artifacts_to_remove:
|
|
1687
|
+
if content.startswith(artifact):
|
|
1688
|
+
content = content[len(artifact):].strip()
|
|
1689
|
+
|
|
1690
|
+
lines = [line.strip() for line in content.split('\n') if line.strip()]
|
|
1691
|
+
content = '\n\n'.join(lines)
|
|
1692
|
+
|
|
1693
|
+
if not content:
|
|
1694
|
+
return "<html><body></body></html>"
|
|
1695
|
+
|
|
1696
|
+
if not content.lower().startswith(('dear ', 'hi ', 'hello ', 'greetings')):
|
|
1697
|
+
content = f"Dear Valued Customer,\n\n{content}"
|
|
1698
|
+
|
|
1699
|
+
closings = ('best regards', 'sincerely', 'thanks', 'thank you', 'kind regards')
|
|
1700
|
+
if not any(closing in content.lower() for closing in closings):
|
|
1701
|
+
content += "\n\nBest regards,\n[Your Name]"
|
|
1702
|
+
|
|
1703
|
+
ctx = context or {}
|
|
1704
|
+
input_data = ctx.get('input_data', {}) or {}
|
|
1705
|
+
rep_profile = getattr(self, '_active_rep_profile', {}) or {}
|
|
1706
|
+
staff_name = input_data.get('staff_name') or self.config.get('staff_name') or 'Sales Team'
|
|
1707
|
+
identity = ctx.get('_recipient_identity') or {}
|
|
1708
|
+
customer_first_name = (
|
|
1709
|
+
identity.get('first_name')
|
|
1710
|
+
or ctx.get('customer_first_name')
|
|
1711
|
+
or input_data.get('customer_first_name')
|
|
1712
|
+
or input_data.get('recipient_name')
|
|
1713
|
+
or input_data.get('customer_name')
|
|
1714
|
+
or ''
|
|
1715
|
+
)
|
|
1716
|
+
|
|
1717
|
+
sanitized = self._sanitize_email_body(content, staff_name, rep_profile, customer_first_name)
|
|
1718
|
+
if '<html' not in sanitized.lower():
|
|
1719
|
+
sanitized = f"<html><body>{sanitized}</body></html>"
|
|
1720
|
+
|
|
1721
|
+
return sanitized
|
|
1722
|
+
|
|
1723
|
+
def _ensure_html_email(self, raw_content: Any, context: Dict[str, Any]) -> str:
|
|
1724
|
+
"""
|
|
1725
|
+
Normalize potentially plain-text content into HTML output.
|
|
1726
|
+
"""
|
|
1727
|
+
if raw_content is None:
|
|
1728
|
+
return "<html><body></body></html>"
|
|
1729
|
+
|
|
1730
|
+
text = str(raw_content)
|
|
1731
|
+
if '<html' in text.lower():
|
|
1732
|
+
return text
|
|
1733
|
+
|
|
1734
|
+
return self._clean_email_content(text, context)
|
|
1735
|
+
|
|
1736
|
+
def _extract_call_to_action(self, email_content: str) -> str:
|
|
1737
|
+
"""Extract the main call-to-action from email content."""
|
|
1738
|
+
plain_content = self._strip_html_tags(email_content)
|
|
1739
|
+
cta_patterns = [
|
|
1740
|
+
r"Would you be (?:interested in|available for|open to) ([^?]+\?)",
|
|
1741
|
+
r"Can we schedule ([^?]+\?)",
|
|
1742
|
+
r"I'd love to ([^.]+\.)",
|
|
1743
|
+
r"Let's ([^.]+\.)",
|
|
1744
|
+
r"Would you like to ([^?]+\?)"
|
|
1745
|
+
]
|
|
1746
|
+
|
|
1747
|
+
for pattern in cta_patterns:
|
|
1748
|
+
match = re.search(pattern, plain_content, re.IGNORECASE)
|
|
1749
|
+
if match:
|
|
1750
|
+
return match.group(0).strip()
|
|
1751
|
+
|
|
1752
|
+
question_index = plain_content.find('?')
|
|
1753
|
+
if question_index != -1:
|
|
1754
|
+
start_idx = plain_content.rfind('.', 0, question_index)
|
|
1755
|
+
start_idx = start_idx + 1 if start_idx != -1 else 0
|
|
1756
|
+
cta_sentence = plain_content[start_idx:question_index + 1].strip()
|
|
1757
|
+
if cta_sentence:
|
|
1758
|
+
return cta_sentence
|
|
1759
|
+
|
|
1760
|
+
sentences = [sentence.strip() for sentence in re.split(r'[.\n]', plain_content) if sentence.strip()]
|
|
1761
|
+
for sentence in sentences:
|
|
1762
|
+
if '?' in sentence:
|
|
1763
|
+
return sentence if sentence.endswith('?') else f"{sentence}?"
|
|
1764
|
+
|
|
1765
|
+
return "Would you be interested in learning more?"
|
|
1766
|
+
|
|
1767
|
+
def _calculate_personalization_score(self, email_content: str, customer_data: Dict[str, Any]) -> int:
|
|
1768
|
+
"""Calculate personalization score based on customer data usage."""
|
|
1769
|
+
plain_content = self._strip_html_tags(email_content)
|
|
1770
|
+
lower_content = plain_content.lower()
|
|
1771
|
+
score = 0
|
|
1772
|
+
company_info = customer_data.get('companyInfo', {})
|
|
1773
|
+
contact_info = customer_data.get('primaryContact', {})
|
|
1774
|
+
|
|
1775
|
+
company_name = str(company_info.get('name', '')).lower()
|
|
1776
|
+
if company_name and company_name in lower_content:
|
|
1777
|
+
score += 25
|
|
1778
|
+
|
|
1779
|
+
contact_name = str(contact_info.get('name', '')).lower()
|
|
1780
|
+
if contact_name and contact_name not in ('a person', '') and contact_name in lower_content:
|
|
1781
|
+
score += 25
|
|
1782
|
+
|
|
1783
|
+
industry = str(company_info.get('industry', '')).lower()
|
|
1784
|
+
if industry and industry in lower_content:
|
|
1785
|
+
score += 20
|
|
1786
|
+
|
|
1787
|
+
pain_points = customer_data.get('painPoints', [])
|
|
1788
|
+
for pain_point in pain_points:
|
|
1789
|
+
description = str(pain_point.get('description', '')).lower()
|
|
1790
|
+
if description and description in lower_content:
|
|
1791
|
+
score += 15
|
|
1792
|
+
break
|
|
1793
|
+
|
|
1794
|
+
size = company_info.get('size')
|
|
1795
|
+
location = company_info.get('location') or company_info.get('address')
|
|
1796
|
+
for detail in (size, location):
|
|
1797
|
+
if detail:
|
|
1798
|
+
detail_text = str(detail).lower()
|
|
1799
|
+
if detail_text and detail_text in lower_content:
|
|
1800
|
+
score += 15
|
|
1801
|
+
break
|
|
1802
|
+
|
|
1803
|
+
return min(score, 100)
|
|
1804
|
+
|
|
1805
|
+
def _get_mock_email_drafts(self, customer_data: Dict[str, Any], recommended_product: Dict[str, Any], context: Dict[str, Any]) -> List[Dict[str, Any]]:
|
|
1806
|
+
"""Get mock email drafts for dry run."""
|
|
1807
|
+
input_data = context.get('input_data', {})
|
|
1808
|
+
company_info = customer_data.get('companyInfo', {})
|
|
1809
|
+
|
|
1810
|
+
recipient_identity = self._resolve_recipient_identity(customer_data, context)
|
|
1811
|
+
context.setdefault('_recipient_identity', recipient_identity)
|
|
1812
|
+
mock_body = f"""[DRY RUN] Mock email content for {company_info.get('name', 'Test Company')}
|
|
1813
|
+
|
|
1814
|
+
This is a mock email that would be generated for testing purposes. In a real execution, this would contain personalized content based on the customer's company information, pain points, and our product recommendations."""
|
|
1815
|
+
|
|
1816
|
+
mock_draft = {
|
|
1817
|
+
'draft_id': 'mock_draft_001',
|
|
1818
|
+
'approach': 'professional_direct',
|
|
1819
|
+
'tone': 'professional and direct',
|
|
1820
|
+
'focus': 'business value and ROI',
|
|
1821
|
+
'subject': f"Partnership Opportunity for {company_info.get('name', 'Test Company')}",
|
|
1822
|
+
'subject_alternatives': [
|
|
1823
|
+
f"Quick Question About {company_info.get('name', 'Test Company')}",
|
|
1824
|
+
f"Helping Companies Like {company_info.get('name', 'Test Company')}"
|
|
1825
|
+
],
|
|
1826
|
+
'email_body': self._clean_email_content(mock_body, context),
|
|
1827
|
+
'email_format': 'html',
|
|
1828
|
+
'recipient_email': recipient_identity.get('email'),
|
|
1829
|
+
'recipient_name': recipient_identity.get('full_name'),
|
|
1830
|
+
'customer_first_name': recipient_identity.get('first_name'),
|
|
1831
|
+
'call_to_action': 'Mock call to action',
|
|
1832
|
+
'personalization_score': 85,
|
|
1833
|
+
'generated_at': datetime.now().isoformat(),
|
|
1834
|
+
'status': 'mock',
|
|
1835
|
+
'metadata': {
|
|
1836
|
+
'generation_method': 'mock_data',
|
|
1837
|
+
'note': 'This is mock data for dry run testing',
|
|
1838
|
+
'recipient_email': recipient_identity.get('email'),
|
|
1839
|
+
'recipient_name': recipient_identity.get('full_name'),
|
|
1840
|
+
'email_format': 'html'
|
|
1841
|
+
}
|
|
1842
|
+
}
|
|
1843
|
+
|
|
1844
|
+
mock_draft['priority_order'] = self._get_draft_priority_order(mock_draft)
|
|
1845
|
+
mock_draft['metadata']['priority_order'] = mock_draft['priority_order']
|
|
1846
|
+
|
|
1847
|
+
return [mock_draft]
|
|
1848
|
+
|
|
1849
|
+
|
|
1850
|
+
def _convert_draft_to_server_format(self, draft: Dict[str, Any], context: Dict[str, Any]) -> Dict[str, Any]:
|
|
1851
|
+
"""
|
|
1852
|
+
Convert local draft format to server-compatible gs_initial_outreach_mail_draft format.
|
|
1853
|
+
|
|
1854
|
+
Server schema fields:
|
|
1855
|
+
- body (text): Email content
|
|
1856
|
+
- subject (text): Single subject line
|
|
1857
|
+
- mail_tone (text): Email tone
|
|
1858
|
+
- priority_order (integer): Draft priority
|
|
1859
|
+
- language (text): Email language
|
|
1860
|
+
- keyid (text): Unique identifier
|
|
1861
|
+
- customer_language (boolean): Whether using customer's language
|
|
1862
|
+
- task_id (text): Task identifier
|
|
1863
|
+
- org_id (text): Organization ID
|
|
1864
|
+
- customer_id (text): Customer identifier
|
|
1865
|
+
- retrieved_date (text): Creation timestamp
|
|
1866
|
+
- import_uuid (text): Import identifier
|
|
1867
|
+
- project_code (text): Project code
|
|
1868
|
+
- project_url (text): Project URL
|
|
1869
|
+
"""
|
|
1870
|
+
input_data = context.get('input_data', {})
|
|
1871
|
+
execution_id = context.get('execution_id', 'unknown')
|
|
1872
|
+
|
|
1873
|
+
# Generate server-compatible keyid
|
|
1874
|
+
keyid = f"{input_data.get('org_id', 'unknown')}_{draft.get('customer_id', 'unknown')}_{execution_id}_{draft['draft_id']}"
|
|
1875
|
+
|
|
1876
|
+
# Map approach to mail_tone
|
|
1877
|
+
tone_mapping = {
|
|
1878
|
+
'professional_direct': 'Professional',
|
|
1879
|
+
'consultative': 'Consultative',
|
|
1880
|
+
'industry_expert': 'Expert',
|
|
1881
|
+
'relationship_building': 'Friendly'
|
|
1882
|
+
}
|
|
1883
|
+
|
|
1884
|
+
server_draft = {
|
|
1885
|
+
'body': draft.get('email_body', ''),
|
|
1886
|
+
'subject': draft.get('subject', ''),
|
|
1887
|
+
'mail_tone': tone_mapping.get(draft.get('approach', 'professional_direct'), 'Professional'),
|
|
1888
|
+
'priority_order': self._get_draft_priority_order(draft),
|
|
1889
|
+
'language': input_data.get('language', 'English').title(),
|
|
1890
|
+
'keyid': keyid,
|
|
1891
|
+
'customer_language': input_data.get('language', 'english').lower() != 'english',
|
|
1892
|
+
'task_id': execution_id,
|
|
1893
|
+
'org_id': input_data.get('org_id', 'unknown'),
|
|
1894
|
+
'customer_id': draft.get('customer_id', execution_id),
|
|
1895
|
+
'retrieved_date': datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
|
|
1896
|
+
'import_uuid': f"{input_data.get('org_id', 'unknown')}_{draft.get('customer_id', execution_id)}_{execution_id}",
|
|
1897
|
+
'project_code': input_data.get('project_code', 'LOCAL'),
|
|
1898
|
+
'project_url': input_data.get('project_url', ''),
|
|
1899
|
+
|
|
1900
|
+
# Keep local fields for compatibility
|
|
1901
|
+
'draft_id': draft.get('draft_id'),
|
|
1902
|
+
'approach': draft.get('approach'),
|
|
1903
|
+
'tone': draft.get('tone'),
|
|
1904
|
+
'focus': draft.get('focus'),
|
|
1905
|
+
'subject_alternatives': draft.get('subject_alternatives', []),
|
|
1906
|
+
'call_to_action': draft.get('call_to_action'),
|
|
1907
|
+
'personalization_score': draft.get('personalization_score'),
|
|
1908
|
+
'generated_at': draft.get('generated_at'),
|
|
1909
|
+
'status': draft.get('status'),
|
|
1910
|
+
'metadata': draft.get('metadata', {})
|
|
1911
|
+
}
|
|
1912
|
+
|
|
1913
|
+
return server_draft
|
|
1914
|
+
|
|
1915
|
+
def _get_draft_priority_order(self, draft: Dict[str, Any]) -> int:
|
|
1916
|
+
"""Get priority order, preferring explicit values and defaulting to 1 if absent."""
|
|
1917
|
+
priority_candidates = [
|
|
1918
|
+
draft.get('priority_order'),
|
|
1919
|
+
(draft.get('metadata') or {}).get('priority_order')
|
|
1920
|
+
]
|
|
1921
|
+
|
|
1922
|
+
for candidate in priority_candidates:
|
|
1923
|
+
try:
|
|
1924
|
+
parsed = int(candidate)
|
|
1925
|
+
if parsed >= 1:
|
|
1926
|
+
return parsed
|
|
1927
|
+
except (TypeError, ValueError):
|
|
1928
|
+
continue
|
|
1929
|
+
|
|
1930
|
+
return 1
|
|
1931
|
+
|
|
1932
|
+
|
|
1933
|
+
def _convert_draft_to_server_format(self, draft: Dict[str, Any], context: Dict[str, Any]) -> Dict[str, Any]:
|
|
1934
|
+
"""
|
|
1935
|
+
Convert local draft format to server-compatible gs_initial_outreach_mail_draft format.
|
|
1936
|
+
|
|
1937
|
+
Server schema fields:
|
|
1938
|
+
- body (text): Email content
|
|
1939
|
+
- subject (text): Single subject line
|
|
1940
|
+
- mail_tone (text): Email tone
|
|
1941
|
+
- priority_order (integer): Draft priority
|
|
1942
|
+
- language (text): Email language
|
|
1943
|
+
- keyid (text): Unique identifier
|
|
1944
|
+
- customer_language (boolean): Whether using customer's language
|
|
1945
|
+
- task_id (text): Task identifier
|
|
1946
|
+
- org_id (text): Organization ID
|
|
1947
|
+
- customer_id (text): Customer identifier
|
|
1948
|
+
- retrieved_date (text): Creation timestamp
|
|
1949
|
+
- import_uuid (text): Import identifier
|
|
1950
|
+
- project_code (text): Project code
|
|
1951
|
+
- project_url (text): Project URL
|
|
1952
|
+
"""
|
|
1953
|
+
input_data = context.get('input_data', {})
|
|
1954
|
+
execution_id = context.get('execution_id', 'unknown')
|
|
1955
|
+
|
|
1956
|
+
# Generate server-compatible keyid
|
|
1957
|
+
keyid = f"{input_data.get('org_id', 'unknown')}_{draft.get('customer_id', 'unknown')}_{execution_id}_{draft['draft_id']}"
|
|
1958
|
+
|
|
1959
|
+
# Map approach to mail_tone
|
|
1960
|
+
tone_mapping = {
|
|
1961
|
+
'professional_direct': 'Professional',
|
|
1962
|
+
'consultative': 'Consultative',
|
|
1963
|
+
'industry_expert': 'Expert',
|
|
1964
|
+
'relationship_building': 'Friendly'
|
|
1965
|
+
}
|
|
1966
|
+
|
|
1967
|
+
server_draft = {
|
|
1968
|
+
'body': draft.get('email_body', ''),
|
|
1969
|
+
'subject': draft.get('subject', ''),
|
|
1970
|
+
'mail_tone': tone_mapping.get(draft.get('approach', 'professional_direct'), 'Professional'),
|
|
1971
|
+
'priority_order': self._get_draft_priority_order(draft),
|
|
1972
|
+
'language': input_data.get('language', 'English').title(),
|
|
1973
|
+
'keyid': keyid,
|
|
1974
|
+
'customer_language': input_data.get('language', 'english').lower() != 'english',
|
|
1975
|
+
'task_id': execution_id,
|
|
1976
|
+
'org_id': input_data.get('org_id', 'unknown'),
|
|
1977
|
+
'customer_id': draft.get('customer_id', execution_id),
|
|
1978
|
+
'retrieved_date': datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
|
|
1979
|
+
'import_uuid': f"{input_data.get('org_id', 'unknown')}_{draft.get('customer_id', execution_id)}_{execution_id}",
|
|
1980
|
+
'project_code': input_data.get('project_code', 'LOCAL'),
|
|
1981
|
+
'project_url': input_data.get('project_url', ''),
|
|
1982
|
+
|
|
1983
|
+
# Keep local fields for compatibility
|
|
1984
|
+
'draft_id': draft.get('draft_id'),
|
|
1985
|
+
'approach': draft.get('approach'),
|
|
1986
|
+
'tone': draft.get('tone'),
|
|
1987
|
+
'focus': draft.get('focus'),
|
|
1988
|
+
'subject_alternatives': draft.get('subject_alternatives', []),
|
|
1989
|
+
'call_to_action': draft.get('call_to_action'),
|
|
1990
|
+
'personalization_score': draft.get('personalization_score'),
|
|
1991
|
+
'generated_at': draft.get('generated_at'),
|
|
1992
|
+
'status': draft.get('status'),
|
|
1993
|
+
'metadata': draft.get('metadata', {})
|
|
1994
|
+
}
|
|
1995
|
+
|
|
1996
|
+
return server_draft
|
|
1997
|
+
|
|
1998
|
+
def _get_draft_priority_order(self, draft: Dict[str, Any]) -> int:
|
|
1999
|
+
"""Get priority order, preferring explicit values and defaulting to 1 if absent."""
|
|
2000
|
+
priority_candidates = [
|
|
2001
|
+
draft.get('priority_order'),
|
|
2002
|
+
(draft.get('metadata') or {}).get('priority_order')
|
|
2003
|
+
]
|
|
2004
|
+
|
|
2005
|
+
for candidate in priority_candidates:
|
|
2006
|
+
try:
|
|
2007
|
+
parsed = int(candidate)
|
|
2008
|
+
if parsed >= 1:
|
|
2009
|
+
return parsed
|
|
2010
|
+
except (TypeError, ValueError):
|
|
2011
|
+
continue
|
|
2012
|
+
|
|
2013
|
+
return 1
|
|
2014
|
+
|
|
2015
|
+
def _save_email_drafts(self, context: Dict[str, Any], email_drafts: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
2016
|
+
"""Save email drafts to database and files."""
|
|
2017
|
+
try:
|
|
2018
|
+
execution_id = context.get('execution_id')
|
|
2019
|
+
saved_drafts = []
|
|
2020
|
+
|
|
2021
|
+
# Get data manager for database operations
|
|
2022
|
+
data_manager = self.data_manager
|
|
2023
|
+
|
|
2024
|
+
for draft in email_drafts:
|
|
2025
|
+
try:
|
|
2026
|
+
# Prepare draft data for database
|
|
2027
|
+
draft_data = {
|
|
2028
|
+
'draft_id': draft['draft_id'],
|
|
2029
|
+
'execution_id': execution_id,
|
|
2030
|
+
'customer_id': execution_id, # Using execution_id as customer_id for now
|
|
2031
|
+
'subject': draft.get('subject', 'No Subject'),
|
|
2032
|
+
'content': draft['email_body'],
|
|
2033
|
+
'draft_type': 'initial',
|
|
2034
|
+
'version': 1,
|
|
2035
|
+
'status': 'draft',
|
|
2036
|
+
'metadata': json.dumps({
|
|
2037
|
+
'approach': draft.get('approach', 'unknown'),
|
|
2038
|
+
'tone': draft.get('tone', 'professional'),
|
|
2039
|
+
'focus': draft.get('focus', 'general'),
|
|
2040
|
+
'all_subject_lines': [draft.get('subject', '')] + draft.get('subject_alternatives', []),
|
|
2041
|
+
'call_to_action': draft.get('call_to_action', ''),
|
|
2042
|
+
'personalization_score': draft.get('personalization_score', 0),
|
|
2043
|
+
'generation_method': draft.get('metadata', {}).get('generation_method', 'llm'),
|
|
2044
|
+
'priority_order': draft.get('priority_order'),
|
|
2045
|
+
'generated_at': draft.get('generated_at', datetime.now().isoformat())
|
|
2046
|
+
})
|
|
2047
|
+
}
|
|
2048
|
+
|
|
2049
|
+
# Save to database
|
|
2050
|
+
if not self.is_dry_run():
|
|
2051
|
+
data_manager.save_email_draft(
|
|
2052
|
+
draft_id=draft_data['draft_id'],
|
|
2053
|
+
execution_id=draft_data['execution_id'],
|
|
2054
|
+
customer_id=draft_data['customer_id'],
|
|
2055
|
+
subject=draft_data['subject'],
|
|
2056
|
+
content=draft_data['content'],
|
|
2057
|
+
draft_type=draft_data['draft_type'],
|
|
2058
|
+
version=draft_data['version'],
|
|
2059
|
+
status=draft_data.get('status', 'draft'),
|
|
2060
|
+
metadata=draft_data.get('metadata'),
|
|
2061
|
+
priority_order=draft.get('priority_order', 0)
|
|
2062
|
+
)
|
|
2063
|
+
self.logger.info(f"Saved draft {draft['draft_id']} to database")
|
|
2064
|
+
|
|
2065
|
+
# Save to file system for backup
|
|
2066
|
+
draft_file_path = self._save_draft_to_file(execution_id, draft)
|
|
2067
|
+
|
|
2068
|
+
# Add context to draft
|
|
2069
|
+
draft_with_context = draft.copy()
|
|
2070
|
+
draft_with_context['execution_id'] = execution_id
|
|
2071
|
+
draft_with_context['file_path'] = draft_file_path
|
|
2072
|
+
draft_with_context['database_saved'] = not self.is_dry_run()
|
|
2073
|
+
draft_with_context['saved_at'] = datetime.now().isoformat()
|
|
2074
|
+
|
|
2075
|
+
saved_drafts.append(draft_with_context)
|
|
2076
|
+
|
|
2077
|
+
except Exception as e:
|
|
2078
|
+
self.logger.error(f"Failed to save individual draft {draft.get('draft_id', 'unknown')}: {str(e)}")
|
|
2079
|
+
# Still add to saved_drafts but mark as failed
|
|
2080
|
+
draft_with_context = draft.copy()
|
|
2081
|
+
draft_with_context['execution_id'] = execution_id
|
|
2082
|
+
draft_with_context['save_error'] = str(e)
|
|
2083
|
+
draft_with_context['database_saved'] = False
|
|
2084
|
+
saved_drafts.append(draft_with_context)
|
|
2085
|
+
|
|
2086
|
+
self.logger.info(f"Successfully saved {len([d for d in saved_drafts if d.get('database_saved', False)])} drafts to database")
|
|
2087
|
+
return saved_drafts
|
|
2088
|
+
|
|
2089
|
+
except Exception as e:
|
|
2090
|
+
self.logger.error(f"Failed to save email drafts: {str(e)}")
|
|
2091
|
+
# Return drafts with error information
|
|
2092
|
+
for draft in email_drafts:
|
|
2093
|
+
draft['save_error'] = str(e)
|
|
2094
|
+
draft['database_saved'] = False
|
|
2095
|
+
return email_drafts
|
|
2096
|
+
|
|
2097
|
+
def _save_draft_to_file(self, execution_id: str, draft: Dict[str, Any]) -> str:
|
|
2098
|
+
"""Save draft to file system as backup."""
|
|
2099
|
+
try:
|
|
2100
|
+
import os
|
|
2101
|
+
|
|
2102
|
+
# Create drafts directory if it doesn't exist
|
|
2103
|
+
drafts_dir = os.path.join(self.config.get('data_dir', './fusesell_data'), 'drafts')
|
|
2104
|
+
os.makedirs(drafts_dir, exist_ok=True)
|
|
2105
|
+
|
|
2106
|
+
# Create file path
|
|
2107
|
+
file_name = f"{execution_id}_{draft['draft_id']}.json"
|
|
2108
|
+
file_path = os.path.join(drafts_dir, file_name)
|
|
2109
|
+
|
|
2110
|
+
# Save draft to file
|
|
2111
|
+
with open(file_path, 'w', encoding='utf-8') as f:
|
|
2112
|
+
json.dump(draft, f, indent=2, ensure_ascii=False)
|
|
2113
|
+
|
|
2114
|
+
return f"drafts/{file_name}"
|
|
2115
|
+
|
|
2116
|
+
except Exception as e:
|
|
2117
|
+
self.logger.warning(f"Failed to save draft to file: {str(e)}")
|
|
2118
|
+
return f"drafts/{execution_id}_{draft['draft_id']}.json"
|
|
2119
|
+
|
|
2120
|
+
def _get_draft_by_id(self, draft_id: str) -> Optional[Dict[str, Any]]:
|
|
2121
|
+
"""Retrieve draft by ID from database."""
|
|
2122
|
+
if self.is_dry_run():
|
|
2123
|
+
return {
|
|
2124
|
+
'draft_id': draft_id,
|
|
2125
|
+
'subject': 'Mock Subject Line',
|
|
2126
|
+
'subject_alternatives': ['Alternative Mock Subject'],
|
|
2127
|
+
'email_body': 'Mock email content for testing purposes.',
|
|
2128
|
+
'approach': 'mock',
|
|
2129
|
+
'tone': 'professional',
|
|
2130
|
+
'status': 'mock',
|
|
2131
|
+
'call_to_action': 'Mock call to action',
|
|
2132
|
+
'personalization_score': 75
|
|
2133
|
+
}
|
|
2134
|
+
|
|
2135
|
+
try:
|
|
2136
|
+
# Get data manager for database operations
|
|
2137
|
+
data_manager = self.data_manager
|
|
2138
|
+
|
|
2139
|
+
# Query database for draft
|
|
2140
|
+
draft_record = data_manager.get_email_draft_by_id(draft_id)
|
|
2141
|
+
|
|
2142
|
+
if not draft_record:
|
|
2143
|
+
self.logger.warning(f"Draft not found in database: {draft_id}")
|
|
2144
|
+
return None
|
|
2145
|
+
|
|
2146
|
+
# Parse metadata
|
|
2147
|
+
metadata = {}
|
|
2148
|
+
if draft_record.get('metadata'):
|
|
2149
|
+
try:
|
|
2150
|
+
metadata = json.loads(draft_record['metadata'])
|
|
2151
|
+
except json.JSONDecodeError:
|
|
2152
|
+
self.logger.warning(f"Failed to parse metadata for draft {draft_id}")
|
|
2153
|
+
|
|
2154
|
+
# Reconstruct draft object
|
|
2155
|
+
draft = {
|
|
2156
|
+
'draft_id': draft_record['draft_id'],
|
|
2157
|
+
'execution_id': draft_record['execution_id'],
|
|
2158
|
+
'customer_id': draft_record['customer_id'],
|
|
2159
|
+
'subject': draft_record['subject'],
|
|
2160
|
+
'subject_alternatives': metadata.get('all_subject_lines', [])[1:] if len(metadata.get('all_subject_lines', [])) > 1 else [],
|
|
2161
|
+
'email_body': draft_record['content'],
|
|
2162
|
+
'approach': metadata.get('approach', 'unknown'),
|
|
2163
|
+
'tone': metadata.get('tone', 'professional'),
|
|
2164
|
+
'focus': metadata.get('focus', 'general'),
|
|
2165
|
+
'call_to_action': metadata.get('call_to_action', ''),
|
|
2166
|
+
'personalization_score': metadata.get('personalization_score', 0),
|
|
2167
|
+
'status': draft_record['status'],
|
|
2168
|
+
'version': draft_record['version'],
|
|
2169
|
+
'draft_type': draft_record['draft_type'],
|
|
2170
|
+
'created_at': draft_record['created_at'],
|
|
2171
|
+
'updated_at': draft_record['updated_at'],
|
|
2172
|
+
'metadata': metadata
|
|
2173
|
+
}
|
|
2174
|
+
|
|
2175
|
+
self.logger.info(f"Retrieved draft {draft_id} from database")
|
|
2176
|
+
return draft
|
|
2177
|
+
|
|
2178
|
+
except Exception as e:
|
|
2179
|
+
self.logger.error(f"Failed to retrieve draft {draft_id}: {str(e)}")
|
|
2180
|
+
return None
|
|
2181
|
+
|
|
2182
|
+
def _rewrite_draft(self, existing_draft: Dict[str, Any], reason: str, customer_data: Dict[str, Any], scoring_data: Dict[str, Any], context: Dict[str, Any]) -> Dict[str, Any]:
|
|
2183
|
+
"""Rewrite existing draft based on reason using LLM."""
|
|
2184
|
+
try:
|
|
2185
|
+
if self.is_dry_run():
|
|
2186
|
+
rewritten = existing_draft.copy()
|
|
2187
|
+
rewritten['draft_id'] = f"uuid:{str(uuid.uuid4())}"
|
|
2188
|
+
rewritten['draft_approach'] = "rewrite"
|
|
2189
|
+
rewritten['draft_type'] = "rewrite"
|
|
2190
|
+
rewritten['email_body'] = f"[DRY RUN - REWRITTEN: {reason}] " + existing_draft.get('email_body', '')
|
|
2191
|
+
rewritten['rewrite_reason'] = reason
|
|
2192
|
+
rewritten['rewritten_at'] = datetime.now().isoformat()
|
|
2193
|
+
rewritten.setdefault('email_format', 'html')
|
|
2194
|
+
return rewritten
|
|
2195
|
+
|
|
2196
|
+
input_data = context.get('input_data', {})
|
|
2197
|
+
company_info = customer_data.get('companyInfo', {})
|
|
2198
|
+
contact_info = customer_data.get('primaryContact', {})
|
|
2199
|
+
|
|
2200
|
+
# Create rewrite prompt
|
|
2201
|
+
rewrite_prompt = f"""Rewrite the following email based on the feedback provided. Keep the core message and personalization but address the specific concerns mentioned.
|
|
2202
|
+
|
|
2203
|
+
ORIGINAL EMAIL:
|
|
2204
|
+
{existing_draft.get('email_body', '')}
|
|
2205
|
+
|
|
2206
|
+
FEEDBACK/REASON FOR REWRITE:
|
|
2207
|
+
{reason}
|
|
2208
|
+
|
|
2209
|
+
CUSTOMER CONTEXT:
|
|
2210
|
+
- Company: {company_info.get('name', 'the company')}
|
|
2211
|
+
- Contact: {contact_info.get('name', 'the contact')}
|
|
2212
|
+
- Industry: {company_info.get('industry', 'technology')}
|
|
2213
|
+
|
|
2214
|
+
REQUIREMENTS:
|
|
2215
|
+
1. Address the feedback/reason provided
|
|
2216
|
+
2. Maintain personalization and relevance
|
|
2217
|
+
3. Keep the professional tone
|
|
2218
|
+
4. Ensure the email flows naturally
|
|
2219
|
+
5. Include a clear call-to-action
|
|
2220
|
+
6. Make improvements based on the feedback
|
|
2221
|
+
|
|
2222
|
+
Generate only the rewritten email content:"""
|
|
2223
|
+
|
|
2224
|
+
# Generate rewritten content using LLM
|
|
2225
|
+
rewritten_content = self.call_llm(
|
|
2226
|
+
prompt=rewrite_prompt,
|
|
2227
|
+
temperature=0.6,
|
|
2228
|
+
max_tokens=800
|
|
2229
|
+
)
|
|
2230
|
+
|
|
2231
|
+
# Clean the rewritten content
|
|
2232
|
+
cleaned_content = self._clean_email_content(rewritten_content, context)
|
|
2233
|
+
|
|
2234
|
+
# Create rewritten draft object
|
|
2235
|
+
rewritten = existing_draft.copy()
|
|
2236
|
+
rewritten['draft_id'] = f"uuid:{str(uuid.uuid4())}"
|
|
2237
|
+
rewritten['draft_approach'] = "rewrite"
|
|
2238
|
+
rewritten['draft_type'] = "rewrite"
|
|
2239
|
+
rewritten['email_body'] = cleaned_content
|
|
2240
|
+
rewritten['rewrite_reason'] = reason
|
|
2241
|
+
rewritten['rewritten_at'] = datetime.now().isoformat()
|
|
2242
|
+
rewritten['version'] = existing_draft.get('version', 1) + 1
|
|
2243
|
+
rewritten['call_to_action'] = self._extract_call_to_action(cleaned_content)
|
|
2244
|
+
rewritten['personalization_score'] = self._calculate_personalization_score(cleaned_content, customer_data)
|
|
2245
|
+
recipient_identity = context.get('_recipient_identity') or self._resolve_recipient_identity(customer_data, context)
|
|
2246
|
+
if recipient_identity:
|
|
2247
|
+
rewritten['recipient_email'] = recipient_identity.get('email')
|
|
2248
|
+
rewritten['recipient_name'] = recipient_identity.get('full_name')
|
|
2249
|
+
rewritten['customer_first_name'] = recipient_identity.get('first_name')
|
|
2250
|
+
rewritten['email_format'] = 'html'
|
|
2251
|
+
|
|
2252
|
+
# Update metadata
|
|
2253
|
+
if 'metadata' not in rewritten:
|
|
2254
|
+
rewritten['metadata'] = {}
|
|
2255
|
+
rewritten['metadata']['rewrite_history'] = rewritten['metadata'].get('rewrite_history', [])
|
|
2256
|
+
rewritten['metadata']['rewrite_history'].append({
|
|
2257
|
+
'reason': reason,
|
|
2258
|
+
'rewritten_at': datetime.now().isoformat(),
|
|
2259
|
+
'original_draft_id': existing_draft.get('draft_id')
|
|
2260
|
+
})
|
|
2261
|
+
rewritten['metadata']['generation_method'] = 'llm_rewrite'
|
|
2262
|
+
if recipient_identity:
|
|
2263
|
+
rewritten['metadata']['recipient_email'] = recipient_identity.get('email')
|
|
2264
|
+
rewritten['metadata']['recipient_name'] = recipient_identity.get('full_name')
|
|
2265
|
+
rewritten['metadata']['email_format'] = 'html'
|
|
2266
|
+
|
|
2267
|
+
self.logger.info(f"Successfully rewrote draft based on reason: {reason}")
|
|
2268
|
+
return rewritten
|
|
2269
|
+
|
|
2270
|
+
except Exception as e:
|
|
2271
|
+
self.logger.error(f"Failed to rewrite draft: {str(e)}")
|
|
2272
|
+
# Fallback to simple modification
|
|
2273
|
+
rewritten = existing_draft.copy()
|
|
2274
|
+
rewritten['draft_id'] = f"uuid:{str(uuid.uuid4())}"
|
|
2275
|
+
rewritten['draft_approach'] = "rewrite"
|
|
2276
|
+
rewritten['draft_type'] = "rewrite"
|
|
2277
|
+
rewritten['email_body'] = f"[REWRITTEN: {reason}]\n\n" + existing_draft.get('email_body', '')
|
|
2278
|
+
rewritten['rewrite_reason'] = reason
|
|
2279
|
+
rewritten['rewritten_at'] = datetime.now().isoformat()
|
|
2280
|
+
rewritten['metadata'] = {'generation_method': 'template_rewrite', 'error': str(e)}
|
|
2281
|
+
rewritten['email_body'] = self._clean_email_content(rewritten['email_body'], context)
|
|
2282
|
+
fallback_identity = context.get('_recipient_identity') or self._resolve_recipient_identity(customer_data, context)
|
|
2283
|
+
if fallback_identity:
|
|
2284
|
+
rewritten['recipient_email'] = fallback_identity.get('email')
|
|
2285
|
+
rewritten['recipient_name'] = fallback_identity.get('full_name')
|
|
2286
|
+
rewritten['customer_first_name'] = fallback_identity.get('first_name')
|
|
2287
|
+
rewritten['metadata']['recipient_email'] = fallback_identity.get('email')
|
|
2288
|
+
rewritten['metadata']['recipient_name'] = fallback_identity.get('full_name')
|
|
2289
|
+
rewritten['metadata']['email_format'] = 'html'
|
|
2290
|
+
rewritten['email_format'] = 'html'
|
|
2291
|
+
return rewritten
|
|
2292
|
+
|
|
2293
|
+
def _save_rewritten_draft(self, context: Dict[str, Any], rewritten_draft: Dict[str, Any], original_draft_id: str) -> Dict[str, Any]:
|
|
2294
|
+
"""Save rewritten draft to database and file system."""
|
|
2295
|
+
try:
|
|
2296
|
+
execution_id = context.get('execution_id')
|
|
2297
|
+
rewritten_draft['original_draft_id'] = original_draft_id
|
|
2298
|
+
rewritten_draft['execution_id'] = execution_id
|
|
2299
|
+
|
|
2300
|
+
# Get data manager for database operations
|
|
2301
|
+
data_manager = self.data_manager
|
|
2302
|
+
|
|
2303
|
+
# Prepare draft data for database
|
|
2304
|
+
draft_data = {
|
|
2305
|
+
'draft_id': rewritten_draft['draft_id'],
|
|
2306
|
+
'execution_id': execution_id,
|
|
2307
|
+
'customer_id': execution_id, # Using execution_id as customer_id for now
|
|
2308
|
+
'subject': rewritten_draft.get('subject', 'Rewritten Draft'),
|
|
2309
|
+
'content': rewritten_draft['email_body'],
|
|
2310
|
+
'draft_type': 'initial_rewrite',
|
|
2311
|
+
'version': rewritten_draft.get('version', 2),
|
|
2312
|
+
'status': 'draft',
|
|
2313
|
+
'metadata': json.dumps({
|
|
2314
|
+
'approach': rewritten_draft.get('approach', 'rewritten'),
|
|
2315
|
+
'tone': rewritten_draft.get('tone', 'professional'),
|
|
2316
|
+
'focus': rewritten_draft.get('focus', 'general'),
|
|
2317
|
+
'all_subject_lines': [rewritten_draft.get('subject', '')] + rewritten_draft.get('subject_alternatives', []),
|
|
2318
|
+
'call_to_action': rewritten_draft.get('call_to_action', ''),
|
|
2319
|
+
'personalization_score': rewritten_draft.get('personalization_score', 0),
|
|
2320
|
+
'generation_method': 'llm_rewrite',
|
|
2321
|
+
'rewrite_reason': rewritten_draft.get('rewrite_reason', ''),
|
|
2322
|
+
'original_draft_id': original_draft_id,
|
|
2323
|
+
'rewritten_at': rewritten_draft.get('rewritten_at', datetime.now().isoformat()),
|
|
2324
|
+
'rewrite_history': rewritten_draft.get('metadata', {}).get('rewrite_history', [])
|
|
2325
|
+
})
|
|
2326
|
+
}
|
|
2327
|
+
|
|
2328
|
+
# Save to database
|
|
2329
|
+
if not self.is_dry_run():
|
|
2330
|
+
data_manager.save_email_draft(draft_data)
|
|
2331
|
+
self.logger.info(f"Saved rewritten draft {rewritten_draft['draft_id']} to database")
|
|
2332
|
+
|
|
2333
|
+
# Save to file system for backup
|
|
2334
|
+
draft_file_path = self._save_draft_to_file(execution_id, rewritten_draft)
|
|
2335
|
+
|
|
2336
|
+
# Add save information
|
|
2337
|
+
rewritten_draft['file_path'] = draft_file_path
|
|
2338
|
+
rewritten_draft['database_saved'] = not self.is_dry_run()
|
|
2339
|
+
rewritten_draft['saved_at'] = datetime.now().isoformat()
|
|
2340
|
+
|
|
2341
|
+
return rewritten_draft
|
|
2342
|
+
|
|
2343
|
+
except Exception as e:
|
|
2344
|
+
self.logger.error(f"Failed to save rewritten draft: {str(e)}")
|
|
2345
|
+
rewritten_draft['save_error'] = str(e)
|
|
2346
|
+
rewritten_draft['database_saved'] = False
|
|
2347
|
+
return rewritten_draft
|
|
2348
|
+
|
|
2349
|
+
def _send_email(self, draft: Dict[str, Any], recipient_address: str, recipient_name: str, context: Dict[str, Any]) -> Dict[str, Any]:
|
|
2350
|
+
"""Send email using existing RTA email service (matching original YAML)."""
|
|
2351
|
+
if self.is_dry_run():
|
|
2352
|
+
return {
|
|
2353
|
+
'success': True,
|
|
2354
|
+
'message': f'[DRY RUN] Would send email to {recipient_address}',
|
|
2355
|
+
'email_id': f'mock_email_{uuid.uuid4().hex[:8]}'
|
|
2356
|
+
}
|
|
2357
|
+
|
|
2358
|
+
try:
|
|
2359
|
+
input_data = context.get('input_data', {})
|
|
2360
|
+
|
|
2361
|
+
# Get auto interaction settings from team settings
|
|
2362
|
+
auto_interaction_config = self._get_auto_interaction_config(input_data.get('team_id'))
|
|
2363
|
+
|
|
2364
|
+
# Prepare email payload for RTA email service (matching trigger_auto_interaction)
|
|
2365
|
+
email_payload = {
|
|
2366
|
+
"project_code": input_data.get('project_code', ''),
|
|
2367
|
+
"event_type": "custom",
|
|
2368
|
+
"event_id": input_data.get('customer_id', context.get('execution_id')),
|
|
2369
|
+
"type": "interaction",
|
|
2370
|
+
"family": "GLOBALSELL_INTERACT_EVENT_ADHOC",
|
|
2371
|
+
"language": input_data.get('language', 'english'),
|
|
2372
|
+
"submission_date": datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
|
|
2373
|
+
"instanceName": f"Send email to {recipient_name} ({recipient_address}) from {auto_interaction_config.get('from_name', input_data.get('org_name', 'Unknown'))}",
|
|
2374
|
+
"instanceID": f"{context.get('execution_id')}_{input_data.get('customer_id', 'unknown')}",
|
|
2375
|
+
"uuid": f"{context.get('execution_id')}_{input_data.get('customer_id', 'unknown')}",
|
|
2376
|
+
"action_type": auto_interaction_config.get('tool_type', 'email').lower(),
|
|
2377
|
+
"email": recipient_address,
|
|
2378
|
+
"number": auto_interaction_config.get('from_number', input_data.get('customer_phone', '')),
|
|
2379
|
+
"subject": draft.get('subject', ''), # Use subject line
|
|
2380
|
+
"content": draft.get('email_body', ''),
|
|
2381
|
+
"team_id": input_data.get('team_id', ''),
|
|
2382
|
+
"from_email": auto_interaction_config.get('from_email', ''),
|
|
2383
|
+
"from_name": auto_interaction_config.get('from_name', ''),
|
|
2384
|
+
"email_cc": auto_interaction_config.get('email_cc', ''),
|
|
2385
|
+
"email_bcc": auto_interaction_config.get('email_bcc', ''),
|
|
2386
|
+
"extraData": {
|
|
2387
|
+
"org_id": input_data.get('org_id'),
|
|
2388
|
+
"human_action_id": input_data.get('human_action_id', ''),
|
|
2389
|
+
"email_tags": "gs_148_initial_outreach",
|
|
2390
|
+
"task_id": input_data.get('customer_id', context.get('execution_id'))
|
|
2391
|
+
}
|
|
2392
|
+
}
|
|
2393
|
+
|
|
2394
|
+
# Send to RTA email service
|
|
2395
|
+
headers = {
|
|
2396
|
+
'Content-Type': 'application/json'
|
|
2397
|
+
}
|
|
2398
|
+
|
|
2399
|
+
response = requests.post(
|
|
2400
|
+
'https://automation.rta.vn/webhook/autoemail-trigger-by-inst-check',
|
|
2401
|
+
json=email_payload,
|
|
2402
|
+
headers=headers,
|
|
2403
|
+
timeout=30
|
|
2404
|
+
)
|
|
2405
|
+
|
|
2406
|
+
if response.status_code == 200:
|
|
2407
|
+
result = response.json()
|
|
2408
|
+
execution_id = result.get('executionID', '')
|
|
2409
|
+
|
|
2410
|
+
if execution_id:
|
|
2411
|
+
self.logger.info(f"Email sent successfully via RTA service: {execution_id}")
|
|
2412
|
+
return {
|
|
2413
|
+
'success': True,
|
|
2414
|
+
'message': f'Email sent to {recipient_address}',
|
|
2415
|
+
'email_id': execution_id,
|
|
2416
|
+
'service': 'RTA_email_service',
|
|
2417
|
+
'response': result
|
|
2418
|
+
}
|
|
2419
|
+
else:
|
|
2420
|
+
self.logger.warning("Email service returned success but no execution ID")
|
|
2421
|
+
return {
|
|
2422
|
+
'success': False,
|
|
2423
|
+
'message': 'Email service returned success but no execution ID',
|
|
2424
|
+
'response': result
|
|
2425
|
+
}
|
|
2426
|
+
else:
|
|
2427
|
+
self.logger.error(f"Email service returned error: {response.status_code} - {response.text}")
|
|
2428
|
+
return {
|
|
2429
|
+
'success': False,
|
|
2430
|
+
'message': f'Email service error: {response.status_code}',
|
|
2431
|
+
'error': response.text
|
|
2432
|
+
}
|
|
2433
|
+
|
|
2434
|
+
except Exception as e:
|
|
2435
|
+
self.logger.error(f"Email sending failed: {str(e)}")
|
|
2436
|
+
return {
|
|
2437
|
+
'success': False,
|
|
2438
|
+
'message': f'Email sending failed: {str(e)}',
|
|
2439
|
+
'error': str(e)
|
|
2440
|
+
}
|
|
2441
|
+
|
|
2442
|
+
def get_drafts_for_execution(self, execution_id: str) -> List[Dict[str, Any]]:
|
|
2443
|
+
"""Get all drafts for a specific execution."""
|
|
2444
|
+
try:
|
|
2445
|
+
data_manager = self.data_manager
|
|
2446
|
+
draft_records = data_manager.get_email_drafts_by_execution(execution_id)
|
|
2447
|
+
|
|
2448
|
+
drafts = []
|
|
2449
|
+
for record in draft_records:
|
|
2450
|
+
# Parse metadata
|
|
2451
|
+
metadata = {}
|
|
2452
|
+
if record.get('metadata'):
|
|
2453
|
+
try:
|
|
2454
|
+
metadata = json.loads(record['metadata'])
|
|
2455
|
+
except json.JSONDecodeError:
|
|
2456
|
+
pass
|
|
2457
|
+
|
|
2458
|
+
draft = {
|
|
2459
|
+
'draft_id': record['draft_id'],
|
|
2460
|
+
'execution_id': record['execution_id'],
|
|
2461
|
+
'subject': record['subject'],
|
|
2462
|
+
'content': record['content'],
|
|
2463
|
+
'approach': metadata.get('approach', 'unknown'),
|
|
2464
|
+
'tone': metadata.get('tone', 'professional'),
|
|
2465
|
+
'personalization_score': metadata.get('personalization_score', 0),
|
|
2466
|
+
'status': record['status'],
|
|
2467
|
+
'version': record['version'],
|
|
2468
|
+
'created_at': record['created_at'],
|
|
2469
|
+
'updated_at': record['updated_at'],
|
|
2470
|
+
'metadata': metadata
|
|
2471
|
+
}
|
|
2472
|
+
drafts.append(draft)
|
|
2473
|
+
|
|
2474
|
+
return drafts
|
|
2475
|
+
|
|
2476
|
+
except Exception as e:
|
|
2477
|
+
self.logger.error(f"Failed to get drafts for execution {execution_id}: {str(e)}")
|
|
2478
|
+
return []
|
|
2479
|
+
|
|
2480
|
+
def compare_drafts(self, draft_ids: List[str]) -> Dict[str, Any]:
|
|
2481
|
+
"""Compare multiple drafts and provide analysis."""
|
|
2482
|
+
try:
|
|
2483
|
+
drafts = []
|
|
2484
|
+
for draft_id in draft_ids:
|
|
2485
|
+
draft = self._get_draft_by_id(draft_id)
|
|
2486
|
+
if draft:
|
|
2487
|
+
drafts.append(draft)
|
|
2488
|
+
|
|
2489
|
+
if len(drafts) < 2:
|
|
2490
|
+
return {'error': 'Need at least 2 drafts to compare'}
|
|
2491
|
+
|
|
2492
|
+
comparison = {
|
|
2493
|
+
'drafts_compared': len(drafts),
|
|
2494
|
+
'comparison_timestamp': datetime.now().isoformat(),
|
|
2495
|
+
'drafts': [],
|
|
2496
|
+
'analysis': {
|
|
2497
|
+
'personalization_scores': {},
|
|
2498
|
+
'approaches': {},
|
|
2499
|
+
'length_analysis': {},
|
|
2500
|
+
'tone_analysis': {},
|
|
2501
|
+
'recommendations': []
|
|
2502
|
+
}
|
|
2503
|
+
}
|
|
2504
|
+
|
|
2505
|
+
# Analyze each draft
|
|
2506
|
+
for draft in drafts:
|
|
2507
|
+
draft_analysis = {
|
|
2508
|
+
'draft_id': draft['draft_id'],
|
|
2509
|
+
'approach': draft.get('approach', 'unknown'),
|
|
2510
|
+
'tone': draft.get('tone', 'professional'),
|
|
2511
|
+
'personalization_score': draft.get('personalization_score', 0),
|
|
2512
|
+
'word_count': len(draft.get('email_body', '').split()),
|
|
2513
|
+
'has_call_to_action': bool(draft.get('call_to_action')),
|
|
2514
|
+
'subject_line_count': 1 + len(draft.get('subject_alternatives', [])),
|
|
2515
|
+
'version': draft.get('version', 1)
|
|
2516
|
+
}
|
|
2517
|
+
comparison['drafts'].append(draft_analysis)
|
|
2518
|
+
|
|
2519
|
+
# Collect data for analysis
|
|
2520
|
+
comparison['analysis']['personalization_scores'][draft['draft_id']] = draft.get('personalization_score', 0)
|
|
2521
|
+
comparison['analysis']['approaches'][draft['draft_id']] = draft.get('approach', 'unknown')
|
|
2522
|
+
comparison['analysis']['length_analysis'][draft['draft_id']] = draft_analysis['word_count']
|
|
2523
|
+
comparison['analysis']['tone_analysis'][draft['draft_id']] = draft.get('tone', 'professional')
|
|
2524
|
+
|
|
2525
|
+
# Generate recommendations
|
|
2526
|
+
best_personalization = max(comparison['analysis']['personalization_scores'].items(), key=lambda x: x[1])
|
|
2527
|
+
comparison['analysis']['recommendations'].append(
|
|
2528
|
+
f"Draft {best_personalization[0]} has the highest personalization score ({best_personalization[1]})"
|
|
2529
|
+
)
|
|
2530
|
+
|
|
2531
|
+
# Length recommendations
|
|
2532
|
+
avg_length = sum(comparison['analysis']['length_analysis'].values()) / len(comparison['analysis']['length_analysis'])
|
|
2533
|
+
for draft_id, length in comparison['analysis']['length_analysis'].items():
|
|
2534
|
+
if length < avg_length * 0.7:
|
|
2535
|
+
comparison['analysis']['recommendations'].append(f"Draft {draft_id} might be too short ({length} words)")
|
|
2536
|
+
elif length > avg_length * 1.5:
|
|
2537
|
+
comparison['analysis']['recommendations'].append(f"Draft {draft_id} might be too long ({length} words)")
|
|
2538
|
+
|
|
2539
|
+
return comparison
|
|
2540
|
+
|
|
2541
|
+
except Exception as e:
|
|
2542
|
+
self.logger.error(f"Failed to compare drafts: {str(e)}")
|
|
2543
|
+
return {'error': str(e)}
|
|
2544
|
+
|
|
2545
|
+
def select_best_draft(self, execution_id: str, criteria: Dict[str, Any] = None) -> Optional[Dict[str, Any]]:
|
|
2546
|
+
"""Select the best draft based on criteria."""
|
|
2547
|
+
try:
|
|
2548
|
+
drafts = self.get_drafts_for_execution(execution_id)
|
|
2549
|
+
|
|
2550
|
+
if not drafts:
|
|
2551
|
+
return None
|
|
2552
|
+
|
|
2553
|
+
if len(drafts) == 1:
|
|
2554
|
+
return drafts[0]
|
|
2555
|
+
|
|
2556
|
+
# Default criteria if none provided
|
|
2557
|
+
if not criteria:
|
|
2558
|
+
criteria = {
|
|
2559
|
+
'personalization_weight': 0.4,
|
|
2560
|
+
'approach_preference': 'professional_direct',
|
|
2561
|
+
'length_preference': 'medium', # short, medium, long
|
|
2562
|
+
'tone_preference': 'professional'
|
|
2563
|
+
}
|
|
2564
|
+
|
|
2565
|
+
scored_drafts = []
|
|
2566
|
+
|
|
2567
|
+
for draft in drafts:
|
|
2568
|
+
score = 0
|
|
2569
|
+
|
|
2570
|
+
# Personalization score (0-100, normalize to 0-1)
|
|
2571
|
+
personalization_score = draft.get('personalization_score', 0) / 100
|
|
2572
|
+
score += personalization_score * criteria.get('personalization_weight', 0.4)
|
|
2573
|
+
|
|
2574
|
+
# Approach preference
|
|
2575
|
+
if draft.get('approach') == criteria.get('approach_preference'):
|
|
2576
|
+
score += 0.3
|
|
2577
|
+
|
|
2578
|
+
# Length preference
|
|
2579
|
+
word_count = len(draft.get('email_body', '').split())
|
|
2580
|
+
if criteria.get('length_preference') == 'short' and word_count < 150:
|
|
2581
|
+
score += 0.2
|
|
2582
|
+
elif criteria.get('length_preference') == 'medium' and 150 <= word_count <= 300:
|
|
2583
|
+
score += 0.2
|
|
2584
|
+
elif criteria.get('length_preference') == 'long' and word_count > 300:
|
|
2585
|
+
score += 0.2
|
|
2586
|
+
|
|
2587
|
+
# Tone preference
|
|
2588
|
+
if draft.get('tone', '').lower().find(criteria.get('tone_preference', '').lower()) != -1:
|
|
2589
|
+
score += 0.1
|
|
2590
|
+
|
|
2591
|
+
scored_drafts.append((draft, score))
|
|
2592
|
+
|
|
2593
|
+
# Sort by score and return best
|
|
2594
|
+
scored_drafts.sort(key=lambda x: x[1], reverse=True)
|
|
2595
|
+
best_draft = scored_drafts[0][0]
|
|
2596
|
+
|
|
2597
|
+
# Add selection metadata
|
|
2598
|
+
best_draft['selection_metadata'] = {
|
|
2599
|
+
'selected_at': datetime.now().isoformat(),
|
|
2600
|
+
'selection_score': scored_drafts[0][1],
|
|
2601
|
+
'criteria_used': criteria,
|
|
2602
|
+
'total_drafts_considered': len(drafts)
|
|
2603
|
+
}
|
|
2604
|
+
|
|
2605
|
+
return best_draft
|
|
2606
|
+
|
|
2607
|
+
except Exception as e:
|
|
2608
|
+
self.logger.error(f"Failed to select best draft: {str(e)}")
|
|
2609
|
+
return None
|
|
2610
|
+
|
|
2611
|
+
def get_draft_versions(self, original_draft_id: str) -> List[Dict[str, Any]]:
|
|
2612
|
+
"""Get all versions of a draft (original + rewrites)."""
|
|
2613
|
+
try:
|
|
2614
|
+
data_manager = self.data_manager
|
|
2615
|
+
|
|
2616
|
+
# Get original draft
|
|
2617
|
+
original_draft = self._get_draft_by_id(original_draft_id)
|
|
2618
|
+
if not original_draft:
|
|
2619
|
+
return []
|
|
2620
|
+
|
|
2621
|
+
versions = [original_draft]
|
|
2622
|
+
|
|
2623
|
+
# Get all rewrites of this draft
|
|
2624
|
+
rewrite_records = data_manager.get_email_drafts_by_original_id(original_draft_id)
|
|
2625
|
+
|
|
2626
|
+
for record in rewrite_records:
|
|
2627
|
+
# Parse metadata
|
|
2628
|
+
metadata = {}
|
|
2629
|
+
if record.get('metadata'):
|
|
2630
|
+
try:
|
|
2631
|
+
metadata = json.loads(record['metadata'])
|
|
2632
|
+
except json.JSONDecodeError:
|
|
2633
|
+
pass
|
|
2634
|
+
|
|
2635
|
+
rewrite_draft = {
|
|
2636
|
+
'draft_id': record['draft_id'],
|
|
2637
|
+
'execution_id': record['execution_id'],
|
|
2638
|
+
'subject': record['subject'],
|
|
2639
|
+
'content': record['content'],
|
|
2640
|
+
'approach': metadata.get('approach', 'rewritten'),
|
|
2641
|
+
'tone': metadata.get('tone', 'professional'),
|
|
2642
|
+
'personalization_score': metadata.get('personalization_score', 0),
|
|
2643
|
+
'status': record['status'],
|
|
2644
|
+
'version': record['version'],
|
|
2645
|
+
'created_at': record['created_at'],
|
|
2646
|
+
'updated_at': record['updated_at'],
|
|
2647
|
+
'rewrite_reason': metadata.get('rewrite_reason', ''),
|
|
2648
|
+
'original_draft_id': original_draft_id,
|
|
2649
|
+
'metadata': metadata
|
|
2650
|
+
}
|
|
2651
|
+
versions.append(rewrite_draft)
|
|
2652
|
+
|
|
2653
|
+
# Sort by version number
|
|
2654
|
+
versions.sort(key=lambda x: x.get('version', 1))
|
|
2655
|
+
|
|
2656
|
+
return versions
|
|
2657
|
+
|
|
2658
|
+
except Exception as e:
|
|
2659
|
+
self.logger.error(f"Failed to get draft versions for {original_draft_id}: {str(e)}")
|
|
2660
|
+
return []
|
|
2661
|
+
|
|
2662
|
+
def archive_draft(self, draft_id: str, reason: str = "Archived by user") -> bool:
|
|
2663
|
+
"""Archive a draft (mark as archived, don't delete)."""
|
|
2664
|
+
try:
|
|
2665
|
+
data_manager = self.data_manager
|
|
2666
|
+
|
|
2667
|
+
# Update draft status to archived
|
|
2668
|
+
success = data_manager.update_email_draft_status(draft_id, 'archived')
|
|
2669
|
+
|
|
2670
|
+
if success:
|
|
2671
|
+
self.logger.info(f"Archived draft {draft_id}: {reason}")
|
|
2672
|
+
return True
|
|
2673
|
+
else:
|
|
2674
|
+
self.logger.warning(f"Failed to archive draft {draft_id}")
|
|
2675
|
+
return False
|
|
2676
|
+
|
|
2677
|
+
except Exception as e:
|
|
2678
|
+
self.logger.error(f"Failed to archive draft {draft_id}: {str(e)}")
|
|
2679
|
+
return False
|
|
2680
|
+
|
|
2681
|
+
def duplicate_draft(self, draft_id: str, modifications: Dict[str, Any] = None) -> Optional[Dict[str, Any]]:
|
|
2682
|
+
"""Create a duplicate of an existing draft with optional modifications."""
|
|
2683
|
+
try:
|
|
2684
|
+
# Get original draft
|
|
2685
|
+
original_draft = self._get_draft_by_id(draft_id)
|
|
2686
|
+
if not original_draft:
|
|
2687
|
+
return None
|
|
2688
|
+
|
|
2689
|
+
# Create duplicate
|
|
2690
|
+
duplicate = original_draft.copy()
|
|
2691
|
+
duplicate['draft_id'] = f"uuid:{str(uuid.uuid4())}"
|
|
2692
|
+
duplicate['draft_approach'] = "duplicate"
|
|
2693
|
+
duplicate['draft_type'] = "duplicate"
|
|
2694
|
+
duplicate['version'] = 1
|
|
2695
|
+
duplicate['status'] = 'draft'
|
|
2696
|
+
duplicate['created_at'] = datetime.now().isoformat()
|
|
2697
|
+
duplicate['updated_at'] = datetime.now().isoformat()
|
|
2698
|
+
|
|
2699
|
+
# Apply modifications if provided
|
|
2700
|
+
if modifications:
|
|
2701
|
+
for key, value in modifications.items():
|
|
2702
|
+
if key in ['email_body', 'subject', 'subject_alternatives', 'approach', 'tone']:
|
|
2703
|
+
duplicate[key] = value
|
|
2704
|
+
|
|
2705
|
+
# Update metadata
|
|
2706
|
+
if 'metadata' not in duplicate:
|
|
2707
|
+
duplicate['metadata'] = {}
|
|
2708
|
+
duplicate['metadata']['duplicated_from'] = draft_id
|
|
2709
|
+
duplicate['metadata']['duplicated_at'] = datetime.now().isoformat()
|
|
2710
|
+
duplicate['metadata']['generation_method'] = 'duplicate'
|
|
2711
|
+
|
|
2712
|
+
# Save duplicate
|
|
2713
|
+
execution_id = duplicate.get('execution_id', 'unknown')
|
|
2714
|
+
saved_duplicate = self._save_email_drafts({'execution_id': execution_id}, [duplicate])
|
|
2715
|
+
|
|
2716
|
+
if saved_duplicate:
|
|
2717
|
+
return saved_duplicate[0]
|
|
2718
|
+
else:
|
|
2719
|
+
return None
|
|
2720
|
+
|
|
2721
|
+
except Exception as e:
|
|
2722
|
+
self.logger.error(f"Failed to duplicate draft {draft_id}: {str(e)}")
|
|
2723
|
+
return None
|
|
2724
|
+
|
|
2725
|
+
def _create_customer_summary(self, customer_data: Dict[str, Any]) -> Dict[str, Any]:
|
|
2726
|
+
"""Create comprehensive customer summary for outreach context."""
|
|
2727
|
+
company_info = customer_data.get('companyInfo', {})
|
|
2728
|
+
contact_info = customer_data.get('primaryContact', {})
|
|
2729
|
+
pain_points = customer_data.get('painPoints', [])
|
|
2730
|
+
|
|
2731
|
+
# Calculate summary metrics
|
|
2732
|
+
high_priority_pain_points = [p for p in pain_points if p.get('severity') == 'high']
|
|
2733
|
+
total_pain_points = len(pain_points)
|
|
2734
|
+
|
|
2735
|
+
return {
|
|
2736
|
+
'company_name': company_info.get('name', 'Unknown'),
|
|
2737
|
+
'industry': company_info.get('industry', 'Unknown'),
|
|
2738
|
+
'company_size': company_info.get('size', 'Unknown'),
|
|
2739
|
+
'annual_revenue': company_info.get('annualRevenue', 'Unknown'),
|
|
2740
|
+
'location': company_info.get('location', 'Unknown'),
|
|
2741
|
+
'website': company_info.get('website', 'Unknown'),
|
|
2742
|
+
'contact_name': contact_info.get('name', 'Unknown'),
|
|
2743
|
+
'contact_title': contact_info.get('title', 'Unknown'),
|
|
2744
|
+
'contact_email': contact_info.get('email', 'Unknown'),
|
|
2745
|
+
'contact_phone': contact_info.get('phone', 'Unknown'),
|
|
2746
|
+
'total_pain_points': total_pain_points,
|
|
2747
|
+
'high_priority_pain_points': len(high_priority_pain_points),
|
|
2748
|
+
'key_challenges': [p.get('description', '') for p in high_priority_pain_points[:3]],
|
|
2749
|
+
'business_profile': {
|
|
2750
|
+
'industry_focus': company_info.get('industry', 'Technology'),
|
|
2751
|
+
'company_stage': self._determine_company_stage(company_info),
|
|
2752
|
+
'technology_maturity': self._assess_technology_maturity(customer_data),
|
|
2753
|
+
'growth_indicators': self._identify_growth_indicators(customer_data)
|
|
2754
|
+
},
|
|
2755
|
+
'outreach_readiness': self._calculate_outreach_readiness(customer_data),
|
|
2756
|
+
'summary_generated_at': datetime.now().isoformat()
|
|
2757
|
+
}
|
|
2758
|
+
|
|
2759
|
+
def _determine_company_stage(self, company_info: Dict[str, Any]) -> str:
|
|
2760
|
+
"""Determine company stage based on size and revenue."""
|
|
2761
|
+
size = company_info.get('size', '').lower()
|
|
2762
|
+
revenue = company_info.get('annualRevenue', '').lower()
|
|
2763
|
+
|
|
2764
|
+
if 'startup' in size or 'small' in size:
|
|
2765
|
+
return 'startup'
|
|
2766
|
+
elif 'medium' in size or 'mid' in size:
|
|
2767
|
+
return 'growth'
|
|
2768
|
+
elif 'large' in size or 'enterprise' in size:
|
|
2769
|
+
return 'enterprise'
|
|
2770
|
+
elif any(indicator in revenue for indicator in ['million', 'billion']):
|
|
2771
|
+
return 'established'
|
|
2772
|
+
else:
|
|
2773
|
+
return 'unknown'
|
|
2774
|
+
|
|
2775
|
+
def _assess_technology_maturity(self, customer_data: Dict[str, Any]) -> str:
|
|
2776
|
+
"""Assess technology maturity based on available data."""
|
|
2777
|
+
tech_info = customer_data.get('technologyAndInnovation', {})
|
|
2778
|
+
pain_points = customer_data.get('painPoints', [])
|
|
2779
|
+
|
|
2780
|
+
# Look for technology-related indicators
|
|
2781
|
+
tech_keywords = ['digital', 'automation', 'cloud', 'ai', 'software', 'platform']
|
|
2782
|
+
legacy_keywords = ['manual', 'paper', 'outdated', 'legacy', 'traditional']
|
|
2783
|
+
|
|
2784
|
+
tech_score = 0
|
|
2785
|
+
legacy_score = 0
|
|
2786
|
+
|
|
2787
|
+
# Check technology info
|
|
2788
|
+
tech_text = str(tech_info).lower()
|
|
2789
|
+
for keyword in tech_keywords:
|
|
2790
|
+
if keyword in tech_text:
|
|
2791
|
+
tech_score += 1
|
|
2792
|
+
for keyword in legacy_keywords:
|
|
2793
|
+
if keyword in tech_text:
|
|
2794
|
+
legacy_score += 1
|
|
2795
|
+
|
|
2796
|
+
# Check pain points
|
|
2797
|
+
for pain_point in pain_points:
|
|
2798
|
+
description = pain_point.get('description', '').lower()
|
|
2799
|
+
for keyword in tech_keywords:
|
|
2800
|
+
if keyword in description:
|
|
2801
|
+
tech_score += 1
|
|
2802
|
+
for keyword in legacy_keywords:
|
|
2803
|
+
if keyword in description:
|
|
2804
|
+
legacy_score += 1
|
|
2805
|
+
|
|
2806
|
+
if tech_score > legacy_score + 2:
|
|
2807
|
+
return 'advanced'
|
|
2808
|
+
elif tech_score > legacy_score:
|
|
2809
|
+
return 'moderate'
|
|
2810
|
+
elif legacy_score > tech_score:
|
|
2811
|
+
return 'traditional'
|
|
2812
|
+
else:
|
|
2813
|
+
return 'mixed'
|
|
2814
|
+
|
|
2815
|
+
def _identify_growth_indicators(self, customer_data: Dict[str, Any]) -> List[str]:
|
|
2816
|
+
"""Identify growth indicators from customer data."""
|
|
2817
|
+
indicators = []
|
|
2818
|
+
|
|
2819
|
+
company_info = customer_data.get('companyInfo', {})
|
|
2820
|
+
development_plans = customer_data.get('developmentPlans', {})
|
|
2821
|
+
pain_points = customer_data.get('painPoints', [])
|
|
2822
|
+
|
|
2823
|
+
# Check for growth keywords
|
|
2824
|
+
growth_keywords = {
|
|
2825
|
+
'expansion': 'Market expansion plans',
|
|
2826
|
+
'scaling': 'Scaling operations',
|
|
2827
|
+
'hiring': 'Team growth',
|
|
2828
|
+
'funding': 'Recent funding',
|
|
2829
|
+
'new market': 'New market entry',
|
|
2830
|
+
'international': 'International expansion',
|
|
2831
|
+
'acquisition': 'Acquisition activity',
|
|
2832
|
+
'partnership': 'Strategic partnerships'
|
|
2833
|
+
}
|
|
2834
|
+
|
|
2835
|
+
# Check development plans
|
|
2836
|
+
dev_text = str(development_plans).lower()
|
|
2837
|
+
for keyword, indicator in growth_keywords.items():
|
|
2838
|
+
if keyword in dev_text:
|
|
2839
|
+
indicators.append(indicator)
|
|
2840
|
+
|
|
2841
|
+
# Check pain points for growth-related challenges
|
|
2842
|
+
for pain_point in pain_points:
|
|
2843
|
+
description = pain_point.get('description', '').lower()
|
|
2844
|
+
if any(keyword in description for keyword in ['capacity', 'demand', 'volume', 'growth']):
|
|
2845
|
+
indicators.append('Growth-related challenges')
|
|
2846
|
+
break
|
|
2847
|
+
|
|
2848
|
+
return list(set(indicators)) # Remove duplicates
|
|
2849
|
+
|
|
2850
|
+
def _calculate_outreach_readiness(self, customer_data: Dict[str, Any]) -> Dict[str, Any]:
|
|
2851
|
+
"""Calculate readiness score for outreach."""
|
|
2852
|
+
score = 0
|
|
2853
|
+
factors = []
|
|
2854
|
+
|
|
2855
|
+
company_info = customer_data.get('companyInfo', {})
|
|
2856
|
+
contact_info = customer_data.get('primaryContact', {})
|
|
2857
|
+
pain_points = customer_data.get('painPoints', [])
|
|
2858
|
+
|
|
2859
|
+
# Contact information completeness (0-30 points)
|
|
2860
|
+
if contact_info.get('name'):
|
|
2861
|
+
score += 10
|
|
2862
|
+
factors.append('Contact name available')
|
|
2863
|
+
if contact_info.get('email'):
|
|
2864
|
+
score += 15
|
|
2865
|
+
factors.append('Email address available')
|
|
2866
|
+
if contact_info.get('title'):
|
|
2867
|
+
score += 5
|
|
2868
|
+
factors.append('Contact title available')
|
|
2869
|
+
|
|
2870
|
+
# Company information completeness (0-30 points)
|
|
2871
|
+
if company_info.get('name'):
|
|
2872
|
+
score += 10
|
|
2873
|
+
factors.append('Company name available')
|
|
2874
|
+
if company_info.get('industry'):
|
|
2875
|
+
score += 10
|
|
2876
|
+
factors.append('Industry information available')
|
|
2877
|
+
if company_info.get('size') or company_info.get('annualRevenue'):
|
|
2878
|
+
score += 10
|
|
2879
|
+
factors.append('Company size/revenue information available')
|
|
2880
|
+
|
|
2881
|
+
# Pain points quality (0-40 points)
|
|
2882
|
+
high_severity_points = [p for p in pain_points if p.get('severity') == 'high']
|
|
2883
|
+
medium_severity_points = [p for p in pain_points if p.get('severity') == 'medium']
|
|
2884
|
+
|
|
2885
|
+
if high_severity_points:
|
|
2886
|
+
score += 25
|
|
2887
|
+
factors.append(f'{len(high_severity_points)} high-severity pain points identified')
|
|
2888
|
+
if medium_severity_points:
|
|
2889
|
+
score += 15
|
|
2890
|
+
factors.append(f'{len(medium_severity_points)} medium-severity pain points identified')
|
|
2891
|
+
|
|
2892
|
+
# Determine readiness level
|
|
2893
|
+
if score >= 80:
|
|
2894
|
+
readiness_level = 'high'
|
|
2895
|
+
elif score >= 60:
|
|
2896
|
+
readiness_level = 'medium'
|
|
2897
|
+
elif score >= 40:
|
|
2898
|
+
readiness_level = 'low'
|
|
2899
|
+
else:
|
|
2900
|
+
readiness_level = 'insufficient'
|
|
2901
|
+
|
|
2902
|
+
return {
|
|
2903
|
+
'score': score,
|
|
2904
|
+
'level': readiness_level,
|
|
2905
|
+
'factors': factors,
|
|
2906
|
+
'recommendations': self._get_readiness_recommendations(score, factors)
|
|
2907
|
+
}
|
|
2908
|
+
|
|
2909
|
+
def _get_readiness_recommendations(self, score: int, factors: List[str]) -> List[str]:
|
|
2910
|
+
"""Get recommendations based on readiness score."""
|
|
2911
|
+
recommendations = []
|
|
2912
|
+
|
|
2913
|
+
if score < 40:
|
|
2914
|
+
recommendations.append('Gather more customer information before outreach')
|
|
2915
|
+
recommendations.append('Focus on identifying key pain points')
|
|
2916
|
+
elif score < 60:
|
|
2917
|
+
recommendations.append('Consider additional research on company background')
|
|
2918
|
+
recommendations.append('Verify contact information accuracy')
|
|
2919
|
+
elif score < 80:
|
|
2920
|
+
recommendations.append('Outreach ready with minor improvements possible')
|
|
2921
|
+
recommendations.append('Consider personalizing based on specific pain points')
|
|
2922
|
+
else:
|
|
2923
|
+
recommendations.append('Excellent outreach readiness')
|
|
2924
|
+
recommendations.append('Proceed with highly personalized outreach')
|
|
2925
|
+
|
|
2926
|
+
return recommendations
|
|
2927
|
+
|
|
2928
|
+
def validate_input(self, context: Dict[str, Any]) -> bool:
|
|
2929
|
+
"""
|
|
2930
|
+
Validate input data for initial outreach stage (server schema compliant).
|
|
2931
|
+
|
|
2932
|
+
Args:
|
|
2933
|
+
context: Execution context
|
|
2934
|
+
|
|
2935
|
+
Returns:
|
|
2936
|
+
True if input is valid
|
|
2937
|
+
"""
|
|
2938
|
+
input_data = context.get('input_data', {})
|
|
2939
|
+
|
|
2940
|
+
# Check for required server schema fields
|
|
2941
|
+
required_fields = [
|
|
2942
|
+
'org_id', 'org_name', 'customer_name', 'customer_id',
|
|
2943
|
+
'interaction_type', 'action', 'language', 'recipient_address',
|
|
2944
|
+
'recipient_name', 'staff_name', 'team_id', 'team_name'
|
|
2945
|
+
]
|
|
2946
|
+
|
|
2947
|
+
# For draft_write action, we need data from previous stages or structured input
|
|
2948
|
+
action = input_data.get('action', 'draft_write')
|
|
2949
|
+
|
|
2950
|
+
if action == 'draft_write':
|
|
2951
|
+
# Check if we have data from previous stages OR structured input
|
|
2952
|
+
stage_results = context.get('stage_results', {})
|
|
2953
|
+
has_stage_data = 'data_preparation' in stage_results and 'lead_scoring' in stage_results
|
|
2954
|
+
has_structured_input = input_data.get('companyInfo') and input_data.get('pain_points')
|
|
2955
|
+
|
|
2956
|
+
return has_stage_data or has_structured_input
|
|
2957
|
+
|
|
2958
|
+
# For other actions, basic validation
|
|
2959
|
+
return bool(input_data.get('org_id') and input_data.get('action'))
|
|
2960
|
+
|
|
2961
|
+
def get_required_fields(self) -> List[str]:
|
|
2962
|
+
"""
|
|
2963
|
+
Get list of required input fields for this stage.
|
|
2964
|
+
|
|
2965
|
+
Returns:
|
|
2966
|
+
List of required field names
|
|
2967
|
+
"""
|
|
2968
|
+
return [
|
|
2969
|
+
'org_id', 'org_name', 'customer_name', 'customer_id',
|
|
2970
|
+
'interaction_type', 'action', 'language', 'recipient_address',
|
|
2971
|
+
'recipient_name', 'staff_name', 'team_id', 'team_name'
|
|
2972
|
+
]
|