fusesell 1.3.42__py3-none-any.whl

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