fusesell 1.2.0__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.
Potentially problematic release.
This version of fusesell might be problematic. Click here for more details.
- fusesell-1.2.0.dist-info/METADATA +872 -0
- fusesell-1.2.0.dist-info/RECORD +31 -0
- fusesell-1.2.0.dist-info/WHEEL +5 -0
- fusesell-1.2.0.dist-info/entry_points.txt +2 -0
- fusesell-1.2.0.dist-info/licenses/LICENSE +21 -0
- fusesell-1.2.0.dist-info/top_level.txt +2 -0
- fusesell.py +15 -0
- fusesell_local/__init__.py +37 -0
- fusesell_local/api.py +341 -0
- fusesell_local/cli.py +1450 -0
- fusesell_local/config/__init__.py +11 -0
- fusesell_local/config/prompts.py +245 -0
- fusesell_local/config/settings.py +277 -0
- fusesell_local/pipeline.py +932 -0
- fusesell_local/stages/__init__.py +19 -0
- fusesell_local/stages/base_stage.py +602 -0
- fusesell_local/stages/data_acquisition.py +1820 -0
- fusesell_local/stages/data_preparation.py +1231 -0
- fusesell_local/stages/follow_up.py +1590 -0
- fusesell_local/stages/initial_outreach.py +2337 -0
- fusesell_local/stages/lead_scoring.py +1452 -0
- fusesell_local/tests/test_api.py +65 -0
- fusesell_local/tests/test_cli.py +37 -0
- fusesell_local/utils/__init__.py +15 -0
- fusesell_local/utils/birthday_email_manager.py +467 -0
- fusesell_local/utils/data_manager.py +4050 -0
- fusesell_local/utils/event_scheduler.py +618 -0
- fusesell_local/utils/llm_client.py +283 -0
- fusesell_local/utils/logger.py +203 -0
- fusesell_local/utils/timezone_detector.py +914 -0
- fusesell_local/utils/validators.py +416 -0
|
@@ -0,0 +1,2337 @@
|
|
|
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 execute(self, context: Dict[str, Any]) -> Dict[str, Any]:
|
|
22
|
+
"""
|
|
23
|
+
Execute initial outreach stage with action-based routing (matching server executor).
|
|
24
|
+
|
|
25
|
+
Actions supported:
|
|
26
|
+
- draft_write: Generate new email drafts
|
|
27
|
+
- draft_rewrite: Modify existing draft using selected_draft_id
|
|
28
|
+
- send: Send approved draft to recipient_address
|
|
29
|
+
- close: Close outreach when customer feels negative
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
context: Execution context
|
|
33
|
+
|
|
34
|
+
Returns:
|
|
35
|
+
Stage execution result
|
|
36
|
+
"""
|
|
37
|
+
try:
|
|
38
|
+
# Get action from input data (matching server schema)
|
|
39
|
+
input_data = context.get('input_data', {})
|
|
40
|
+
action = input_data.get('action', 'draft_write') # Default to draft_write
|
|
41
|
+
|
|
42
|
+
self.logger.info(f"Executing initial outreach with action: {action}")
|
|
43
|
+
|
|
44
|
+
# Validate required fields based on action
|
|
45
|
+
self._validate_action_input(action, input_data)
|
|
46
|
+
|
|
47
|
+
# Route based on action type (matching server executor schema)
|
|
48
|
+
if action == 'draft_write':
|
|
49
|
+
return self._handle_draft_write(context)
|
|
50
|
+
elif action == 'draft_rewrite':
|
|
51
|
+
return self._handle_draft_rewrite(context)
|
|
52
|
+
elif action == 'send':
|
|
53
|
+
return self._handle_send(context)
|
|
54
|
+
elif action == 'close':
|
|
55
|
+
return self._handle_close(context)
|
|
56
|
+
else:
|
|
57
|
+
raise ValueError(f"Invalid action: {action}. Must be one of: draft_write, draft_rewrite, send, close")
|
|
58
|
+
|
|
59
|
+
except Exception as e:
|
|
60
|
+
self.log_stage_error(context, e)
|
|
61
|
+
return self.handle_stage_error(e, context)
|
|
62
|
+
|
|
63
|
+
def _validate_action_input(self, action: str, input_data: Dict[str, Any]) -> None:
|
|
64
|
+
"""
|
|
65
|
+
Validate required fields based on action type.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
action: Action type
|
|
69
|
+
input_data: Input data
|
|
70
|
+
|
|
71
|
+
Raises:
|
|
72
|
+
ValueError: If required fields are missing
|
|
73
|
+
"""
|
|
74
|
+
if action in ['draft_rewrite', 'send']:
|
|
75
|
+
if not input_data.get('selected_draft_id'):
|
|
76
|
+
raise ValueError(f"selected_draft_id is required for {action} action")
|
|
77
|
+
|
|
78
|
+
if action == 'send':
|
|
79
|
+
if not input_data.get('recipient_address'):
|
|
80
|
+
raise ValueError("recipient_address is required for send action")
|
|
81
|
+
|
|
82
|
+
def _handle_draft_write(self, context: Dict[str, Any]) -> Dict[str, Any]:
|
|
83
|
+
"""
|
|
84
|
+
Handle draft_write action - Generate new email drafts.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
context: Execution context
|
|
88
|
+
|
|
89
|
+
Returns:
|
|
90
|
+
Stage execution result with new drafts
|
|
91
|
+
"""
|
|
92
|
+
# Get data from previous stages
|
|
93
|
+
customer_data = self._get_customer_data(context)
|
|
94
|
+
scoring_data = self._get_scoring_data(context)
|
|
95
|
+
|
|
96
|
+
# Get the best product recommendation
|
|
97
|
+
recommended_product = self._get_recommended_product(scoring_data)
|
|
98
|
+
|
|
99
|
+
if not recommended_product:
|
|
100
|
+
raise ValueError("No product recommendation available for email generation")
|
|
101
|
+
|
|
102
|
+
# Generate multiple email drafts
|
|
103
|
+
email_drafts = self._generate_email_drafts(customer_data, recommended_product, scoring_data, context)
|
|
104
|
+
|
|
105
|
+
# Save drafts to local files and database
|
|
106
|
+
saved_drafts = self._save_email_drafts(context, email_drafts)
|
|
107
|
+
|
|
108
|
+
# Prepare final output
|
|
109
|
+
outreach_data = {
|
|
110
|
+
'action': 'draft_write',
|
|
111
|
+
'status': 'drafts_generated',
|
|
112
|
+
'email_drafts': saved_drafts,
|
|
113
|
+
'recommended_product': recommended_product,
|
|
114
|
+
'customer_summary': self._create_customer_summary(customer_data),
|
|
115
|
+
'total_drafts_generated': len(saved_drafts),
|
|
116
|
+
'generation_timestamp': datetime.now().isoformat(),
|
|
117
|
+
'customer_id': context.get('execution_id')
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
# Save to database
|
|
121
|
+
self.save_stage_result(context, outreach_data)
|
|
122
|
+
|
|
123
|
+
result = self.create_success_result(outreach_data, context)
|
|
124
|
+
return result
|
|
125
|
+
|
|
126
|
+
def _handle_draft_rewrite(self, context: Dict[str, Any]) -> Dict[str, Any]:
|
|
127
|
+
"""
|
|
128
|
+
Handle draft_rewrite action - Modify existing draft using selected_draft_id.
|
|
129
|
+
|
|
130
|
+
Args:
|
|
131
|
+
context: Execution context
|
|
132
|
+
|
|
133
|
+
Returns:
|
|
134
|
+
Stage execution result with rewritten draft
|
|
135
|
+
"""
|
|
136
|
+
input_data = context.get('input_data', {})
|
|
137
|
+
selected_draft_id = input_data.get('selected_draft_id')
|
|
138
|
+
reason = input_data.get('reason', 'No reason provided')
|
|
139
|
+
|
|
140
|
+
# Retrieve existing draft
|
|
141
|
+
existing_draft = self._get_draft_by_id(selected_draft_id)
|
|
142
|
+
if not existing_draft:
|
|
143
|
+
raise ValueError(f"Draft not found: {selected_draft_id}")
|
|
144
|
+
|
|
145
|
+
# Get customer data for context
|
|
146
|
+
customer_data = self._get_customer_data(context)
|
|
147
|
+
scoring_data = self._get_scoring_data(context)
|
|
148
|
+
|
|
149
|
+
# Rewrite the draft based on reason
|
|
150
|
+
rewritten_draft = self._rewrite_draft(existing_draft, reason, customer_data, scoring_data, context)
|
|
151
|
+
|
|
152
|
+
# Save the rewritten draft
|
|
153
|
+
saved_draft = self._save_rewritten_draft(context, rewritten_draft, selected_draft_id)
|
|
154
|
+
|
|
155
|
+
# Prepare output
|
|
156
|
+
outreach_data = {
|
|
157
|
+
'action': 'draft_rewrite',
|
|
158
|
+
'status': 'draft_rewritten',
|
|
159
|
+
'original_draft_id': selected_draft_id,
|
|
160
|
+
'rewritten_draft': saved_draft,
|
|
161
|
+
'rewrite_reason': reason,
|
|
162
|
+
'generation_timestamp': datetime.now().isoformat(),
|
|
163
|
+
'customer_id': context.get('execution_id')
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
# Save to database
|
|
167
|
+
self.save_stage_result(context, outreach_data)
|
|
168
|
+
|
|
169
|
+
result = self.create_success_result(outreach_data, context)
|
|
170
|
+
# Logging handled by execute_with_timing wrapper
|
|
171
|
+
|
|
172
|
+
return result
|
|
173
|
+
|
|
174
|
+
def _handle_send(self, context: Dict[str, Any]) -> Dict[str, Any]:
|
|
175
|
+
"""
|
|
176
|
+
Handle send action - Send approved draft to recipient (with optional scheduling).
|
|
177
|
+
|
|
178
|
+
Args:
|
|
179
|
+
context: Execution context
|
|
180
|
+
|
|
181
|
+
Returns:
|
|
182
|
+
Stage execution result with send status
|
|
183
|
+
"""
|
|
184
|
+
input_data = context.get('input_data', {})
|
|
185
|
+
selected_draft_id = input_data.get('selected_draft_id')
|
|
186
|
+
recipient_address = input_data.get('recipient_address')
|
|
187
|
+
recipient_name = input_data.get('recipient_name', 'Dear Customer')
|
|
188
|
+
send_immediately = input_data.get('send_immediately', False) # New parameter for immediate sending
|
|
189
|
+
|
|
190
|
+
# Retrieve the draft to send
|
|
191
|
+
draft_to_send = self._get_draft_by_id(selected_draft_id)
|
|
192
|
+
if not draft_to_send:
|
|
193
|
+
raise ValueError(f"Draft not found: {selected_draft_id}")
|
|
194
|
+
|
|
195
|
+
# Check if we should send immediately or schedule
|
|
196
|
+
if send_immediately:
|
|
197
|
+
# Send immediately
|
|
198
|
+
send_result = self._send_email(draft_to_send, recipient_address, recipient_name, context)
|
|
199
|
+
|
|
200
|
+
outreach_data = {
|
|
201
|
+
'action': 'send',
|
|
202
|
+
'status': 'email_sent' if send_result['success'] else 'send_failed',
|
|
203
|
+
'draft_id': selected_draft_id,
|
|
204
|
+
'recipient_address': recipient_address,
|
|
205
|
+
'recipient_name': recipient_name,
|
|
206
|
+
'send_result': send_result,
|
|
207
|
+
'sent_timestamp': datetime.now().isoformat(),
|
|
208
|
+
'customer_id': context.get('execution_id'),
|
|
209
|
+
'scheduling': 'immediate'
|
|
210
|
+
}
|
|
211
|
+
else:
|
|
212
|
+
# Schedule for optimal time
|
|
213
|
+
schedule_result = self._schedule_email(draft_to_send, recipient_address, recipient_name, context)
|
|
214
|
+
|
|
215
|
+
outreach_data = {
|
|
216
|
+
'action': 'send',
|
|
217
|
+
'status': 'email_scheduled' if schedule_result['success'] else 'schedule_failed',
|
|
218
|
+
'draft_id': selected_draft_id,
|
|
219
|
+
'recipient_address': recipient_address,
|
|
220
|
+
'recipient_name': recipient_name,
|
|
221
|
+
'schedule_result': schedule_result,
|
|
222
|
+
'scheduled_timestamp': datetime.now().isoformat(),
|
|
223
|
+
'customer_id': context.get('execution_id'),
|
|
224
|
+
'scheduling': 'delayed'
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
# Save to database
|
|
228
|
+
self.save_stage_result(context, outreach_data)
|
|
229
|
+
|
|
230
|
+
result = self.create_success_result(outreach_data, context)
|
|
231
|
+
# Logging handled by execute_with_timing wrapper
|
|
232
|
+
|
|
233
|
+
return result
|
|
234
|
+
|
|
235
|
+
def _schedule_email(self, draft: Dict[str, Any], recipient_address: str, recipient_name: str, context: Dict[str, Any]) -> Dict[str, Any]:
|
|
236
|
+
"""
|
|
237
|
+
Schedule email event in database for external app to handle.
|
|
238
|
+
|
|
239
|
+
Args:
|
|
240
|
+
draft: Email draft to send
|
|
241
|
+
recipient_address: Email address of recipient
|
|
242
|
+
recipient_name: Name of recipient
|
|
243
|
+
context: Execution context
|
|
244
|
+
|
|
245
|
+
Returns:
|
|
246
|
+
Scheduling result
|
|
247
|
+
"""
|
|
248
|
+
try:
|
|
249
|
+
from ..utils.event_scheduler import EventScheduler
|
|
250
|
+
|
|
251
|
+
input_data = context.get('input_data', {})
|
|
252
|
+
|
|
253
|
+
# Initialize event scheduler
|
|
254
|
+
scheduler = EventScheduler(self.config.get('data_dir', './fusesell_data'))
|
|
255
|
+
|
|
256
|
+
# Check if immediate sending is requested
|
|
257
|
+
send_immediately = input_data.get('send_immediately', False)
|
|
258
|
+
|
|
259
|
+
# Schedule the email event
|
|
260
|
+
schedule_result = scheduler.schedule_email_event(
|
|
261
|
+
draft_id=draft.get('draft_id'),
|
|
262
|
+
recipient_address=recipient_address,
|
|
263
|
+
recipient_name=recipient_name,
|
|
264
|
+
org_id=input_data.get('org_id', 'default'),
|
|
265
|
+
team_id=input_data.get('team_id'),
|
|
266
|
+
customer_timezone=input_data.get('customer_timezone'),
|
|
267
|
+
email_type='initial',
|
|
268
|
+
send_immediately=send_immediately
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
if schedule_result['success']:
|
|
272
|
+
self.logger.info(f"Email event scheduled successfully: {schedule_result['event_id']} for {schedule_result['scheduled_time']}")
|
|
273
|
+
return {
|
|
274
|
+
'success': True,
|
|
275
|
+
'message': f'Email event scheduled for {schedule_result["scheduled_time"]}',
|
|
276
|
+
'event_id': schedule_result['event_id'],
|
|
277
|
+
'scheduled_time': schedule_result['scheduled_time'],
|
|
278
|
+
'follow_up_event_id': schedule_result.get('follow_up_event_id'),
|
|
279
|
+
'service': 'Database Event Scheduler'
|
|
280
|
+
}
|
|
281
|
+
else:
|
|
282
|
+
self.logger.error(f"Email event scheduling failed: {schedule_result.get('error', 'Unknown error')}")
|
|
283
|
+
return {
|
|
284
|
+
'success': False,
|
|
285
|
+
'message': f'Email event scheduling failed: {schedule_result.get("error", "Unknown error")}',
|
|
286
|
+
'error': schedule_result.get('error')
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
except Exception as e:
|
|
290
|
+
self.logger.error(f"Email scheduling failed: {str(e)}")
|
|
291
|
+
return {
|
|
292
|
+
'success': False,
|
|
293
|
+
'message': f'Email scheduling failed: {str(e)}',
|
|
294
|
+
'error': str(e)
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
def _handle_close(self, context: Dict[str, Any]) -> Dict[str, Any]:
|
|
298
|
+
"""
|
|
299
|
+
Handle close action - Close outreach when customer feels negative.
|
|
300
|
+
|
|
301
|
+
Args:
|
|
302
|
+
context: Execution context
|
|
303
|
+
|
|
304
|
+
Returns:
|
|
305
|
+
Stage execution result with close status
|
|
306
|
+
"""
|
|
307
|
+
input_data = context.get('input_data', {})
|
|
308
|
+
reason = input_data.get('reason', 'Customer not interested')
|
|
309
|
+
|
|
310
|
+
# Prepare output
|
|
311
|
+
outreach_data = {
|
|
312
|
+
'action': 'close',
|
|
313
|
+
'status': 'outreach_closed',
|
|
314
|
+
'close_reason': reason,
|
|
315
|
+
'closed_timestamp': datetime.now().isoformat(),
|
|
316
|
+
'customer_id': context.get('execution_id')
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
# Save to database
|
|
320
|
+
self.save_stage_result(context, outreach_data)
|
|
321
|
+
|
|
322
|
+
result = self.create_success_result(outreach_data, context)
|
|
323
|
+
# Logging handled by execute_with_timing wrapper
|
|
324
|
+
|
|
325
|
+
return result
|
|
326
|
+
|
|
327
|
+
def _get_customer_data(self, context: Dict[str, Any]) -> Dict[str, Any]:
|
|
328
|
+
"""Get customer data from previous stages or input."""
|
|
329
|
+
# Try to get from stage results first
|
|
330
|
+
stage_results = context.get('stage_results', {})
|
|
331
|
+
if 'data_preparation' in stage_results:
|
|
332
|
+
return stage_results['data_preparation'].get('data', {})
|
|
333
|
+
|
|
334
|
+
# Fallback: get from input_data (for server compatibility)
|
|
335
|
+
input_data = context.get('input_data', {})
|
|
336
|
+
return {
|
|
337
|
+
'companyInfo': input_data.get('companyInfo', {}),
|
|
338
|
+
'primaryContact': input_data.get('primaryContact', {}),
|
|
339
|
+
'painPoints': input_data.get('pain_points', [])
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
def _get_scoring_data(self, context: Dict[str, Any]) -> Dict[str, Any]:
|
|
343
|
+
"""Get scoring data from previous stages or input."""
|
|
344
|
+
# Try to get from stage results first
|
|
345
|
+
stage_results = context.get('stage_results', {})
|
|
346
|
+
if 'lead_scoring' in stage_results:
|
|
347
|
+
return stage_results['lead_scoring'].get('data', {})
|
|
348
|
+
|
|
349
|
+
# Fallback: get from input_data (for server compatibility)
|
|
350
|
+
input_data = context.get('input_data', {})
|
|
351
|
+
return {
|
|
352
|
+
'lead_scoring': input_data.get('lead_scoring', [])
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
def _get_recommended_product(self, scoring_data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
|
356
|
+
"""Get recommended product from scoring data."""
|
|
357
|
+
try:
|
|
358
|
+
# Try to get from analysis first
|
|
359
|
+
analysis = scoring_data.get('analysis', {})
|
|
360
|
+
if 'recommended_product' in analysis:
|
|
361
|
+
return analysis['recommended_product']
|
|
362
|
+
|
|
363
|
+
# Fallback: get highest scoring product
|
|
364
|
+
lead_scores = scoring_data.get('lead_scoring', [])
|
|
365
|
+
if lead_scores:
|
|
366
|
+
sorted_scores = sorted(lead_scores, key=lambda x: x.get('total_weighted_score', 0), reverse=True)
|
|
367
|
+
top_score = sorted_scores[0]
|
|
368
|
+
return {
|
|
369
|
+
'product_name': top_score.get('product_name'),
|
|
370
|
+
'product_id': top_score.get('product_id'),
|
|
371
|
+
'score': top_score.get('total_weighted_score')
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
return None
|
|
375
|
+
except Exception as e:
|
|
376
|
+
self.logger.error(f"Failed to get recommended product: {str(e)}")
|
|
377
|
+
return None
|
|
378
|
+
|
|
379
|
+
def _get_auto_interaction_config(self, team_id: str = None) -> Dict[str, Any]:
|
|
380
|
+
"""
|
|
381
|
+
Get auto interaction configuration from team settings.
|
|
382
|
+
|
|
383
|
+
Args:
|
|
384
|
+
team_id: Team ID to get settings for
|
|
385
|
+
|
|
386
|
+
Returns:
|
|
387
|
+
Auto interaction configuration dictionary with from_email, from_name, etc.
|
|
388
|
+
If multiple configs exist, returns the first Email type config.
|
|
389
|
+
"""
|
|
390
|
+
default_config = {
|
|
391
|
+
'from_email': '',
|
|
392
|
+
'from_name': '',
|
|
393
|
+
'from_number': '',
|
|
394
|
+
'tool_type': 'Email',
|
|
395
|
+
'email_cc': '',
|
|
396
|
+
'email_bcc': ''
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
if not team_id:
|
|
400
|
+
return default_config
|
|
401
|
+
|
|
402
|
+
try:
|
|
403
|
+
# Get team settings
|
|
404
|
+
auto_interaction_settings = self.get_team_setting('gs_team_auto_interaction', team_id, [])
|
|
405
|
+
|
|
406
|
+
if not auto_interaction_settings or not isinstance(auto_interaction_settings, list):
|
|
407
|
+
self.logger.debug(f"No auto interaction settings found for team {team_id}, using defaults")
|
|
408
|
+
return default_config
|
|
409
|
+
|
|
410
|
+
# Find Email type configuration (preferred for email sending)
|
|
411
|
+
email_config = None
|
|
412
|
+
for config in auto_interaction_settings:
|
|
413
|
+
if config.get('tool_type') == 'Email':
|
|
414
|
+
email_config = config
|
|
415
|
+
break
|
|
416
|
+
|
|
417
|
+
# If no Email config found, use the first one available
|
|
418
|
+
if not email_config and len(auto_interaction_settings) > 0:
|
|
419
|
+
email_config = auto_interaction_settings[0]
|
|
420
|
+
self.logger.warning(f"No Email tool_type found in auto interaction settings, using first config with tool_type: {email_config.get('tool_type')}")
|
|
421
|
+
|
|
422
|
+
if email_config:
|
|
423
|
+
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')}")
|
|
424
|
+
return email_config
|
|
425
|
+
else:
|
|
426
|
+
return default_config
|
|
427
|
+
|
|
428
|
+
except Exception as e:
|
|
429
|
+
self.logger.error(f"Failed to get auto interaction config for team {team_id}: {str(e)}")
|
|
430
|
+
return default_config
|
|
431
|
+
|
|
432
|
+
def _generate_email_drafts(self, customer_data: Dict[str, Any], recommended_product: Dict[str, Any], scoring_data: Dict[str, Any], context: Dict[str, Any]) -> List[Dict[str, Any]]:
|
|
433
|
+
"""Generate multiple personalized email drafts using LLM."""
|
|
434
|
+
if self.is_dry_run():
|
|
435
|
+
return self._get_mock_email_drafts(customer_data, recommended_product, context)
|
|
436
|
+
|
|
437
|
+
try:
|
|
438
|
+
input_data = context.get('input_data', {})
|
|
439
|
+
company_info = customer_data.get('companyInfo', {})
|
|
440
|
+
contact_info = customer_data.get('primaryContact', {})
|
|
441
|
+
pain_points = customer_data.get('painPoints', [])
|
|
442
|
+
|
|
443
|
+
prompt_drafts = self._generate_email_drafts_from_prompt(
|
|
444
|
+
customer_data,
|
|
445
|
+
recommended_product,
|
|
446
|
+
scoring_data,
|
|
447
|
+
context
|
|
448
|
+
)
|
|
449
|
+
if prompt_drafts:
|
|
450
|
+
return prompt_drafts
|
|
451
|
+
|
|
452
|
+
# Generate multiple draft variations with different approaches
|
|
453
|
+
draft_approaches = [
|
|
454
|
+
{
|
|
455
|
+
'name': 'professional_direct',
|
|
456
|
+
'tone': 'professional and direct',
|
|
457
|
+
'focus': 'business value and ROI',
|
|
458
|
+
'length': 'concise'
|
|
459
|
+
},
|
|
460
|
+
{
|
|
461
|
+
'name': 'consultative',
|
|
462
|
+
'tone': 'consultative and helpful',
|
|
463
|
+
'focus': 'solving specific pain points',
|
|
464
|
+
'length': 'medium'
|
|
465
|
+
},
|
|
466
|
+
{
|
|
467
|
+
'name': 'industry_expert',
|
|
468
|
+
'tone': 'industry expert and insightful',
|
|
469
|
+
'focus': 'industry trends and challenges',
|
|
470
|
+
'length': 'detailed'
|
|
471
|
+
},
|
|
472
|
+
{
|
|
473
|
+
'name': 'relationship_building',
|
|
474
|
+
'tone': 'warm and relationship-focused',
|
|
475
|
+
'focus': 'building connection and trust',
|
|
476
|
+
'length': 'personal'
|
|
477
|
+
}
|
|
478
|
+
]
|
|
479
|
+
|
|
480
|
+
generated_drafts = []
|
|
481
|
+
|
|
482
|
+
for approach in draft_approaches:
|
|
483
|
+
try:
|
|
484
|
+
# Generate email content for this approach
|
|
485
|
+
email_content = self._generate_single_email_draft(
|
|
486
|
+
customer_data, recommended_product, scoring_data,
|
|
487
|
+
approach, context
|
|
488
|
+
)
|
|
489
|
+
|
|
490
|
+
# Generate subject lines for this approach
|
|
491
|
+
subject_lines = self._generate_subject_lines(
|
|
492
|
+
customer_data, recommended_product, approach, context
|
|
493
|
+
)
|
|
494
|
+
|
|
495
|
+
draft_id = f"uuid:{str(uuid.uuid4())}"
|
|
496
|
+
draft_approach = approach['name']
|
|
497
|
+
draft_type = "initial"
|
|
498
|
+
|
|
499
|
+
# Select the best subject line (first one, or most relevant)
|
|
500
|
+
selected_subject = subject_lines[0] if subject_lines else f"Partnership opportunity for {company_info.get('name', 'your company')}"
|
|
501
|
+
|
|
502
|
+
draft = {
|
|
503
|
+
'draft_id': draft_id,
|
|
504
|
+
'approach': approach['name'],
|
|
505
|
+
'tone': approach['tone'],
|
|
506
|
+
'focus': approach['focus'],
|
|
507
|
+
'subject': selected_subject, # Single subject instead of array
|
|
508
|
+
'subject_alternatives': subject_lines[1:4] if len(subject_lines) > 1 else [], # Store alternatives separately
|
|
509
|
+
'email_body': email_content,
|
|
510
|
+
'call_to_action': self._extract_call_to_action(email_content),
|
|
511
|
+
'personalization_score': self._calculate_personalization_score(email_content, customer_data),
|
|
512
|
+
'generated_at': datetime.now().isoformat(),
|
|
513
|
+
'status': 'draft',
|
|
514
|
+
'metadata': {
|
|
515
|
+
'customer_company': company_info.get('name', 'Unknown'),
|
|
516
|
+
'contact_name': contact_info.get('name', 'Unknown'),
|
|
517
|
+
'recommended_product': recommended_product.get('product_name', 'Unknown'),
|
|
518
|
+
'pain_points_addressed': len([p for p in pain_points if p.get('severity') in ['high', 'medium']]),
|
|
519
|
+
'generation_method': 'llm_powered'
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
generated_drafts.append(draft)
|
|
524
|
+
|
|
525
|
+
except Exception as e:
|
|
526
|
+
self.logger.warning(f"Failed to generate draft for approach {approach['name']}: {str(e)}")
|
|
527
|
+
continue
|
|
528
|
+
|
|
529
|
+
if not generated_drafts:
|
|
530
|
+
# Fallback to simple template if all LLM generations fail
|
|
531
|
+
self.logger.warning("All LLM draft generations failed, using fallback template")
|
|
532
|
+
return self._generate_fallback_draft(customer_data, recommended_product, context)
|
|
533
|
+
|
|
534
|
+
self.logger.info(f"Generated {len(generated_drafts)} email drafts successfully")
|
|
535
|
+
return generated_drafts
|
|
536
|
+
|
|
537
|
+
except Exception as e:
|
|
538
|
+
self.logger.error(f"Email draft generation failed: {str(e)}")
|
|
539
|
+
return self._generate_fallback_draft(customer_data, recommended_product, context)
|
|
540
|
+
|
|
541
|
+
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]) -> List[Dict[str, Any]]:
|
|
542
|
+
"""Attempt to generate drafts using configured prompt template."""
|
|
543
|
+
prompt_template = self.get_prompt_template('email_generation')
|
|
544
|
+
if not prompt_template:
|
|
545
|
+
return []
|
|
546
|
+
|
|
547
|
+
try:
|
|
548
|
+
prompt = self._prepare_email_generation_prompt(
|
|
549
|
+
prompt_template,
|
|
550
|
+
customer_data,
|
|
551
|
+
recommended_product,
|
|
552
|
+
scoring_data,
|
|
553
|
+
context
|
|
554
|
+
)
|
|
555
|
+
except Exception as exc:
|
|
556
|
+
self.logger.warning(f"Failed to prepare email generation prompt: {str(exc)}")
|
|
557
|
+
return []
|
|
558
|
+
|
|
559
|
+
if not prompt or not prompt.strip():
|
|
560
|
+
self.logger.warning('Email generation prompt resolved to empty content after placeholder replacement')
|
|
561
|
+
return []
|
|
562
|
+
|
|
563
|
+
temperature = self.get_stage_config('email_generation_temperature', 0.35)
|
|
564
|
+
max_tokens = self.get_stage_config('email_generation_max_tokens', 3200)
|
|
565
|
+
|
|
566
|
+
try:
|
|
567
|
+
response = self.call_llm(
|
|
568
|
+
prompt=prompt,
|
|
569
|
+
temperature=temperature,
|
|
570
|
+
max_tokens=max_tokens
|
|
571
|
+
)
|
|
572
|
+
except Exception as exc:
|
|
573
|
+
self.logger.error(f"LLM call for prompt-based email generation failed: {str(exc)}")
|
|
574
|
+
return []
|
|
575
|
+
|
|
576
|
+
try:
|
|
577
|
+
parsed_entries = self._parse_prompt_response(response)
|
|
578
|
+
except Exception as exc:
|
|
579
|
+
self.logger.error(f"Failed to parse email generation response: {str(exc)}")
|
|
580
|
+
return []
|
|
581
|
+
|
|
582
|
+
drafts: List[Dict[str, Any]] = []
|
|
583
|
+
for entry in parsed_entries:
|
|
584
|
+
normalized = self._normalize_prompt_draft_entry(entry, customer_data, recommended_product, context)
|
|
585
|
+
if normalized:
|
|
586
|
+
drafts.append(normalized)
|
|
587
|
+
|
|
588
|
+
if not drafts:
|
|
589
|
+
self.logger.warning('Prompt-based email generation returned no usable drafts')
|
|
590
|
+
return []
|
|
591
|
+
|
|
592
|
+
valid_priority = all(
|
|
593
|
+
isinstance(d.get('priority_order'), int) and d['priority_order'] > 0
|
|
594
|
+
for d in drafts
|
|
595
|
+
)
|
|
596
|
+
|
|
597
|
+
if valid_priority:
|
|
598
|
+
drafts.sort(key=lambda d: d['priority_order'])
|
|
599
|
+
else:
|
|
600
|
+
for idx, draft in enumerate(drafts, start=1):
|
|
601
|
+
draft['priority_order'] = idx
|
|
602
|
+
|
|
603
|
+
return drafts
|
|
604
|
+
|
|
605
|
+
def _parse_prompt_response(self, response: str) -> List[Dict[str, Any]]:
|
|
606
|
+
"""Parse LLM response produced by prompt template."""
|
|
607
|
+
cleaned = self._strip_code_fences(response)
|
|
608
|
+
parsed = self._extract_json_array(cleaned)
|
|
609
|
+
|
|
610
|
+
if isinstance(parsed, dict):
|
|
611
|
+
for key in ('emails', 'drafts', 'data', 'results'):
|
|
612
|
+
value = parsed.get(key)
|
|
613
|
+
if isinstance(value, list):
|
|
614
|
+
parsed = value
|
|
615
|
+
break
|
|
616
|
+
else:
|
|
617
|
+
raise ValueError('Prompt response JSON object does not contain an email list')
|
|
618
|
+
|
|
619
|
+
if not isinstance(parsed, list):
|
|
620
|
+
raise ValueError('Prompt response is not a list of drafts')
|
|
621
|
+
|
|
622
|
+
return parsed
|
|
623
|
+
|
|
624
|
+
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:
|
|
625
|
+
replacements = self._build_prompt_replacements(
|
|
626
|
+
customer_data,
|
|
627
|
+
recommended_product,
|
|
628
|
+
scoring_data,
|
|
629
|
+
context
|
|
630
|
+
)
|
|
631
|
+
|
|
632
|
+
prompt = template
|
|
633
|
+
for placeholder, value in replacements.items():
|
|
634
|
+
prompt = prompt.replace(placeholder, value)
|
|
635
|
+
|
|
636
|
+
return prompt
|
|
637
|
+
|
|
638
|
+
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]:
|
|
639
|
+
input_data = context.get('input_data', {})
|
|
640
|
+
company_info = customer_data.get('companyInfo', {}) or {}
|
|
641
|
+
contact_info = customer_data.get('primaryContact', {}) or {}
|
|
642
|
+
language = input_data.get('language') or company_info.get('language') or 'English'
|
|
643
|
+
contact_name = contact_info.get('name') or input_data.get('customer_name') or input_data.get('recipient_name') or 'there'
|
|
644
|
+
company_name = company_info.get('name') or input_data.get('company_name') or 'the company'
|
|
645
|
+
staff_name = input_data.get('staff_name') or input_data.get('sender_name') or 'Sales Team'
|
|
646
|
+
org_name = input_data.get('org_name') or 'Our Company'
|
|
647
|
+
selected_product_name = recommended_product.get('product_name') if recommended_product else None
|
|
648
|
+
|
|
649
|
+
action = input_data.get('action', 'draft_write')
|
|
650
|
+
action_labels = {
|
|
651
|
+
'draft_write': 'email drafts',
|
|
652
|
+
'draft_rewrite': 'email rewrites',
|
|
653
|
+
'send': 'email sends',
|
|
654
|
+
'close': 'email workflow'
|
|
655
|
+
}
|
|
656
|
+
action_type = action_labels.get(action, action.replace('_', ' '))
|
|
657
|
+
|
|
658
|
+
company_summary = self._build_company_info_summary(
|
|
659
|
+
company_info,
|
|
660
|
+
contact_info,
|
|
661
|
+
customer_data.get('painPoints', []),
|
|
662
|
+
scoring_data
|
|
663
|
+
)
|
|
664
|
+
product_summary = self._build_product_info_summary(recommended_product)
|
|
665
|
+
first_name_guide = self._build_first_name_guide(language, contact_name)
|
|
666
|
+
|
|
667
|
+
replacements = {
|
|
668
|
+
'##action_type##': action_type,
|
|
669
|
+
'##language##': language.title() if isinstance(language, str) else 'English',
|
|
670
|
+
'##customer_name##': contact_name,
|
|
671
|
+
'##company_name##': company_name,
|
|
672
|
+
'##staff_name##': staff_name,
|
|
673
|
+
'##org_name##': org_name,
|
|
674
|
+
'##first_name_guide##': first_name_guide,
|
|
675
|
+
'##selected_product##': selected_product_name or 'our solution',
|
|
676
|
+
'##company_info##': company_summary,
|
|
677
|
+
'##selected_product_info##': product_summary
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
return {key: (value if value is not None else '') for key, value in replacements.items()}
|
|
681
|
+
|
|
682
|
+
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:
|
|
683
|
+
lines: List[str] = []
|
|
684
|
+
|
|
685
|
+
if company_info.get('name'):
|
|
686
|
+
lines.append(f"Company: {company_info.get('name')}")
|
|
687
|
+
if company_info.get('industry'):
|
|
688
|
+
lines.append(f"Industry: {company_info.get('industry')}")
|
|
689
|
+
if company_info.get('size'):
|
|
690
|
+
lines.append(f"Company size: {company_info.get('size')}")
|
|
691
|
+
if company_info.get('location'):
|
|
692
|
+
lines.append(f"Location: {company_info.get('location')}")
|
|
693
|
+
|
|
694
|
+
if contact_info.get('name'):
|
|
695
|
+
title = contact_info.get('title')
|
|
696
|
+
if title:
|
|
697
|
+
lines.append(f"Primary contact: {contact_info.get('name')} ({title})")
|
|
698
|
+
else:
|
|
699
|
+
lines.append(f"Primary contact: {contact_info.get('name')}")
|
|
700
|
+
if contact_info.get('email'):
|
|
701
|
+
lines.append(f"Contact email: {contact_info.get('email')}")
|
|
702
|
+
|
|
703
|
+
visible_pain_points = [p for p in pain_points if p]
|
|
704
|
+
if visible_pain_points:
|
|
705
|
+
lines.append('Top pain points:')
|
|
706
|
+
for point in visible_pain_points[:5]:
|
|
707
|
+
description = str(point.get('description', '')).strip()
|
|
708
|
+
if not description:
|
|
709
|
+
continue
|
|
710
|
+
severity = point.get('severity')
|
|
711
|
+
severity_text = f" (severity: {severity})" if severity else ''
|
|
712
|
+
lines.append(f"- {description}{severity_text}")
|
|
713
|
+
|
|
714
|
+
lead_scores = scoring_data.get('lead_scoring', []) or []
|
|
715
|
+
if lead_scores:
|
|
716
|
+
sorted_scores = sorted(lead_scores, key=lambda item: item.get('total_weighted_score', 0), reverse=True)
|
|
717
|
+
top_score = sorted_scores[0]
|
|
718
|
+
product_name = top_score.get('product_name')
|
|
719
|
+
score_value = top_score.get('total_weighted_score')
|
|
720
|
+
if product_name:
|
|
721
|
+
if score_value is not None:
|
|
722
|
+
lines.append(f"Highest scoring product: {product_name} (score {score_value})")
|
|
723
|
+
else:
|
|
724
|
+
lines.append(f"Highest scoring product: {product_name}")
|
|
725
|
+
|
|
726
|
+
summary = "\n".join(lines).strip()
|
|
727
|
+
return summary or 'Company details unavailable.'
|
|
728
|
+
|
|
729
|
+
def _build_product_info_summary(self, recommended_product: Optional[Dict[str, Any]]) -> str:
|
|
730
|
+
if not recommended_product:
|
|
731
|
+
return "No specific product selected. Focus on aligning our solutions with the customer's pain points."
|
|
732
|
+
|
|
733
|
+
lines: List[str] = []
|
|
734
|
+
name = recommended_product.get('product_name')
|
|
735
|
+
if name:
|
|
736
|
+
lines.append(f"Product: {name}")
|
|
737
|
+
description = recommended_product.get('description')
|
|
738
|
+
if description:
|
|
739
|
+
lines.append(f"Description: {description}")
|
|
740
|
+
benefits = recommended_product.get('key_benefits')
|
|
741
|
+
if isinstance(benefits, list) and benefits:
|
|
742
|
+
lines.append('Key benefits: ' + ', '.join(str(b) for b in benefits if b))
|
|
743
|
+
differentiators = recommended_product.get('differentiators')
|
|
744
|
+
if isinstance(differentiators, list) and differentiators:
|
|
745
|
+
lines.append('Differentiators: ' + ', '.join(str(d) for d in differentiators if d))
|
|
746
|
+
score = recommended_product.get('score')
|
|
747
|
+
if score is not None:
|
|
748
|
+
lines.append(f"Lead score: {score}")
|
|
749
|
+
|
|
750
|
+
summary = "\n".join(lines).strip()
|
|
751
|
+
return summary or 'Product details unavailable.'
|
|
752
|
+
|
|
753
|
+
def _build_first_name_guide(self, language: str, contact_name: str) -> str:
|
|
754
|
+
if not language:
|
|
755
|
+
return ''
|
|
756
|
+
|
|
757
|
+
language_lower = language.lower()
|
|
758
|
+
if language_lower in ('vietnamese', 'vi'):
|
|
759
|
+
if not contact_name or contact_name.lower() == 'a person':
|
|
760
|
+
return "If the recipient's name is unknown, use `anh/chi` in the greeting."
|
|
761
|
+
first_name = self._extract_first_name(contact_name)
|
|
762
|
+
if first_name:
|
|
763
|
+
return f"For Vietnamese recipients, use `anh/chi {first_name}` in the greeting to keep it respectful."
|
|
764
|
+
return "For Vietnamese recipients, use `anh/chi` followed by the recipient's first name in the greeting."
|
|
765
|
+
|
|
766
|
+
return ''
|
|
767
|
+
|
|
768
|
+
def _extract_first_name(self, full_name: str) -> str:
|
|
769
|
+
if not full_name:
|
|
770
|
+
return ''
|
|
771
|
+
parts = full_name.strip().split()
|
|
772
|
+
return parts[-1] if parts else full_name
|
|
773
|
+
|
|
774
|
+
def _strip_code_fences(self, text: str) -> str:
|
|
775
|
+
if not text:
|
|
776
|
+
return ''
|
|
777
|
+
cleaned = text.strip()
|
|
778
|
+
if cleaned.startswith('```'):
|
|
779
|
+
lines = cleaned.splitlines()
|
|
780
|
+
normalized = '\n'.join(lines[1:]) if len(lines) > 1 else ''
|
|
781
|
+
if '```' in normalized:
|
|
782
|
+
normalized = normalized.rsplit('```', 1)[0]
|
|
783
|
+
cleaned = normalized
|
|
784
|
+
return cleaned.strip()
|
|
785
|
+
|
|
786
|
+
def _extract_json_array(self, text: str) -> Any:
|
|
787
|
+
try:
|
|
788
|
+
return json.loads(text)
|
|
789
|
+
except json.JSONDecodeError:
|
|
790
|
+
pass
|
|
791
|
+
|
|
792
|
+
start = text.find('[')
|
|
793
|
+
end = text.rfind(']') + 1
|
|
794
|
+
if start != -1 and end > start:
|
|
795
|
+
snippet = text[start:end]
|
|
796
|
+
try:
|
|
797
|
+
return json.loads(snippet)
|
|
798
|
+
except json.JSONDecodeError:
|
|
799
|
+
pass
|
|
800
|
+
|
|
801
|
+
start = text.find('{')
|
|
802
|
+
end = text.rfind('}') + 1
|
|
803
|
+
if start != -1 and end > start:
|
|
804
|
+
snippet = text[start:end]
|
|
805
|
+
try:
|
|
806
|
+
return json.loads(snippet)
|
|
807
|
+
except json.JSONDecodeError:
|
|
808
|
+
pass
|
|
809
|
+
|
|
810
|
+
raise ValueError('Could not parse JSON from prompt response')
|
|
811
|
+
|
|
812
|
+
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]]:
|
|
813
|
+
if not isinstance(entry, dict):
|
|
814
|
+
self.logger.debug('Skipping prompt entry because it is not a dict: %s', entry)
|
|
815
|
+
return None
|
|
816
|
+
|
|
817
|
+
email_body = entry.get('body') or entry.get('content') or ''
|
|
818
|
+
if isinstance(email_body, dict):
|
|
819
|
+
email_body = email_body.get('html') or email_body.get('text') or email_body.get('content') or ''
|
|
820
|
+
email_body = str(email_body).strip()
|
|
821
|
+
if not email_body:
|
|
822
|
+
self.logger.debug('Skipping prompt entry because email body is empty: %s', entry)
|
|
823
|
+
return None
|
|
824
|
+
|
|
825
|
+
subject = entry.get('subject')
|
|
826
|
+
if isinstance(subject, list):
|
|
827
|
+
subject = subject[0] if subject else ''
|
|
828
|
+
subject = str(subject).strip() if subject else ''
|
|
829
|
+
|
|
830
|
+
subject_alternatives: List[str] = []
|
|
831
|
+
for key in ('subject_alternatives', 'subject_variations', 'subject_variants', 'alternative_subjects', 'subjects'):
|
|
832
|
+
variants = entry.get(key)
|
|
833
|
+
if isinstance(variants, list):
|
|
834
|
+
subject_alternatives = [str(item).strip() for item in variants if str(item).strip()]
|
|
835
|
+
if subject_alternatives:
|
|
836
|
+
break
|
|
837
|
+
|
|
838
|
+
if not subject and subject_alternatives:
|
|
839
|
+
subject = subject_alternatives[0]
|
|
840
|
+
|
|
841
|
+
if not subject:
|
|
842
|
+
company_name = customer_data.get('companyInfo', {}).get('name', 'your organization')
|
|
843
|
+
subject = f"Opportunity for {company_name}"
|
|
844
|
+
|
|
845
|
+
mail_tone = str(entry.get('mail_tone') or entry.get('tone') or 'custom').strip()
|
|
846
|
+
approach = str(entry.get('approach') or entry.get('strategy') or 'custom').strip()
|
|
847
|
+
focus = str(entry.get('focus') or entry.get('value_focus') or 'custom_prompt').strip()
|
|
848
|
+
|
|
849
|
+
priority_order = entry.get('priority_order')
|
|
850
|
+
try:
|
|
851
|
+
priority_order = int(priority_order)
|
|
852
|
+
if priority_order < 1:
|
|
853
|
+
raise ValueError
|
|
854
|
+
except (TypeError, ValueError):
|
|
855
|
+
priority_order = None
|
|
856
|
+
|
|
857
|
+
product_name = entry.get('product_name') or (recommended_product.get('product_name') if recommended_product else None)
|
|
858
|
+
product_mention = entry.get('product_mention')
|
|
859
|
+
if isinstance(product_mention, str):
|
|
860
|
+
product_mention = product_mention.strip().lower() in ('true', 'yes', '1')
|
|
861
|
+
elif not isinstance(product_mention, bool):
|
|
862
|
+
product_mention = bool(product_name)
|
|
863
|
+
|
|
864
|
+
tags = entry.get('tags', [])
|
|
865
|
+
if isinstance(tags, str):
|
|
866
|
+
tags = [tags]
|
|
867
|
+
tags = [str(tag).strip() for tag in tags if str(tag).strip()]
|
|
868
|
+
|
|
869
|
+
call_to_action = self._extract_call_to_action(email_body)
|
|
870
|
+
personalization_score = self._calculate_personalization_score(email_body, customer_data)
|
|
871
|
+
message_type = entry.get('message_type') or 'Email'
|
|
872
|
+
|
|
873
|
+
metadata = {
|
|
874
|
+
'customer_company': customer_data.get('companyInfo', {}).get('name', 'Unknown'),
|
|
875
|
+
'contact_name': customer_data.get('primaryContact', {}).get('name', 'Unknown'),
|
|
876
|
+
'recommended_product': product_name or 'Unknown',
|
|
877
|
+
'generation_method': 'prompt_template',
|
|
878
|
+
'tags': tags,
|
|
879
|
+
'message_type': message_type
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
draft_id = entry.get('draft_id') or f"uuid:{str(uuid.uuid4())}"
|
|
883
|
+
draft_approach = "prompt"
|
|
884
|
+
draft_type = "initial"
|
|
885
|
+
|
|
886
|
+
return {
|
|
887
|
+
'draft_id': draft_id,
|
|
888
|
+
'approach': approach,
|
|
889
|
+
'tone': mail_tone,
|
|
890
|
+
'focus': focus,
|
|
891
|
+
'subject': subject,
|
|
892
|
+
'subject_alternatives': subject_alternatives,
|
|
893
|
+
'email_body': email_body,
|
|
894
|
+
'call_to_action': call_to_action,
|
|
895
|
+
'product_mention': product_mention,
|
|
896
|
+
'product_name': product_name,
|
|
897
|
+
'priority_order': priority_order if priority_order is not None else 0,
|
|
898
|
+
'personalization_score': personalization_score,
|
|
899
|
+
'generated_at': datetime.now().isoformat(),
|
|
900
|
+
'status': 'draft',
|
|
901
|
+
'metadata': metadata
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
def _strip_html_tags(self, html: str) -> str:
|
|
905
|
+
if not html:
|
|
906
|
+
return ''
|
|
907
|
+
|
|
908
|
+
text = re.sub(r'(?i)<br\s*/?>', '\n', html)
|
|
909
|
+
text = re.sub(r'(?i)</p>', '\n', text)
|
|
910
|
+
text = re.sub(r'(?i)<li>', '\n- ', text)
|
|
911
|
+
text = re.sub(r'<[^>]+>', ' ', text)
|
|
912
|
+
text = re.sub(r'\s+', ' ', text)
|
|
913
|
+
return text.strip()
|
|
914
|
+
|
|
915
|
+
def _generate_single_email_draft(self, customer_data: Dict[str, Any], recommended_product: Dict[str, Any],
|
|
916
|
+
scoring_data: Dict[str, Any], approach: Dict[str, Any], context: Dict[str, Any]) -> str:
|
|
917
|
+
"""Generate a single email draft using LLM with specific approach."""
|
|
918
|
+
try:
|
|
919
|
+
input_data = context.get('input_data', {})
|
|
920
|
+
company_info = customer_data.get('companyInfo', {})
|
|
921
|
+
contact_info = customer_data.get('primaryContact', {})
|
|
922
|
+
pain_points = customer_data.get('painPoints', [])
|
|
923
|
+
|
|
924
|
+
# Prepare context for LLM
|
|
925
|
+
customer_context = {
|
|
926
|
+
'company_name': company_info.get('name', 'the company'),
|
|
927
|
+
'contact_name': contact_info.get('name', 'there'),
|
|
928
|
+
'contact_title': contact_info.get('title', ''),
|
|
929
|
+
'industry': company_info.get('industry', 'technology'),
|
|
930
|
+
'company_size': company_info.get('size', 'unknown'),
|
|
931
|
+
'main_pain_points': [p.get('description', '') for p in pain_points[:3]],
|
|
932
|
+
'recommended_product': recommended_product.get('product_name', 'our solution'),
|
|
933
|
+
'product_benefits': recommended_product.get('key_benefits', []),
|
|
934
|
+
'sender_name': input_data.get('staff_name', 'Sales Team'),
|
|
935
|
+
'sender_company': input_data.get('org_name', 'Our Company'),
|
|
936
|
+
'approach_tone': approach.get('tone', 'professional'),
|
|
937
|
+
'approach_focus': approach.get('focus', 'business value'),
|
|
938
|
+
'approach_length': approach.get('length', 'medium')
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
# Create LLM prompt for email generation
|
|
942
|
+
prompt = self._create_email_generation_prompt(customer_context, approach)
|
|
943
|
+
|
|
944
|
+
# Generate email using LLM
|
|
945
|
+
email_content = self.call_llm(
|
|
946
|
+
prompt=prompt,
|
|
947
|
+
temperature=0.7,
|
|
948
|
+
max_tokens=800
|
|
949
|
+
)
|
|
950
|
+
|
|
951
|
+
# Clean and validate the generated content
|
|
952
|
+
cleaned_content = self._clean_email_content(email_content)
|
|
953
|
+
|
|
954
|
+
return cleaned_content
|
|
955
|
+
|
|
956
|
+
except Exception as e:
|
|
957
|
+
self.logger.error(f"Failed to generate single email draft: {str(e)}")
|
|
958
|
+
return self._generate_template_email(customer_data, recommended_product, approach, context)
|
|
959
|
+
|
|
960
|
+
def _create_email_generation_prompt(self, customer_context: Dict[str, Any], approach: Dict[str, Any]) -> str:
|
|
961
|
+
"""Create LLM prompt for email generation."""
|
|
962
|
+
|
|
963
|
+
pain_points_text = ""
|
|
964
|
+
if customer_context['main_pain_points']:
|
|
965
|
+
pain_points_text = f"Key challenges they face: {', '.join(customer_context['main_pain_points'])}"
|
|
966
|
+
|
|
967
|
+
benefits_text = ""
|
|
968
|
+
if customer_context['product_benefits']:
|
|
969
|
+
benefits_text = f"Our solution benefits: {', '.join(customer_context['product_benefits'])}"
|
|
970
|
+
|
|
971
|
+
prompt = f"""Generate a personalized outreach email with the following specifications:
|
|
972
|
+
|
|
973
|
+
CUSTOMER INFORMATION:
|
|
974
|
+
- Company: {customer_context['company_name']}
|
|
975
|
+
- Contact: {customer_context['contact_name']} ({customer_context['contact_title']})
|
|
976
|
+
- Industry: {customer_context['industry']}
|
|
977
|
+
- Company Size: {customer_context['company_size']}
|
|
978
|
+
{pain_points_text}
|
|
979
|
+
|
|
980
|
+
OUR OFFERING:
|
|
981
|
+
- Product/Solution: {customer_context['recommended_product']}
|
|
982
|
+
{benefits_text}
|
|
983
|
+
|
|
984
|
+
SENDER INFORMATION:
|
|
985
|
+
- Sender: {customer_context['sender_name']}
|
|
986
|
+
- Company: {customer_context['sender_company']}
|
|
987
|
+
|
|
988
|
+
EMAIL APPROACH:
|
|
989
|
+
- Tone: {customer_context['approach_tone']}
|
|
990
|
+
- Focus: {customer_context['approach_focus']}
|
|
991
|
+
- Length: {customer_context['approach_length']}
|
|
992
|
+
|
|
993
|
+
REQUIREMENTS:
|
|
994
|
+
1. Write a complete email from greeting to signature
|
|
995
|
+
2. Personalize based on their company and industry
|
|
996
|
+
3. Address their specific pain points naturally
|
|
997
|
+
4. Present our solution as a potential fit
|
|
998
|
+
5. Include a clear, specific call-to-action
|
|
999
|
+
6. Keep the tone {customer_context['approach_tone']}
|
|
1000
|
+
7. Focus on {customer_context['approach_focus']}
|
|
1001
|
+
8. Make it {customer_context['approach_length']} in length
|
|
1002
|
+
|
|
1003
|
+
Generate only the email content, no additional commentary:"""
|
|
1004
|
+
|
|
1005
|
+
return prompt
|
|
1006
|
+
|
|
1007
|
+
def _generate_subject_lines(self, customer_data: Dict[str, Any], recommended_product: Dict[str, Any],
|
|
1008
|
+
approach: Dict[str, Any], context: Dict[str, Any]) -> List[str]:
|
|
1009
|
+
"""Generate multiple subject line variations using LLM."""
|
|
1010
|
+
try:
|
|
1011
|
+
input_data = context.get('input_data', {})
|
|
1012
|
+
company_info = customer_data.get('companyInfo', {})
|
|
1013
|
+
|
|
1014
|
+
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.
|
|
1015
|
+
|
|
1016
|
+
CONTEXT:
|
|
1017
|
+
- Target Company: {company_info.get('name', 'the company')}
|
|
1018
|
+
- Industry: {company_info.get('industry', 'technology')}
|
|
1019
|
+
- Our Solution: {recommended_product.get('product_name', 'our solution')}
|
|
1020
|
+
- Sender Company: {input_data.get('org_name', 'our company')}
|
|
1021
|
+
- Approach Tone: {approach.get('tone', 'professional')}
|
|
1022
|
+
|
|
1023
|
+
REQUIREMENTS:
|
|
1024
|
+
1. Keep subject lines under 50 characters
|
|
1025
|
+
2. Make them personalized and specific
|
|
1026
|
+
3. Create urgency or curiosity
|
|
1027
|
+
4. Avoid spam trigger words
|
|
1028
|
+
5. Match the {approach.get('tone', 'professional')} tone
|
|
1029
|
+
|
|
1030
|
+
Generate 4 subject lines, one per line, no numbering or bullets:"""
|
|
1031
|
+
|
|
1032
|
+
response = self.call_llm(
|
|
1033
|
+
prompt=prompt,
|
|
1034
|
+
temperature=0.8,
|
|
1035
|
+
max_tokens=200
|
|
1036
|
+
)
|
|
1037
|
+
|
|
1038
|
+
# Parse subject lines from response
|
|
1039
|
+
subject_lines = [line.strip() for line in response.split('\n') if line.strip()]
|
|
1040
|
+
|
|
1041
|
+
# Ensure we have at least 3 subject lines
|
|
1042
|
+
if len(subject_lines) < 3:
|
|
1043
|
+
subject_lines.extend(self._generate_fallback_subject_lines(customer_data, recommended_product))
|
|
1044
|
+
|
|
1045
|
+
return subject_lines[:4] # Return max 4 subject lines
|
|
1046
|
+
|
|
1047
|
+
except Exception as e:
|
|
1048
|
+
self.logger.warning(f"Failed to generate subject lines: {str(e)}")
|
|
1049
|
+
return self._generate_fallback_subject_lines(customer_data, recommended_product)
|
|
1050
|
+
|
|
1051
|
+
def _generate_fallback_subject_lines(self, customer_data: Dict[str, Any], recommended_product: Dict[str, Any]) -> List[str]:
|
|
1052
|
+
"""Generate fallback subject lines using templates."""
|
|
1053
|
+
company_info = customer_data.get('companyInfo', {})
|
|
1054
|
+
company_name = company_info.get('name', 'Your Company')
|
|
1055
|
+
|
|
1056
|
+
return [
|
|
1057
|
+
f"Quick question about {company_name}",
|
|
1058
|
+
f"Partnership opportunity for {company_name}",
|
|
1059
|
+
f"Helping {company_name} with {company_info.get('industry', 'growth')}",
|
|
1060
|
+
f"5-minute chat about {company_name}?"
|
|
1061
|
+
]
|
|
1062
|
+
|
|
1063
|
+
def _clean_email_content(self, raw_content: str) -> str:
|
|
1064
|
+
"""Clean and validate generated email content."""
|
|
1065
|
+
# Remove any unwanted prefixes or suffixes
|
|
1066
|
+
content = raw_content.strip()
|
|
1067
|
+
|
|
1068
|
+
# Remove common LLM artifacts
|
|
1069
|
+
artifacts_to_remove = [
|
|
1070
|
+
"Here's the email:",
|
|
1071
|
+
"Here is the email:",
|
|
1072
|
+
"Email content:",
|
|
1073
|
+
"Generated email:",
|
|
1074
|
+
"Subject:",
|
|
1075
|
+
"Email:"
|
|
1076
|
+
]
|
|
1077
|
+
|
|
1078
|
+
for artifact in artifacts_to_remove:
|
|
1079
|
+
if content.startswith(artifact):
|
|
1080
|
+
content = content[len(artifact):].strip()
|
|
1081
|
+
|
|
1082
|
+
# Ensure proper email structure
|
|
1083
|
+
if not content.startswith(('Dear', 'Hi', 'Hello', 'Greetings')):
|
|
1084
|
+
# Add a greeting if missing
|
|
1085
|
+
content = f"Dear Valued Customer,\n\n{content}"
|
|
1086
|
+
|
|
1087
|
+
# Ensure proper closing
|
|
1088
|
+
if not any(closing in content.lower() for closing in ['best regards', 'sincerely', 'best', 'thanks']):
|
|
1089
|
+
content += "\n\nBest regards"
|
|
1090
|
+
|
|
1091
|
+
return content
|
|
1092
|
+
|
|
1093
|
+
def _extract_call_to_action(self, email_content: str) -> str:
|
|
1094
|
+
"""Extract the main call-to-action from email content."""
|
|
1095
|
+
plain_content = self._strip_html_tags(email_content)
|
|
1096
|
+
cta_patterns = [
|
|
1097
|
+
r"Would you be (?:interested in|available for|open to) ([^?]+\?)",
|
|
1098
|
+
r"Can we schedule ([^?]+\?)",
|
|
1099
|
+
r"I'd love to ([^.]+\.)",
|
|
1100
|
+
r"Let's ([^.]+\.)",
|
|
1101
|
+
r"Would you like to ([^?]+\?)"
|
|
1102
|
+
]
|
|
1103
|
+
|
|
1104
|
+
for pattern in cta_patterns:
|
|
1105
|
+
match = re.search(pattern, plain_content, re.IGNORECASE)
|
|
1106
|
+
if match:
|
|
1107
|
+
return match.group(0).strip()
|
|
1108
|
+
|
|
1109
|
+
question_index = plain_content.find('?')
|
|
1110
|
+
if question_index != -1:
|
|
1111
|
+
start_idx = plain_content.rfind('.', 0, question_index)
|
|
1112
|
+
start_idx = start_idx + 1 if start_idx != -1 else 0
|
|
1113
|
+
cta_sentence = plain_content[start_idx:question_index + 1].strip()
|
|
1114
|
+
if cta_sentence:
|
|
1115
|
+
return cta_sentence
|
|
1116
|
+
|
|
1117
|
+
sentences = [sentence.strip() for sentence in re.split(r'[.\n]', plain_content) if sentence.strip()]
|
|
1118
|
+
for sentence in sentences:
|
|
1119
|
+
if '?' in sentence:
|
|
1120
|
+
return sentence if sentence.endswith('?') else f"{sentence}?"
|
|
1121
|
+
|
|
1122
|
+
return "Would you be interested in learning more?"
|
|
1123
|
+
|
|
1124
|
+
def _calculate_personalization_score(self, email_content: str, customer_data: Dict[str, Any]) -> int:
|
|
1125
|
+
"""Calculate personalization score based on customer data usage."""
|
|
1126
|
+
plain_content = self._strip_html_tags(email_content)
|
|
1127
|
+
lower_content = plain_content.lower()
|
|
1128
|
+
score = 0
|
|
1129
|
+
company_info = customer_data.get('companyInfo', {})
|
|
1130
|
+
contact_info = customer_data.get('primaryContact', {})
|
|
1131
|
+
|
|
1132
|
+
company_name = str(company_info.get('name', '')).lower()
|
|
1133
|
+
if company_name and company_name in lower_content:
|
|
1134
|
+
score += 25
|
|
1135
|
+
|
|
1136
|
+
contact_name = str(contact_info.get('name', '')).lower()
|
|
1137
|
+
if contact_name and contact_name not in ('a person', '') and contact_name in lower_content:
|
|
1138
|
+
score += 25
|
|
1139
|
+
|
|
1140
|
+
industry = str(company_info.get('industry', '')).lower()
|
|
1141
|
+
if industry and industry in lower_content:
|
|
1142
|
+
score += 20
|
|
1143
|
+
|
|
1144
|
+
pain_points = customer_data.get('painPoints', [])
|
|
1145
|
+
for pain_point in pain_points:
|
|
1146
|
+
description = str(pain_point.get('description', '')).lower()
|
|
1147
|
+
if description and description in lower_content:
|
|
1148
|
+
score += 15
|
|
1149
|
+
break
|
|
1150
|
+
|
|
1151
|
+
size = company_info.get('size')
|
|
1152
|
+
location = company_info.get('location') or company_info.get('address')
|
|
1153
|
+
for detail in (size, location):
|
|
1154
|
+
if detail:
|
|
1155
|
+
detail_text = str(detail).lower()
|
|
1156
|
+
if detail_text and detail_text in lower_content:
|
|
1157
|
+
score += 15
|
|
1158
|
+
break
|
|
1159
|
+
|
|
1160
|
+
return min(score, 100)
|
|
1161
|
+
|
|
1162
|
+
def _generate_template_email(self, customer_data: Dict[str, Any], recommended_product: Dict[str, Any],
|
|
1163
|
+
approach: Dict[str, Any], context: Dict[str, Any]) -> str:
|
|
1164
|
+
"""Generate email using template as fallback."""
|
|
1165
|
+
input_data = context.get('input_data', {})
|
|
1166
|
+
company_info = customer_data.get('companyInfo', {})
|
|
1167
|
+
contact_info = customer_data.get('primaryContact', {})
|
|
1168
|
+
|
|
1169
|
+
return f"""Dear {contact_info.get('name', 'there')},
|
|
1170
|
+
|
|
1171
|
+
I hope this email finds you well. I'm reaching out from {input_data.get('org_name', 'our company')} regarding a potential opportunity for {company_info.get('name', 'your company')}.
|
|
1172
|
+
|
|
1173
|
+
Based on our research of companies in the {company_info.get('industry', 'technology')} sector, I believe {company_info.get('name', 'your company')} could benefit from our {recommended_product.get('product_name', 'solution')}.
|
|
1174
|
+
|
|
1175
|
+
We've helped similar organizations achieve significant improvements in their operations. Would you be interested in a brief 15-minute call to discuss how we might be able to help {company_info.get('name', 'your company')} achieve its goals?
|
|
1176
|
+
|
|
1177
|
+
Best regards,
|
|
1178
|
+
{input_data.get('staff_name', 'Sales Team')}
|
|
1179
|
+
{input_data.get('org_name', 'Our Company')}"""
|
|
1180
|
+
|
|
1181
|
+
def _generate_fallback_draft(self, customer_data: Dict[str, Any], recommended_product: Dict[str, Any], context: Dict[str, Any]) -> List[Dict[str, Any]]:
|
|
1182
|
+
"""Generate fallback draft when LLM generation fails."""
|
|
1183
|
+
draft_id = f"uuid:{str(uuid.uuid4())}"
|
|
1184
|
+
draft_approach = "fallback"
|
|
1185
|
+
draft_type = "initial"
|
|
1186
|
+
|
|
1187
|
+
return [{
|
|
1188
|
+
'draft_id': draft_id,
|
|
1189
|
+
'approach': 'fallback_template',
|
|
1190
|
+
'tone': 'professional',
|
|
1191
|
+
'focus': 'general outreach',
|
|
1192
|
+
'subject': self._generate_fallback_subject_lines(customer_data, recommended_product)[0],
|
|
1193
|
+
'subject_alternatives': self._generate_fallback_subject_lines(customer_data, recommended_product)[1:],
|
|
1194
|
+
'email_body': self._generate_template_email(customer_data, recommended_product, {'tone': 'professional'}, context),
|
|
1195
|
+
'call_to_action': 'Would you be interested in a brief call?',
|
|
1196
|
+
'personalization_score': 50,
|
|
1197
|
+
'generated_at': datetime.now().isoformat(),
|
|
1198
|
+
'status': 'draft',
|
|
1199
|
+
'metadata': {
|
|
1200
|
+
'generation_method': 'template_fallback',
|
|
1201
|
+
'note': 'Generated using template due to LLM failure'
|
|
1202
|
+
}
|
|
1203
|
+
}]
|
|
1204
|
+
|
|
1205
|
+
def _get_mock_email_drafts(self, customer_data: Dict[str, Any], recommended_product: Dict[str, Any], context: Dict[str, Any]) -> List[Dict[str, Any]]:
|
|
1206
|
+
"""Get mock email drafts for dry run."""
|
|
1207
|
+
input_data = context.get('input_data', {})
|
|
1208
|
+
company_info = customer_data.get('companyInfo', {})
|
|
1209
|
+
|
|
1210
|
+
return [{
|
|
1211
|
+
'draft_id': 'mock_draft_001',
|
|
1212
|
+
'approach': 'professional_direct',
|
|
1213
|
+
'tone': 'professional and direct',
|
|
1214
|
+
'focus': 'business value and ROI',
|
|
1215
|
+
'subject': f"Partnership Opportunity for {company_info.get('name', 'Test Company')}",
|
|
1216
|
+
'subject_alternatives': [
|
|
1217
|
+
f"Quick Question About {company_info.get('name', 'Test Company')}",
|
|
1218
|
+
f"Helping Companies Like {company_info.get('name', 'Test Company')}"
|
|
1219
|
+
],
|
|
1220
|
+
'email_body': f"""[DRY RUN] Mock email content for {company_info.get('name', 'Test Company')}
|
|
1221
|
+
|
|
1222
|
+
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.""",
|
|
1223
|
+
'call_to_action': 'Mock call to action',
|
|
1224
|
+
'personalization_score': 85,
|
|
1225
|
+
'generated_at': datetime.now().isoformat(),
|
|
1226
|
+
'status': 'mock',
|
|
1227
|
+
'metadata': {
|
|
1228
|
+
'generation_method': 'mock_data',
|
|
1229
|
+
'note': 'This is mock data for dry run testing'
|
|
1230
|
+
}
|
|
1231
|
+
}]
|
|
1232
|
+
|
|
1233
|
+
|
|
1234
|
+
def _convert_draft_to_server_format(self, draft: Dict[str, Any], context: Dict[str, Any]) -> Dict[str, Any]:
|
|
1235
|
+
"""
|
|
1236
|
+
Convert local draft format to server-compatible gs_initial_outreach_mail_draft format.
|
|
1237
|
+
|
|
1238
|
+
Server schema fields:
|
|
1239
|
+
- body (text): Email content
|
|
1240
|
+
- subject (text): Single subject line
|
|
1241
|
+
- mail_tone (text): Email tone
|
|
1242
|
+
- priority_order (integer): Draft priority
|
|
1243
|
+
- language (text): Email language
|
|
1244
|
+
- keyid (text): Unique identifier
|
|
1245
|
+
- customer_language (boolean): Whether using customer's language
|
|
1246
|
+
- task_id (text): Task identifier
|
|
1247
|
+
- org_id (text): Organization ID
|
|
1248
|
+
- customer_id (text): Customer identifier
|
|
1249
|
+
- retrieved_date (text): Creation timestamp
|
|
1250
|
+
- import_uuid (text): Import identifier
|
|
1251
|
+
- project_code (text): Project code
|
|
1252
|
+
- project_url (text): Project URL
|
|
1253
|
+
"""
|
|
1254
|
+
input_data = context.get('input_data', {})
|
|
1255
|
+
execution_id = context.get('execution_id', 'unknown')
|
|
1256
|
+
|
|
1257
|
+
# Generate server-compatible keyid
|
|
1258
|
+
keyid = f"{input_data.get('org_id', 'unknown')}_{draft.get('customer_id', 'unknown')}_{execution_id}_{draft['draft_id']}"
|
|
1259
|
+
|
|
1260
|
+
# Map approach to mail_tone
|
|
1261
|
+
tone_mapping = {
|
|
1262
|
+
'professional_direct': 'Professional',
|
|
1263
|
+
'consultative': 'Consultative',
|
|
1264
|
+
'industry_expert': 'Expert',
|
|
1265
|
+
'relationship_building': 'Friendly'
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
server_draft = {
|
|
1269
|
+
'body': draft.get('email_body', ''),
|
|
1270
|
+
'subject': draft.get('subject', ''),
|
|
1271
|
+
'mail_tone': tone_mapping.get(draft.get('approach', 'professional_direct'), 'Professional'),
|
|
1272
|
+
'priority_order': self._get_draft_priority_order(draft),
|
|
1273
|
+
'language': input_data.get('language', 'English').title(),
|
|
1274
|
+
'keyid': keyid,
|
|
1275
|
+
'customer_language': input_data.get('language', 'english').lower() != 'english',
|
|
1276
|
+
'task_id': execution_id,
|
|
1277
|
+
'org_id': input_data.get('org_id', 'unknown'),
|
|
1278
|
+
'customer_id': draft.get('customer_id', execution_id),
|
|
1279
|
+
'retrieved_date': datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
|
|
1280
|
+
'import_uuid': f"{input_data.get('org_id', 'unknown')}_{draft.get('customer_id', execution_id)}_{execution_id}",
|
|
1281
|
+
'project_code': input_data.get('project_code', 'LOCAL'),
|
|
1282
|
+
'project_url': input_data.get('project_url', ''),
|
|
1283
|
+
|
|
1284
|
+
# Keep local fields for compatibility
|
|
1285
|
+
'draft_id': draft.get('draft_id'),
|
|
1286
|
+
'approach': draft.get('approach'),
|
|
1287
|
+
'tone': draft.get('tone'),
|
|
1288
|
+
'focus': draft.get('focus'),
|
|
1289
|
+
'subject_alternatives': draft.get('subject_alternatives', []),
|
|
1290
|
+
'call_to_action': draft.get('call_to_action'),
|
|
1291
|
+
'personalization_score': draft.get('personalization_score'),
|
|
1292
|
+
'generated_at': draft.get('generated_at'),
|
|
1293
|
+
'status': draft.get('status'),
|
|
1294
|
+
'metadata': draft.get('metadata', {})
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
return server_draft
|
|
1298
|
+
|
|
1299
|
+
def _get_draft_priority_order(self, draft: Dict[str, Any]) -> int:
|
|
1300
|
+
"""Get priority order for draft based on approach and personalization score."""
|
|
1301
|
+
approach_priorities = {
|
|
1302
|
+
'professional_direct': 1,
|
|
1303
|
+
'consultative': 2,
|
|
1304
|
+
'industry_expert': 3,
|
|
1305
|
+
'relationship_building': 4
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
base_priority = approach_priorities.get(draft.get('approach', 'professional_direct'), 1)
|
|
1309
|
+
personalization_score = draft.get('personalization_score', 50)
|
|
1310
|
+
|
|
1311
|
+
# Adjust priority based on personalization score
|
|
1312
|
+
if personalization_score >= 80:
|
|
1313
|
+
return base_priority
|
|
1314
|
+
elif personalization_score >= 60:
|
|
1315
|
+
return base_priority + 1
|
|
1316
|
+
else:
|
|
1317
|
+
return base_priority + 2
|
|
1318
|
+
|
|
1319
|
+
|
|
1320
|
+
def _convert_draft_to_server_format(self, draft: Dict[str, Any], context: Dict[str, Any]) -> Dict[str, Any]:
|
|
1321
|
+
"""
|
|
1322
|
+
Convert local draft format to server-compatible gs_initial_outreach_mail_draft format.
|
|
1323
|
+
|
|
1324
|
+
Server schema fields:
|
|
1325
|
+
- body (text): Email content
|
|
1326
|
+
- subject (text): Single subject line
|
|
1327
|
+
- mail_tone (text): Email tone
|
|
1328
|
+
- priority_order (integer): Draft priority
|
|
1329
|
+
- language (text): Email language
|
|
1330
|
+
- keyid (text): Unique identifier
|
|
1331
|
+
- customer_language (boolean): Whether using customer's language
|
|
1332
|
+
- task_id (text): Task identifier
|
|
1333
|
+
- org_id (text): Organization ID
|
|
1334
|
+
- customer_id (text): Customer identifier
|
|
1335
|
+
- retrieved_date (text): Creation timestamp
|
|
1336
|
+
- import_uuid (text): Import identifier
|
|
1337
|
+
- project_code (text): Project code
|
|
1338
|
+
- project_url (text): Project URL
|
|
1339
|
+
"""
|
|
1340
|
+
input_data = context.get('input_data', {})
|
|
1341
|
+
execution_id = context.get('execution_id', 'unknown')
|
|
1342
|
+
|
|
1343
|
+
# Generate server-compatible keyid
|
|
1344
|
+
keyid = f"{input_data.get('org_id', 'unknown')}_{draft.get('customer_id', 'unknown')}_{execution_id}_{draft['draft_id']}"
|
|
1345
|
+
|
|
1346
|
+
# Map approach to mail_tone
|
|
1347
|
+
tone_mapping = {
|
|
1348
|
+
'professional_direct': 'Professional',
|
|
1349
|
+
'consultative': 'Consultative',
|
|
1350
|
+
'industry_expert': 'Expert',
|
|
1351
|
+
'relationship_building': 'Friendly'
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
server_draft = {
|
|
1355
|
+
'body': draft.get('email_body', ''),
|
|
1356
|
+
'subject': draft.get('subject', ''),
|
|
1357
|
+
'mail_tone': tone_mapping.get(draft.get('approach', 'professional_direct'), 'Professional'),
|
|
1358
|
+
'priority_order': self._get_draft_priority_order(draft),
|
|
1359
|
+
'language': input_data.get('language', 'English').title(),
|
|
1360
|
+
'keyid': keyid,
|
|
1361
|
+
'customer_language': input_data.get('language', 'english').lower() != 'english',
|
|
1362
|
+
'task_id': execution_id,
|
|
1363
|
+
'org_id': input_data.get('org_id', 'unknown'),
|
|
1364
|
+
'customer_id': draft.get('customer_id', execution_id),
|
|
1365
|
+
'retrieved_date': datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
|
|
1366
|
+
'import_uuid': f"{input_data.get('org_id', 'unknown')}_{draft.get('customer_id', execution_id)}_{execution_id}",
|
|
1367
|
+
'project_code': input_data.get('project_code', 'LOCAL'),
|
|
1368
|
+
'project_url': input_data.get('project_url', ''),
|
|
1369
|
+
|
|
1370
|
+
# Keep local fields for compatibility
|
|
1371
|
+
'draft_id': draft.get('draft_id'),
|
|
1372
|
+
'approach': draft.get('approach'),
|
|
1373
|
+
'tone': draft.get('tone'),
|
|
1374
|
+
'focus': draft.get('focus'),
|
|
1375
|
+
'subject_alternatives': draft.get('subject_alternatives', []),
|
|
1376
|
+
'call_to_action': draft.get('call_to_action'),
|
|
1377
|
+
'personalization_score': draft.get('personalization_score'),
|
|
1378
|
+
'generated_at': draft.get('generated_at'),
|
|
1379
|
+
'status': draft.get('status'),
|
|
1380
|
+
'metadata': draft.get('metadata', {})
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
return server_draft
|
|
1384
|
+
|
|
1385
|
+
def _get_draft_priority_order(self, draft: Dict[str, Any]) -> int:
|
|
1386
|
+
"""Get priority order for draft based on approach and personalization score."""
|
|
1387
|
+
approach_priorities = {
|
|
1388
|
+
'professional_direct': 1,
|
|
1389
|
+
'consultative': 2,
|
|
1390
|
+
'industry_expert': 3,
|
|
1391
|
+
'relationship_building': 4
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
base_priority = approach_priorities.get(draft.get('approach', 'professional_direct'), 1)
|
|
1395
|
+
personalization_score = draft.get('personalization_score', 50)
|
|
1396
|
+
|
|
1397
|
+
# Adjust priority based on personalization score
|
|
1398
|
+
if personalization_score >= 80:
|
|
1399
|
+
return base_priority
|
|
1400
|
+
elif personalization_score >= 60:
|
|
1401
|
+
return base_priority + 1
|
|
1402
|
+
else:
|
|
1403
|
+
return base_priority + 2
|
|
1404
|
+
|
|
1405
|
+
def _save_email_drafts(self, context: Dict[str, Any], email_drafts: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
1406
|
+
"""Save email drafts to database and files."""
|
|
1407
|
+
try:
|
|
1408
|
+
execution_id = context.get('execution_id')
|
|
1409
|
+
saved_drafts = []
|
|
1410
|
+
|
|
1411
|
+
# Get data manager for database operations
|
|
1412
|
+
data_manager = self.data_manager
|
|
1413
|
+
|
|
1414
|
+
for draft in email_drafts:
|
|
1415
|
+
try:
|
|
1416
|
+
# Prepare draft data for database
|
|
1417
|
+
draft_data = {
|
|
1418
|
+
'draft_id': draft['draft_id'],
|
|
1419
|
+
'execution_id': execution_id,
|
|
1420
|
+
'customer_id': execution_id, # Using execution_id as customer_id for now
|
|
1421
|
+
'subject': draft.get('subject', 'No Subject'),
|
|
1422
|
+
'content': draft['email_body'],
|
|
1423
|
+
'draft_type': 'initial',
|
|
1424
|
+
'version': 1,
|
|
1425
|
+
'status': 'draft',
|
|
1426
|
+
'metadata': json.dumps({
|
|
1427
|
+
'approach': draft.get('approach', 'unknown'),
|
|
1428
|
+
'tone': draft.get('tone', 'professional'),
|
|
1429
|
+
'focus': draft.get('focus', 'general'),
|
|
1430
|
+
'all_subject_lines': [draft.get('subject', '')] + draft.get('subject_alternatives', []),
|
|
1431
|
+
'call_to_action': draft.get('call_to_action', ''),
|
|
1432
|
+
'personalization_score': draft.get('personalization_score', 0),
|
|
1433
|
+
'generation_method': draft.get('metadata', {}).get('generation_method', 'llm'),
|
|
1434
|
+
'generated_at': draft.get('generated_at', datetime.now().isoformat())
|
|
1435
|
+
})
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1438
|
+
# Save to database
|
|
1439
|
+
if not self.is_dry_run():
|
|
1440
|
+
data_manager.save_email_draft(
|
|
1441
|
+
draft_id=draft_data['draft_id'],
|
|
1442
|
+
execution_id=draft_data['execution_id'],
|
|
1443
|
+
customer_id=draft_data['customer_id'],
|
|
1444
|
+
subject=draft_data['subject'],
|
|
1445
|
+
content=draft_data['content'],
|
|
1446
|
+
draft_type=draft_data['draft_type'],
|
|
1447
|
+
version=draft_data['version']
|
|
1448
|
+
)
|
|
1449
|
+
self.logger.info(f"Saved draft {draft['draft_id']} to database")
|
|
1450
|
+
|
|
1451
|
+
# Save to file system for backup
|
|
1452
|
+
draft_file_path = self._save_draft_to_file(execution_id, draft)
|
|
1453
|
+
|
|
1454
|
+
# Add context to draft
|
|
1455
|
+
draft_with_context = draft.copy()
|
|
1456
|
+
draft_with_context['execution_id'] = execution_id
|
|
1457
|
+
draft_with_context['file_path'] = draft_file_path
|
|
1458
|
+
draft_with_context['database_saved'] = not self.is_dry_run()
|
|
1459
|
+
draft_with_context['saved_at'] = datetime.now().isoformat()
|
|
1460
|
+
|
|
1461
|
+
saved_drafts.append(draft_with_context)
|
|
1462
|
+
|
|
1463
|
+
except Exception as e:
|
|
1464
|
+
self.logger.error(f"Failed to save individual draft {draft.get('draft_id', 'unknown')}: {str(e)}")
|
|
1465
|
+
# Still add to saved_drafts but mark as failed
|
|
1466
|
+
draft_with_context = draft.copy()
|
|
1467
|
+
draft_with_context['execution_id'] = execution_id
|
|
1468
|
+
draft_with_context['save_error'] = str(e)
|
|
1469
|
+
draft_with_context['database_saved'] = False
|
|
1470
|
+
saved_drafts.append(draft_with_context)
|
|
1471
|
+
|
|
1472
|
+
self.logger.info(f"Successfully saved {len([d for d in saved_drafts if d.get('database_saved', False)])} drafts to database")
|
|
1473
|
+
return saved_drafts
|
|
1474
|
+
|
|
1475
|
+
except Exception as e:
|
|
1476
|
+
self.logger.error(f"Failed to save email drafts: {str(e)}")
|
|
1477
|
+
# Return drafts with error information
|
|
1478
|
+
for draft in email_drafts:
|
|
1479
|
+
draft['save_error'] = str(e)
|
|
1480
|
+
draft['database_saved'] = False
|
|
1481
|
+
return email_drafts
|
|
1482
|
+
|
|
1483
|
+
def _save_draft_to_file(self, execution_id: str, draft: Dict[str, Any]) -> str:
|
|
1484
|
+
"""Save draft to file system as backup."""
|
|
1485
|
+
try:
|
|
1486
|
+
import os
|
|
1487
|
+
|
|
1488
|
+
# Create drafts directory if it doesn't exist
|
|
1489
|
+
drafts_dir = os.path.join(self.config.get('data_dir', './fusesell_data'), 'drafts')
|
|
1490
|
+
os.makedirs(drafts_dir, exist_ok=True)
|
|
1491
|
+
|
|
1492
|
+
# Create file path
|
|
1493
|
+
file_name = f"{execution_id}_{draft['draft_id']}.json"
|
|
1494
|
+
file_path = os.path.join(drafts_dir, file_name)
|
|
1495
|
+
|
|
1496
|
+
# Save draft to file
|
|
1497
|
+
with open(file_path, 'w', encoding='utf-8') as f:
|
|
1498
|
+
json.dump(draft, f, indent=2, ensure_ascii=False)
|
|
1499
|
+
|
|
1500
|
+
return f"drafts/{file_name}"
|
|
1501
|
+
|
|
1502
|
+
except Exception as e:
|
|
1503
|
+
self.logger.warning(f"Failed to save draft to file: {str(e)}")
|
|
1504
|
+
return f"drafts/{execution_id}_{draft['draft_id']}.json"
|
|
1505
|
+
|
|
1506
|
+
def _get_draft_by_id(self, draft_id: str) -> Optional[Dict[str, Any]]:
|
|
1507
|
+
"""Retrieve draft by ID from database."""
|
|
1508
|
+
if self.is_dry_run():
|
|
1509
|
+
return {
|
|
1510
|
+
'draft_id': draft_id,
|
|
1511
|
+
'subject': 'Mock Subject Line',
|
|
1512
|
+
'subject_alternatives': ['Alternative Mock Subject'],
|
|
1513
|
+
'email_body': 'Mock email content for testing purposes.',
|
|
1514
|
+
'approach': 'mock',
|
|
1515
|
+
'tone': 'professional',
|
|
1516
|
+
'status': 'mock',
|
|
1517
|
+
'call_to_action': 'Mock call to action',
|
|
1518
|
+
'personalization_score': 75
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
try:
|
|
1522
|
+
# Get data manager for database operations
|
|
1523
|
+
data_manager = self.data_manager
|
|
1524
|
+
|
|
1525
|
+
# Query database for draft
|
|
1526
|
+
draft_record = data_manager.get_email_draft_by_id(draft_id)
|
|
1527
|
+
|
|
1528
|
+
if not draft_record:
|
|
1529
|
+
self.logger.warning(f"Draft not found in database: {draft_id}")
|
|
1530
|
+
return None
|
|
1531
|
+
|
|
1532
|
+
# Parse metadata
|
|
1533
|
+
metadata = {}
|
|
1534
|
+
if draft_record.get('metadata'):
|
|
1535
|
+
try:
|
|
1536
|
+
metadata = json.loads(draft_record['metadata'])
|
|
1537
|
+
except json.JSONDecodeError:
|
|
1538
|
+
self.logger.warning(f"Failed to parse metadata for draft {draft_id}")
|
|
1539
|
+
|
|
1540
|
+
# Reconstruct draft object
|
|
1541
|
+
draft = {
|
|
1542
|
+
'draft_id': draft_record['draft_id'],
|
|
1543
|
+
'execution_id': draft_record['execution_id'],
|
|
1544
|
+
'customer_id': draft_record['customer_id'],
|
|
1545
|
+
'subject': draft_record['subject'],
|
|
1546
|
+
'subject_alternatives': metadata.get('all_subject_lines', [])[1:] if len(metadata.get('all_subject_lines', [])) > 1 else [],
|
|
1547
|
+
'email_body': draft_record['content'],
|
|
1548
|
+
'approach': metadata.get('approach', 'unknown'),
|
|
1549
|
+
'tone': metadata.get('tone', 'professional'),
|
|
1550
|
+
'focus': metadata.get('focus', 'general'),
|
|
1551
|
+
'call_to_action': metadata.get('call_to_action', ''),
|
|
1552
|
+
'personalization_score': metadata.get('personalization_score', 0),
|
|
1553
|
+
'status': draft_record['status'],
|
|
1554
|
+
'version': draft_record['version'],
|
|
1555
|
+
'draft_type': draft_record['draft_type'],
|
|
1556
|
+
'created_at': draft_record['created_at'],
|
|
1557
|
+
'updated_at': draft_record['updated_at'],
|
|
1558
|
+
'metadata': metadata
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1561
|
+
self.logger.info(f"Retrieved draft {draft_id} from database")
|
|
1562
|
+
return draft
|
|
1563
|
+
|
|
1564
|
+
except Exception as e:
|
|
1565
|
+
self.logger.error(f"Failed to retrieve draft {draft_id}: {str(e)}")
|
|
1566
|
+
return None
|
|
1567
|
+
|
|
1568
|
+
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]:
|
|
1569
|
+
"""Rewrite existing draft based on reason using LLM."""
|
|
1570
|
+
try:
|
|
1571
|
+
if self.is_dry_run():
|
|
1572
|
+
rewritten = existing_draft.copy()
|
|
1573
|
+
rewritten['draft_id'] = f"uuid:{str(uuid.uuid4())}"
|
|
1574
|
+
rewritten['draft_approach'] = "rewrite"
|
|
1575
|
+
rewritten['draft_type'] = "rewrite"
|
|
1576
|
+
rewritten['email_body'] = f"[DRY RUN - REWRITTEN: {reason}] " + existing_draft.get('email_body', '')
|
|
1577
|
+
rewritten['rewrite_reason'] = reason
|
|
1578
|
+
rewritten['rewritten_at'] = datetime.now().isoformat()
|
|
1579
|
+
return rewritten
|
|
1580
|
+
|
|
1581
|
+
input_data = context.get('input_data', {})
|
|
1582
|
+
company_info = customer_data.get('companyInfo', {})
|
|
1583
|
+
contact_info = customer_data.get('primaryContact', {})
|
|
1584
|
+
|
|
1585
|
+
# Create rewrite prompt
|
|
1586
|
+
rewrite_prompt = f"""Rewrite the following email based on the feedback provided. Keep the core message and personalization but address the specific concerns mentioned.
|
|
1587
|
+
|
|
1588
|
+
ORIGINAL EMAIL:
|
|
1589
|
+
{existing_draft.get('email_body', '')}
|
|
1590
|
+
|
|
1591
|
+
FEEDBACK/REASON FOR REWRITE:
|
|
1592
|
+
{reason}
|
|
1593
|
+
|
|
1594
|
+
CUSTOMER CONTEXT:
|
|
1595
|
+
- Company: {company_info.get('name', 'the company')}
|
|
1596
|
+
- Contact: {contact_info.get('name', 'the contact')}
|
|
1597
|
+
- Industry: {company_info.get('industry', 'technology')}
|
|
1598
|
+
|
|
1599
|
+
REQUIREMENTS:
|
|
1600
|
+
1. Address the feedback/reason provided
|
|
1601
|
+
2. Maintain personalization and relevance
|
|
1602
|
+
3. Keep the professional tone
|
|
1603
|
+
4. Ensure the email flows naturally
|
|
1604
|
+
5. Include a clear call-to-action
|
|
1605
|
+
6. Make improvements based on the feedback
|
|
1606
|
+
|
|
1607
|
+
Generate only the rewritten email content:"""
|
|
1608
|
+
|
|
1609
|
+
# Generate rewritten content using LLM
|
|
1610
|
+
rewritten_content = self.call_llm(
|
|
1611
|
+
prompt=rewrite_prompt,
|
|
1612
|
+
temperature=0.6,
|
|
1613
|
+
max_tokens=800
|
|
1614
|
+
)
|
|
1615
|
+
|
|
1616
|
+
# Clean the rewritten content
|
|
1617
|
+
cleaned_content = self._clean_email_content(rewritten_content)
|
|
1618
|
+
|
|
1619
|
+
# Create rewritten draft object
|
|
1620
|
+
rewritten = existing_draft.copy()
|
|
1621
|
+
rewritten['draft_id'] = f"uuid:{str(uuid.uuid4())}"
|
|
1622
|
+
rewritten['draft_approach'] = "rewrite"
|
|
1623
|
+
rewritten['draft_type'] = "rewrite"
|
|
1624
|
+
rewritten['email_body'] = cleaned_content
|
|
1625
|
+
rewritten['rewrite_reason'] = reason
|
|
1626
|
+
rewritten['rewritten_at'] = datetime.now().isoformat()
|
|
1627
|
+
rewritten['version'] = existing_draft.get('version', 1) + 1
|
|
1628
|
+
rewritten['call_to_action'] = self._extract_call_to_action(cleaned_content)
|
|
1629
|
+
rewritten['personalization_score'] = self._calculate_personalization_score(cleaned_content, customer_data)
|
|
1630
|
+
|
|
1631
|
+
# Update metadata
|
|
1632
|
+
if 'metadata' not in rewritten:
|
|
1633
|
+
rewritten['metadata'] = {}
|
|
1634
|
+
rewritten['metadata']['rewrite_history'] = rewritten['metadata'].get('rewrite_history', [])
|
|
1635
|
+
rewritten['metadata']['rewrite_history'].append({
|
|
1636
|
+
'reason': reason,
|
|
1637
|
+
'rewritten_at': datetime.now().isoformat(),
|
|
1638
|
+
'original_draft_id': existing_draft.get('draft_id')
|
|
1639
|
+
})
|
|
1640
|
+
rewritten['metadata']['generation_method'] = 'llm_rewrite'
|
|
1641
|
+
|
|
1642
|
+
self.logger.info(f"Successfully rewrote draft based on reason: {reason}")
|
|
1643
|
+
return rewritten
|
|
1644
|
+
|
|
1645
|
+
except Exception as e:
|
|
1646
|
+
self.logger.error(f"Failed to rewrite draft: {str(e)}")
|
|
1647
|
+
# Fallback to simple modification
|
|
1648
|
+
rewritten = existing_draft.copy()
|
|
1649
|
+
rewritten['draft_id'] = f"uuid:{str(uuid.uuid4())}"
|
|
1650
|
+
rewritten['draft_approach'] = "rewrite"
|
|
1651
|
+
rewritten['draft_type'] = "rewrite"
|
|
1652
|
+
rewritten['email_body'] = f"[REWRITTEN: {reason}]\n\n" + existing_draft.get('email_body', '')
|
|
1653
|
+
rewritten['rewrite_reason'] = reason
|
|
1654
|
+
rewritten['rewritten_at'] = datetime.now().isoformat()
|
|
1655
|
+
rewritten['metadata'] = {'generation_method': 'template_rewrite', 'error': str(e)}
|
|
1656
|
+
return rewritten
|
|
1657
|
+
|
|
1658
|
+
def _save_rewritten_draft(self, context: Dict[str, Any], rewritten_draft: Dict[str, Any], original_draft_id: str) -> Dict[str, Any]:
|
|
1659
|
+
"""Save rewritten draft to database and file system."""
|
|
1660
|
+
try:
|
|
1661
|
+
execution_id = context.get('execution_id')
|
|
1662
|
+
rewritten_draft['original_draft_id'] = original_draft_id
|
|
1663
|
+
rewritten_draft['execution_id'] = execution_id
|
|
1664
|
+
|
|
1665
|
+
# Get data manager for database operations
|
|
1666
|
+
data_manager = self.data_manager
|
|
1667
|
+
|
|
1668
|
+
# Prepare draft data for database
|
|
1669
|
+
draft_data = {
|
|
1670
|
+
'draft_id': rewritten_draft['draft_id'],
|
|
1671
|
+
'execution_id': execution_id,
|
|
1672
|
+
'customer_id': execution_id, # Using execution_id as customer_id for now
|
|
1673
|
+
'subject': rewritten_draft.get('subject', 'Rewritten Draft'),
|
|
1674
|
+
'content': rewritten_draft['email_body'],
|
|
1675
|
+
'draft_type': 'initial_rewrite',
|
|
1676
|
+
'version': rewritten_draft.get('version', 2),
|
|
1677
|
+
'status': 'draft',
|
|
1678
|
+
'metadata': json.dumps({
|
|
1679
|
+
'approach': rewritten_draft.get('approach', 'rewritten'),
|
|
1680
|
+
'tone': rewritten_draft.get('tone', 'professional'),
|
|
1681
|
+
'focus': rewritten_draft.get('focus', 'general'),
|
|
1682
|
+
'all_subject_lines': [rewritten_draft.get('subject', '')] + rewritten_draft.get('subject_alternatives', []),
|
|
1683
|
+
'call_to_action': rewritten_draft.get('call_to_action', ''),
|
|
1684
|
+
'personalization_score': rewritten_draft.get('personalization_score', 0),
|
|
1685
|
+
'generation_method': 'llm_rewrite',
|
|
1686
|
+
'rewrite_reason': rewritten_draft.get('rewrite_reason', ''),
|
|
1687
|
+
'original_draft_id': original_draft_id,
|
|
1688
|
+
'rewritten_at': rewritten_draft.get('rewritten_at', datetime.now().isoformat()),
|
|
1689
|
+
'rewrite_history': rewritten_draft.get('metadata', {}).get('rewrite_history', [])
|
|
1690
|
+
})
|
|
1691
|
+
}
|
|
1692
|
+
|
|
1693
|
+
# Save to database
|
|
1694
|
+
if not self.is_dry_run():
|
|
1695
|
+
data_manager.save_email_draft(draft_data)
|
|
1696
|
+
self.logger.info(f"Saved rewritten draft {rewritten_draft['draft_id']} to database")
|
|
1697
|
+
|
|
1698
|
+
# Save to file system for backup
|
|
1699
|
+
draft_file_path = self._save_draft_to_file(execution_id, rewritten_draft)
|
|
1700
|
+
|
|
1701
|
+
# Add save information
|
|
1702
|
+
rewritten_draft['file_path'] = draft_file_path
|
|
1703
|
+
rewritten_draft['database_saved'] = not self.is_dry_run()
|
|
1704
|
+
rewritten_draft['saved_at'] = datetime.now().isoformat()
|
|
1705
|
+
|
|
1706
|
+
return rewritten_draft
|
|
1707
|
+
|
|
1708
|
+
except Exception as e:
|
|
1709
|
+
self.logger.error(f"Failed to save rewritten draft: {str(e)}")
|
|
1710
|
+
rewritten_draft['save_error'] = str(e)
|
|
1711
|
+
rewritten_draft['database_saved'] = False
|
|
1712
|
+
return rewritten_draft
|
|
1713
|
+
|
|
1714
|
+
def _send_email(self, draft: Dict[str, Any], recipient_address: str, recipient_name: str, context: Dict[str, Any]) -> Dict[str, Any]:
|
|
1715
|
+
"""Send email using existing RTA email service (matching original YAML)."""
|
|
1716
|
+
if self.is_dry_run():
|
|
1717
|
+
return {
|
|
1718
|
+
'success': True,
|
|
1719
|
+
'message': f'[DRY RUN] Would send email to {recipient_address}',
|
|
1720
|
+
'email_id': f'mock_email_{uuid.uuid4().hex[:8]}'
|
|
1721
|
+
}
|
|
1722
|
+
|
|
1723
|
+
try:
|
|
1724
|
+
input_data = context.get('input_data', {})
|
|
1725
|
+
|
|
1726
|
+
# Get auto interaction settings from team settings
|
|
1727
|
+
auto_interaction_config = self._get_auto_interaction_config(input_data.get('team_id'))
|
|
1728
|
+
|
|
1729
|
+
# Prepare email payload for RTA email service (matching trigger_auto_interaction)
|
|
1730
|
+
email_payload = {
|
|
1731
|
+
"project_code": input_data.get('project_code', ''),
|
|
1732
|
+
"event_type": "custom",
|
|
1733
|
+
"event_id": input_data.get('customer_id', context.get('execution_id')),
|
|
1734
|
+
"type": "interaction",
|
|
1735
|
+
"family": "GLOBALSELL_INTERACT_EVENT_ADHOC",
|
|
1736
|
+
"language": input_data.get('language', 'english'),
|
|
1737
|
+
"submission_date": datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
|
|
1738
|
+
"instanceName": f"Send email to {recipient_name} ({recipient_address}) from {auto_interaction_config.get('from_name', input_data.get('org_name', 'Unknown'))}",
|
|
1739
|
+
"instanceID": f"{context.get('execution_id')}_{input_data.get('customer_id', 'unknown')}",
|
|
1740
|
+
"uuid": f"{context.get('execution_id')}_{input_data.get('customer_id', 'unknown')}",
|
|
1741
|
+
"action_type": auto_interaction_config.get('tool_type', 'email').lower(),
|
|
1742
|
+
"email": recipient_address,
|
|
1743
|
+
"number": auto_interaction_config.get('from_number', input_data.get('customer_phone', '')),
|
|
1744
|
+
"subject": draft.get('subject', ''), # Use subject line
|
|
1745
|
+
"content": draft.get('email_body', ''),
|
|
1746
|
+
"team_id": input_data.get('team_id', ''),
|
|
1747
|
+
"from_email": auto_interaction_config.get('from_email', ''),
|
|
1748
|
+
"from_name": auto_interaction_config.get('from_name', ''),
|
|
1749
|
+
"email_cc": auto_interaction_config.get('email_cc', ''),
|
|
1750
|
+
"email_bcc": auto_interaction_config.get('email_bcc', ''),
|
|
1751
|
+
"extraData": {
|
|
1752
|
+
"org_id": input_data.get('org_id'),
|
|
1753
|
+
"human_action_id": input_data.get('human_action_id', ''),
|
|
1754
|
+
"email_tags": "gs_148_initial_outreach",
|
|
1755
|
+
"task_id": input_data.get('customer_id', context.get('execution_id'))
|
|
1756
|
+
}
|
|
1757
|
+
}
|
|
1758
|
+
|
|
1759
|
+
# Send to RTA email service
|
|
1760
|
+
headers = {
|
|
1761
|
+
'Content-Type': 'application/json'
|
|
1762
|
+
}
|
|
1763
|
+
|
|
1764
|
+
response = requests.post(
|
|
1765
|
+
'https://automation.rta.vn/webhook/autoemail-trigger-by-inst-check',
|
|
1766
|
+
json=email_payload,
|
|
1767
|
+
headers=headers,
|
|
1768
|
+
timeout=30
|
|
1769
|
+
)
|
|
1770
|
+
|
|
1771
|
+
if response.status_code == 200:
|
|
1772
|
+
result = response.json()
|
|
1773
|
+
execution_id = result.get('executionID', '')
|
|
1774
|
+
|
|
1775
|
+
if execution_id:
|
|
1776
|
+
self.logger.info(f"Email sent successfully via RTA service: {execution_id}")
|
|
1777
|
+
return {
|
|
1778
|
+
'success': True,
|
|
1779
|
+
'message': f'Email sent to {recipient_address}',
|
|
1780
|
+
'email_id': execution_id,
|
|
1781
|
+
'service': 'RTA_email_service',
|
|
1782
|
+
'response': result
|
|
1783
|
+
}
|
|
1784
|
+
else:
|
|
1785
|
+
self.logger.warning("Email service returned success but no execution ID")
|
|
1786
|
+
return {
|
|
1787
|
+
'success': False,
|
|
1788
|
+
'message': 'Email service returned success but no execution ID',
|
|
1789
|
+
'response': result
|
|
1790
|
+
}
|
|
1791
|
+
else:
|
|
1792
|
+
self.logger.error(f"Email service returned error: {response.status_code} - {response.text}")
|
|
1793
|
+
return {
|
|
1794
|
+
'success': False,
|
|
1795
|
+
'message': f'Email service error: {response.status_code}',
|
|
1796
|
+
'error': response.text
|
|
1797
|
+
}
|
|
1798
|
+
|
|
1799
|
+
except Exception as e:
|
|
1800
|
+
self.logger.error(f"Email sending failed: {str(e)}")
|
|
1801
|
+
return {
|
|
1802
|
+
'success': False,
|
|
1803
|
+
'message': f'Email sending failed: {str(e)}',
|
|
1804
|
+
'error': str(e)
|
|
1805
|
+
}
|
|
1806
|
+
|
|
1807
|
+
def get_drafts_for_execution(self, execution_id: str) -> List[Dict[str, Any]]:
|
|
1808
|
+
"""Get all drafts for a specific execution."""
|
|
1809
|
+
try:
|
|
1810
|
+
data_manager = self.data_manager
|
|
1811
|
+
draft_records = data_manager.get_email_drafts_by_execution(execution_id)
|
|
1812
|
+
|
|
1813
|
+
drafts = []
|
|
1814
|
+
for record in draft_records:
|
|
1815
|
+
# Parse metadata
|
|
1816
|
+
metadata = {}
|
|
1817
|
+
if record.get('metadata'):
|
|
1818
|
+
try:
|
|
1819
|
+
metadata = json.loads(record['metadata'])
|
|
1820
|
+
except json.JSONDecodeError:
|
|
1821
|
+
pass
|
|
1822
|
+
|
|
1823
|
+
draft = {
|
|
1824
|
+
'draft_id': record['draft_id'],
|
|
1825
|
+
'execution_id': record['execution_id'],
|
|
1826
|
+
'subject': record['subject'],
|
|
1827
|
+
'content': record['content'],
|
|
1828
|
+
'approach': metadata.get('approach', 'unknown'),
|
|
1829
|
+
'tone': metadata.get('tone', 'professional'),
|
|
1830
|
+
'personalization_score': metadata.get('personalization_score', 0),
|
|
1831
|
+
'status': record['status'],
|
|
1832
|
+
'version': record['version'],
|
|
1833
|
+
'created_at': record['created_at'],
|
|
1834
|
+
'updated_at': record['updated_at'],
|
|
1835
|
+
'metadata': metadata
|
|
1836
|
+
}
|
|
1837
|
+
drafts.append(draft)
|
|
1838
|
+
|
|
1839
|
+
return drafts
|
|
1840
|
+
|
|
1841
|
+
except Exception as e:
|
|
1842
|
+
self.logger.error(f"Failed to get drafts for execution {execution_id}: {str(e)}")
|
|
1843
|
+
return []
|
|
1844
|
+
|
|
1845
|
+
def compare_drafts(self, draft_ids: List[str]) -> Dict[str, Any]:
|
|
1846
|
+
"""Compare multiple drafts and provide analysis."""
|
|
1847
|
+
try:
|
|
1848
|
+
drafts = []
|
|
1849
|
+
for draft_id in draft_ids:
|
|
1850
|
+
draft = self._get_draft_by_id(draft_id)
|
|
1851
|
+
if draft:
|
|
1852
|
+
drafts.append(draft)
|
|
1853
|
+
|
|
1854
|
+
if len(drafts) < 2:
|
|
1855
|
+
return {'error': 'Need at least 2 drafts to compare'}
|
|
1856
|
+
|
|
1857
|
+
comparison = {
|
|
1858
|
+
'drafts_compared': len(drafts),
|
|
1859
|
+
'comparison_timestamp': datetime.now().isoformat(),
|
|
1860
|
+
'drafts': [],
|
|
1861
|
+
'analysis': {
|
|
1862
|
+
'personalization_scores': {},
|
|
1863
|
+
'approaches': {},
|
|
1864
|
+
'length_analysis': {},
|
|
1865
|
+
'tone_analysis': {},
|
|
1866
|
+
'recommendations': []
|
|
1867
|
+
}
|
|
1868
|
+
}
|
|
1869
|
+
|
|
1870
|
+
# Analyze each draft
|
|
1871
|
+
for draft in drafts:
|
|
1872
|
+
draft_analysis = {
|
|
1873
|
+
'draft_id': draft['draft_id'],
|
|
1874
|
+
'approach': draft.get('approach', 'unknown'),
|
|
1875
|
+
'tone': draft.get('tone', 'professional'),
|
|
1876
|
+
'personalization_score': draft.get('personalization_score', 0),
|
|
1877
|
+
'word_count': len(draft.get('email_body', '').split()),
|
|
1878
|
+
'has_call_to_action': bool(draft.get('call_to_action')),
|
|
1879
|
+
'subject_line_count': 1 + len(draft.get('subject_alternatives', [])),
|
|
1880
|
+
'version': draft.get('version', 1)
|
|
1881
|
+
}
|
|
1882
|
+
comparison['drafts'].append(draft_analysis)
|
|
1883
|
+
|
|
1884
|
+
# Collect data for analysis
|
|
1885
|
+
comparison['analysis']['personalization_scores'][draft['draft_id']] = draft.get('personalization_score', 0)
|
|
1886
|
+
comparison['analysis']['approaches'][draft['draft_id']] = draft.get('approach', 'unknown')
|
|
1887
|
+
comparison['analysis']['length_analysis'][draft['draft_id']] = draft_analysis['word_count']
|
|
1888
|
+
comparison['analysis']['tone_analysis'][draft['draft_id']] = draft.get('tone', 'professional')
|
|
1889
|
+
|
|
1890
|
+
# Generate recommendations
|
|
1891
|
+
best_personalization = max(comparison['analysis']['personalization_scores'].items(), key=lambda x: x[1])
|
|
1892
|
+
comparison['analysis']['recommendations'].append(
|
|
1893
|
+
f"Draft {best_personalization[0]} has the highest personalization score ({best_personalization[1]})"
|
|
1894
|
+
)
|
|
1895
|
+
|
|
1896
|
+
# Length recommendations
|
|
1897
|
+
avg_length = sum(comparison['analysis']['length_analysis'].values()) / len(comparison['analysis']['length_analysis'])
|
|
1898
|
+
for draft_id, length in comparison['analysis']['length_analysis'].items():
|
|
1899
|
+
if length < avg_length * 0.7:
|
|
1900
|
+
comparison['analysis']['recommendations'].append(f"Draft {draft_id} might be too short ({length} words)")
|
|
1901
|
+
elif length > avg_length * 1.5:
|
|
1902
|
+
comparison['analysis']['recommendations'].append(f"Draft {draft_id} might be too long ({length} words)")
|
|
1903
|
+
|
|
1904
|
+
return comparison
|
|
1905
|
+
|
|
1906
|
+
except Exception as e:
|
|
1907
|
+
self.logger.error(f"Failed to compare drafts: {str(e)}")
|
|
1908
|
+
return {'error': str(e)}
|
|
1909
|
+
|
|
1910
|
+
def select_best_draft(self, execution_id: str, criteria: Dict[str, Any] = None) -> Optional[Dict[str, Any]]:
|
|
1911
|
+
"""Select the best draft based on criteria."""
|
|
1912
|
+
try:
|
|
1913
|
+
drafts = self.get_drafts_for_execution(execution_id)
|
|
1914
|
+
|
|
1915
|
+
if not drafts:
|
|
1916
|
+
return None
|
|
1917
|
+
|
|
1918
|
+
if len(drafts) == 1:
|
|
1919
|
+
return drafts[0]
|
|
1920
|
+
|
|
1921
|
+
# Default criteria if none provided
|
|
1922
|
+
if not criteria:
|
|
1923
|
+
criteria = {
|
|
1924
|
+
'personalization_weight': 0.4,
|
|
1925
|
+
'approach_preference': 'professional_direct',
|
|
1926
|
+
'length_preference': 'medium', # short, medium, long
|
|
1927
|
+
'tone_preference': 'professional'
|
|
1928
|
+
}
|
|
1929
|
+
|
|
1930
|
+
scored_drafts = []
|
|
1931
|
+
|
|
1932
|
+
for draft in drafts:
|
|
1933
|
+
score = 0
|
|
1934
|
+
|
|
1935
|
+
# Personalization score (0-100, normalize to 0-1)
|
|
1936
|
+
personalization_score = draft.get('personalization_score', 0) / 100
|
|
1937
|
+
score += personalization_score * criteria.get('personalization_weight', 0.4)
|
|
1938
|
+
|
|
1939
|
+
# Approach preference
|
|
1940
|
+
if draft.get('approach') == criteria.get('approach_preference'):
|
|
1941
|
+
score += 0.3
|
|
1942
|
+
|
|
1943
|
+
# Length preference
|
|
1944
|
+
word_count = len(draft.get('email_body', '').split())
|
|
1945
|
+
if criteria.get('length_preference') == 'short' and word_count < 150:
|
|
1946
|
+
score += 0.2
|
|
1947
|
+
elif criteria.get('length_preference') == 'medium' and 150 <= word_count <= 300:
|
|
1948
|
+
score += 0.2
|
|
1949
|
+
elif criteria.get('length_preference') == 'long' and word_count > 300:
|
|
1950
|
+
score += 0.2
|
|
1951
|
+
|
|
1952
|
+
# Tone preference
|
|
1953
|
+
if draft.get('tone', '').lower().find(criteria.get('tone_preference', '').lower()) != -1:
|
|
1954
|
+
score += 0.1
|
|
1955
|
+
|
|
1956
|
+
scored_drafts.append((draft, score))
|
|
1957
|
+
|
|
1958
|
+
# Sort by score and return best
|
|
1959
|
+
scored_drafts.sort(key=lambda x: x[1], reverse=True)
|
|
1960
|
+
best_draft = scored_drafts[0][0]
|
|
1961
|
+
|
|
1962
|
+
# Add selection metadata
|
|
1963
|
+
best_draft['selection_metadata'] = {
|
|
1964
|
+
'selected_at': datetime.now().isoformat(),
|
|
1965
|
+
'selection_score': scored_drafts[0][1],
|
|
1966
|
+
'criteria_used': criteria,
|
|
1967
|
+
'total_drafts_considered': len(drafts)
|
|
1968
|
+
}
|
|
1969
|
+
|
|
1970
|
+
return best_draft
|
|
1971
|
+
|
|
1972
|
+
except Exception as e:
|
|
1973
|
+
self.logger.error(f"Failed to select best draft: {str(e)}")
|
|
1974
|
+
return None
|
|
1975
|
+
|
|
1976
|
+
def get_draft_versions(self, original_draft_id: str) -> List[Dict[str, Any]]:
|
|
1977
|
+
"""Get all versions of a draft (original + rewrites)."""
|
|
1978
|
+
try:
|
|
1979
|
+
data_manager = self.data_manager
|
|
1980
|
+
|
|
1981
|
+
# Get original draft
|
|
1982
|
+
original_draft = self._get_draft_by_id(original_draft_id)
|
|
1983
|
+
if not original_draft:
|
|
1984
|
+
return []
|
|
1985
|
+
|
|
1986
|
+
versions = [original_draft]
|
|
1987
|
+
|
|
1988
|
+
# Get all rewrites of this draft
|
|
1989
|
+
rewrite_records = data_manager.get_email_drafts_by_original_id(original_draft_id)
|
|
1990
|
+
|
|
1991
|
+
for record in rewrite_records:
|
|
1992
|
+
# Parse metadata
|
|
1993
|
+
metadata = {}
|
|
1994
|
+
if record.get('metadata'):
|
|
1995
|
+
try:
|
|
1996
|
+
metadata = json.loads(record['metadata'])
|
|
1997
|
+
except json.JSONDecodeError:
|
|
1998
|
+
pass
|
|
1999
|
+
|
|
2000
|
+
rewrite_draft = {
|
|
2001
|
+
'draft_id': record['draft_id'],
|
|
2002
|
+
'execution_id': record['execution_id'],
|
|
2003
|
+
'subject': record['subject'],
|
|
2004
|
+
'content': record['content'],
|
|
2005
|
+
'approach': metadata.get('approach', 'rewritten'),
|
|
2006
|
+
'tone': metadata.get('tone', 'professional'),
|
|
2007
|
+
'personalization_score': metadata.get('personalization_score', 0),
|
|
2008
|
+
'status': record['status'],
|
|
2009
|
+
'version': record['version'],
|
|
2010
|
+
'created_at': record['created_at'],
|
|
2011
|
+
'updated_at': record['updated_at'],
|
|
2012
|
+
'rewrite_reason': metadata.get('rewrite_reason', ''),
|
|
2013
|
+
'original_draft_id': original_draft_id,
|
|
2014
|
+
'metadata': metadata
|
|
2015
|
+
}
|
|
2016
|
+
versions.append(rewrite_draft)
|
|
2017
|
+
|
|
2018
|
+
# Sort by version number
|
|
2019
|
+
versions.sort(key=lambda x: x.get('version', 1))
|
|
2020
|
+
|
|
2021
|
+
return versions
|
|
2022
|
+
|
|
2023
|
+
except Exception as e:
|
|
2024
|
+
self.logger.error(f"Failed to get draft versions for {original_draft_id}: {str(e)}")
|
|
2025
|
+
return []
|
|
2026
|
+
|
|
2027
|
+
def archive_draft(self, draft_id: str, reason: str = "Archived by user") -> bool:
|
|
2028
|
+
"""Archive a draft (mark as archived, don't delete)."""
|
|
2029
|
+
try:
|
|
2030
|
+
data_manager = self.data_manager
|
|
2031
|
+
|
|
2032
|
+
# Update draft status to archived
|
|
2033
|
+
success = data_manager.update_email_draft_status(draft_id, 'archived')
|
|
2034
|
+
|
|
2035
|
+
if success:
|
|
2036
|
+
self.logger.info(f"Archived draft {draft_id}: {reason}")
|
|
2037
|
+
return True
|
|
2038
|
+
else:
|
|
2039
|
+
self.logger.warning(f"Failed to archive draft {draft_id}")
|
|
2040
|
+
return False
|
|
2041
|
+
|
|
2042
|
+
except Exception as e:
|
|
2043
|
+
self.logger.error(f"Failed to archive draft {draft_id}: {str(e)}")
|
|
2044
|
+
return False
|
|
2045
|
+
|
|
2046
|
+
def duplicate_draft(self, draft_id: str, modifications: Dict[str, Any] = None) -> Optional[Dict[str, Any]]:
|
|
2047
|
+
"""Create a duplicate of an existing draft with optional modifications."""
|
|
2048
|
+
try:
|
|
2049
|
+
# Get original draft
|
|
2050
|
+
original_draft = self._get_draft_by_id(draft_id)
|
|
2051
|
+
if not original_draft:
|
|
2052
|
+
return None
|
|
2053
|
+
|
|
2054
|
+
# Create duplicate
|
|
2055
|
+
duplicate = original_draft.copy()
|
|
2056
|
+
duplicate['draft_id'] = f"uuid:{str(uuid.uuid4())}"
|
|
2057
|
+
duplicate['draft_approach'] = "duplicate"
|
|
2058
|
+
duplicate['draft_type'] = "duplicate"
|
|
2059
|
+
duplicate['version'] = 1
|
|
2060
|
+
duplicate['status'] = 'draft'
|
|
2061
|
+
duplicate['created_at'] = datetime.now().isoformat()
|
|
2062
|
+
duplicate['updated_at'] = datetime.now().isoformat()
|
|
2063
|
+
|
|
2064
|
+
# Apply modifications if provided
|
|
2065
|
+
if modifications:
|
|
2066
|
+
for key, value in modifications.items():
|
|
2067
|
+
if key in ['email_body', 'subject', 'subject_alternatives', 'approach', 'tone']:
|
|
2068
|
+
duplicate[key] = value
|
|
2069
|
+
|
|
2070
|
+
# Update metadata
|
|
2071
|
+
if 'metadata' not in duplicate:
|
|
2072
|
+
duplicate['metadata'] = {}
|
|
2073
|
+
duplicate['metadata']['duplicated_from'] = draft_id
|
|
2074
|
+
duplicate['metadata']['duplicated_at'] = datetime.now().isoformat()
|
|
2075
|
+
duplicate['metadata']['generation_method'] = 'duplicate'
|
|
2076
|
+
|
|
2077
|
+
# Save duplicate
|
|
2078
|
+
execution_id = duplicate.get('execution_id', 'unknown')
|
|
2079
|
+
saved_duplicate = self._save_email_drafts({'execution_id': execution_id}, [duplicate])
|
|
2080
|
+
|
|
2081
|
+
if saved_duplicate:
|
|
2082
|
+
return saved_duplicate[0]
|
|
2083
|
+
else:
|
|
2084
|
+
return None
|
|
2085
|
+
|
|
2086
|
+
except Exception as e:
|
|
2087
|
+
self.logger.error(f"Failed to duplicate draft {draft_id}: {str(e)}")
|
|
2088
|
+
return None
|
|
2089
|
+
|
|
2090
|
+
def _create_customer_summary(self, customer_data: Dict[str, Any]) -> Dict[str, Any]:
|
|
2091
|
+
"""Create comprehensive customer summary for outreach context."""
|
|
2092
|
+
company_info = customer_data.get('companyInfo', {})
|
|
2093
|
+
contact_info = customer_data.get('primaryContact', {})
|
|
2094
|
+
pain_points = customer_data.get('painPoints', [])
|
|
2095
|
+
|
|
2096
|
+
# Calculate summary metrics
|
|
2097
|
+
high_priority_pain_points = [p for p in pain_points if p.get('severity') == 'high']
|
|
2098
|
+
total_pain_points = len(pain_points)
|
|
2099
|
+
|
|
2100
|
+
return {
|
|
2101
|
+
'company_name': company_info.get('name', 'Unknown'),
|
|
2102
|
+
'industry': company_info.get('industry', 'Unknown'),
|
|
2103
|
+
'company_size': company_info.get('size', 'Unknown'),
|
|
2104
|
+
'annual_revenue': company_info.get('annualRevenue', 'Unknown'),
|
|
2105
|
+
'location': company_info.get('location', 'Unknown'),
|
|
2106
|
+
'website': company_info.get('website', 'Unknown'),
|
|
2107
|
+
'contact_name': contact_info.get('name', 'Unknown'),
|
|
2108
|
+
'contact_title': contact_info.get('title', 'Unknown'),
|
|
2109
|
+
'contact_email': contact_info.get('email', 'Unknown'),
|
|
2110
|
+
'contact_phone': contact_info.get('phone', 'Unknown'),
|
|
2111
|
+
'total_pain_points': total_pain_points,
|
|
2112
|
+
'high_priority_pain_points': len(high_priority_pain_points),
|
|
2113
|
+
'key_challenges': [p.get('description', '') for p in high_priority_pain_points[:3]],
|
|
2114
|
+
'business_profile': {
|
|
2115
|
+
'industry_focus': company_info.get('industry', 'Technology'),
|
|
2116
|
+
'company_stage': self._determine_company_stage(company_info),
|
|
2117
|
+
'technology_maturity': self._assess_technology_maturity(customer_data),
|
|
2118
|
+
'growth_indicators': self._identify_growth_indicators(customer_data)
|
|
2119
|
+
},
|
|
2120
|
+
'outreach_readiness': self._calculate_outreach_readiness(customer_data),
|
|
2121
|
+
'summary_generated_at': datetime.now().isoformat()
|
|
2122
|
+
}
|
|
2123
|
+
|
|
2124
|
+
def _determine_company_stage(self, company_info: Dict[str, Any]) -> str:
|
|
2125
|
+
"""Determine company stage based on size and revenue."""
|
|
2126
|
+
size = company_info.get('size', '').lower()
|
|
2127
|
+
revenue = company_info.get('annualRevenue', '').lower()
|
|
2128
|
+
|
|
2129
|
+
if 'startup' in size or 'small' in size:
|
|
2130
|
+
return 'startup'
|
|
2131
|
+
elif 'medium' in size or 'mid' in size:
|
|
2132
|
+
return 'growth'
|
|
2133
|
+
elif 'large' in size or 'enterprise' in size:
|
|
2134
|
+
return 'enterprise'
|
|
2135
|
+
elif any(indicator in revenue for indicator in ['million', 'billion']):
|
|
2136
|
+
return 'established'
|
|
2137
|
+
else:
|
|
2138
|
+
return 'unknown'
|
|
2139
|
+
|
|
2140
|
+
def _assess_technology_maturity(self, customer_data: Dict[str, Any]) -> str:
|
|
2141
|
+
"""Assess technology maturity based on available data."""
|
|
2142
|
+
tech_info = customer_data.get('technologyAndInnovation', {})
|
|
2143
|
+
pain_points = customer_data.get('painPoints', [])
|
|
2144
|
+
|
|
2145
|
+
# Look for technology-related indicators
|
|
2146
|
+
tech_keywords = ['digital', 'automation', 'cloud', 'ai', 'software', 'platform']
|
|
2147
|
+
legacy_keywords = ['manual', 'paper', 'outdated', 'legacy', 'traditional']
|
|
2148
|
+
|
|
2149
|
+
tech_score = 0
|
|
2150
|
+
legacy_score = 0
|
|
2151
|
+
|
|
2152
|
+
# Check technology info
|
|
2153
|
+
tech_text = str(tech_info).lower()
|
|
2154
|
+
for keyword in tech_keywords:
|
|
2155
|
+
if keyword in tech_text:
|
|
2156
|
+
tech_score += 1
|
|
2157
|
+
for keyword in legacy_keywords:
|
|
2158
|
+
if keyword in tech_text:
|
|
2159
|
+
legacy_score += 1
|
|
2160
|
+
|
|
2161
|
+
# Check pain points
|
|
2162
|
+
for pain_point in pain_points:
|
|
2163
|
+
description = pain_point.get('description', '').lower()
|
|
2164
|
+
for keyword in tech_keywords:
|
|
2165
|
+
if keyword in description:
|
|
2166
|
+
tech_score += 1
|
|
2167
|
+
for keyword in legacy_keywords:
|
|
2168
|
+
if keyword in description:
|
|
2169
|
+
legacy_score += 1
|
|
2170
|
+
|
|
2171
|
+
if tech_score > legacy_score + 2:
|
|
2172
|
+
return 'advanced'
|
|
2173
|
+
elif tech_score > legacy_score:
|
|
2174
|
+
return 'moderate'
|
|
2175
|
+
elif legacy_score > tech_score:
|
|
2176
|
+
return 'traditional'
|
|
2177
|
+
else:
|
|
2178
|
+
return 'mixed'
|
|
2179
|
+
|
|
2180
|
+
def _identify_growth_indicators(self, customer_data: Dict[str, Any]) -> List[str]:
|
|
2181
|
+
"""Identify growth indicators from customer data."""
|
|
2182
|
+
indicators = []
|
|
2183
|
+
|
|
2184
|
+
company_info = customer_data.get('companyInfo', {})
|
|
2185
|
+
development_plans = customer_data.get('developmentPlans', {})
|
|
2186
|
+
pain_points = customer_data.get('painPoints', [])
|
|
2187
|
+
|
|
2188
|
+
# Check for growth keywords
|
|
2189
|
+
growth_keywords = {
|
|
2190
|
+
'expansion': 'Market expansion plans',
|
|
2191
|
+
'scaling': 'Scaling operations',
|
|
2192
|
+
'hiring': 'Team growth',
|
|
2193
|
+
'funding': 'Recent funding',
|
|
2194
|
+
'new market': 'New market entry',
|
|
2195
|
+
'international': 'International expansion',
|
|
2196
|
+
'acquisition': 'Acquisition activity',
|
|
2197
|
+
'partnership': 'Strategic partnerships'
|
|
2198
|
+
}
|
|
2199
|
+
|
|
2200
|
+
# Check development plans
|
|
2201
|
+
dev_text = str(development_plans).lower()
|
|
2202
|
+
for keyword, indicator in growth_keywords.items():
|
|
2203
|
+
if keyword in dev_text:
|
|
2204
|
+
indicators.append(indicator)
|
|
2205
|
+
|
|
2206
|
+
# Check pain points for growth-related challenges
|
|
2207
|
+
for pain_point in pain_points:
|
|
2208
|
+
description = pain_point.get('description', '').lower()
|
|
2209
|
+
if any(keyword in description for keyword in ['capacity', 'demand', 'volume', 'growth']):
|
|
2210
|
+
indicators.append('Growth-related challenges')
|
|
2211
|
+
break
|
|
2212
|
+
|
|
2213
|
+
return list(set(indicators)) # Remove duplicates
|
|
2214
|
+
|
|
2215
|
+
def _calculate_outreach_readiness(self, customer_data: Dict[str, Any]) -> Dict[str, Any]:
|
|
2216
|
+
"""Calculate readiness score for outreach."""
|
|
2217
|
+
score = 0
|
|
2218
|
+
factors = []
|
|
2219
|
+
|
|
2220
|
+
company_info = customer_data.get('companyInfo', {})
|
|
2221
|
+
contact_info = customer_data.get('primaryContact', {})
|
|
2222
|
+
pain_points = customer_data.get('painPoints', [])
|
|
2223
|
+
|
|
2224
|
+
# Contact information completeness (0-30 points)
|
|
2225
|
+
if contact_info.get('name'):
|
|
2226
|
+
score += 10
|
|
2227
|
+
factors.append('Contact name available')
|
|
2228
|
+
if contact_info.get('email'):
|
|
2229
|
+
score += 15
|
|
2230
|
+
factors.append('Email address available')
|
|
2231
|
+
if contact_info.get('title'):
|
|
2232
|
+
score += 5
|
|
2233
|
+
factors.append('Contact title available')
|
|
2234
|
+
|
|
2235
|
+
# Company information completeness (0-30 points)
|
|
2236
|
+
if company_info.get('name'):
|
|
2237
|
+
score += 10
|
|
2238
|
+
factors.append('Company name available')
|
|
2239
|
+
if company_info.get('industry'):
|
|
2240
|
+
score += 10
|
|
2241
|
+
factors.append('Industry information available')
|
|
2242
|
+
if company_info.get('size') or company_info.get('annualRevenue'):
|
|
2243
|
+
score += 10
|
|
2244
|
+
factors.append('Company size/revenue information available')
|
|
2245
|
+
|
|
2246
|
+
# Pain points quality (0-40 points)
|
|
2247
|
+
high_severity_points = [p for p in pain_points if p.get('severity') == 'high']
|
|
2248
|
+
medium_severity_points = [p for p in pain_points if p.get('severity') == 'medium']
|
|
2249
|
+
|
|
2250
|
+
if high_severity_points:
|
|
2251
|
+
score += 25
|
|
2252
|
+
factors.append(f'{len(high_severity_points)} high-severity pain points identified')
|
|
2253
|
+
if medium_severity_points:
|
|
2254
|
+
score += 15
|
|
2255
|
+
factors.append(f'{len(medium_severity_points)} medium-severity pain points identified')
|
|
2256
|
+
|
|
2257
|
+
# Determine readiness level
|
|
2258
|
+
if score >= 80:
|
|
2259
|
+
readiness_level = 'high'
|
|
2260
|
+
elif score >= 60:
|
|
2261
|
+
readiness_level = 'medium'
|
|
2262
|
+
elif score >= 40:
|
|
2263
|
+
readiness_level = 'low'
|
|
2264
|
+
else:
|
|
2265
|
+
readiness_level = 'insufficient'
|
|
2266
|
+
|
|
2267
|
+
return {
|
|
2268
|
+
'score': score,
|
|
2269
|
+
'level': readiness_level,
|
|
2270
|
+
'factors': factors,
|
|
2271
|
+
'recommendations': self._get_readiness_recommendations(score, factors)
|
|
2272
|
+
}
|
|
2273
|
+
|
|
2274
|
+
def _get_readiness_recommendations(self, score: int, factors: List[str]) -> List[str]:
|
|
2275
|
+
"""Get recommendations based on readiness score."""
|
|
2276
|
+
recommendations = []
|
|
2277
|
+
|
|
2278
|
+
if score < 40:
|
|
2279
|
+
recommendations.append('Gather more customer information before outreach')
|
|
2280
|
+
recommendations.append('Focus on identifying key pain points')
|
|
2281
|
+
elif score < 60:
|
|
2282
|
+
recommendations.append('Consider additional research on company background')
|
|
2283
|
+
recommendations.append('Verify contact information accuracy')
|
|
2284
|
+
elif score < 80:
|
|
2285
|
+
recommendations.append('Outreach ready with minor improvements possible')
|
|
2286
|
+
recommendations.append('Consider personalizing based on specific pain points')
|
|
2287
|
+
else:
|
|
2288
|
+
recommendations.append('Excellent outreach readiness')
|
|
2289
|
+
recommendations.append('Proceed with highly personalized outreach')
|
|
2290
|
+
|
|
2291
|
+
return recommendations
|
|
2292
|
+
|
|
2293
|
+
def validate_input(self, context: Dict[str, Any]) -> bool:
|
|
2294
|
+
"""
|
|
2295
|
+
Validate input data for initial outreach stage (server schema compliant).
|
|
2296
|
+
|
|
2297
|
+
Args:
|
|
2298
|
+
context: Execution context
|
|
2299
|
+
|
|
2300
|
+
Returns:
|
|
2301
|
+
True if input is valid
|
|
2302
|
+
"""
|
|
2303
|
+
input_data = context.get('input_data', {})
|
|
2304
|
+
|
|
2305
|
+
# Check for required server schema fields
|
|
2306
|
+
required_fields = [
|
|
2307
|
+
'org_id', 'org_name', 'customer_name', 'customer_id',
|
|
2308
|
+
'interaction_type', 'action', 'language', 'recipient_address',
|
|
2309
|
+
'recipient_name', 'staff_name', 'team_id', 'team_name'
|
|
2310
|
+
]
|
|
2311
|
+
|
|
2312
|
+
# For draft_write action, we need data from previous stages or structured input
|
|
2313
|
+
action = input_data.get('action', 'draft_write')
|
|
2314
|
+
|
|
2315
|
+
if action == 'draft_write':
|
|
2316
|
+
# Check if we have data from previous stages OR structured input
|
|
2317
|
+
stage_results = context.get('stage_results', {})
|
|
2318
|
+
has_stage_data = 'data_preparation' in stage_results and 'lead_scoring' in stage_results
|
|
2319
|
+
has_structured_input = input_data.get('companyInfo') and input_data.get('pain_points')
|
|
2320
|
+
|
|
2321
|
+
return has_stage_data or has_structured_input
|
|
2322
|
+
|
|
2323
|
+
# For other actions, basic validation
|
|
2324
|
+
return bool(input_data.get('org_id') and input_data.get('action'))
|
|
2325
|
+
|
|
2326
|
+
def get_required_fields(self) -> List[str]:
|
|
2327
|
+
"""
|
|
2328
|
+
Get list of required input fields for this stage.
|
|
2329
|
+
|
|
2330
|
+
Returns:
|
|
2331
|
+
List of required field names
|
|
2332
|
+
"""
|
|
2333
|
+
return [
|
|
2334
|
+
'org_id', 'org_name', 'customer_name', 'customer_id',
|
|
2335
|
+
'interaction_type', 'action', 'language', 'recipient_address',
|
|
2336
|
+
'recipient_name', 'staff_name', 'team_id', 'team_name'
|
|
2337
|
+
]
|