fusesell 1.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of fusesell might be problematic. Click here for more details.

@@ -0,0 +1,1590 @@
1
+ """
2
+ Follow-up Stage - Generate contextual follow-up emails based on interaction history
3
+ Converted from fusesell_follow_up.yaml with enhanced LLM-powered generation
4
+ """
5
+
6
+ import json
7
+ import uuid
8
+ import requests
9
+ from typing import Dict, Any, List, Optional
10
+ from datetime import datetime, timedelta
11
+ from .base_stage import BaseStage
12
+
13
+
14
+ class FollowUpStage(BaseStage):
15
+ """
16
+ Follow-up stage for managing ongoing customer engagement with intelligent context analysis.
17
+ Supports multiple follow-up strategies based on interaction history and customer behavior.
18
+ """
19
+
20
+ def execute(self, context: Dict[str, Any]) -> Dict[str, Any]:
21
+ """
22
+ Execute follow-up stage with action-based routing and interaction history analysis.
23
+
24
+ Actions supported:
25
+ - draft_write: Generate new follow-up drafts based on interaction history
26
+ - draft_rewrite: Modify existing draft using selected_draft_id
27
+ - send: Send approved draft to recipient_address with follow-up scheduling
28
+ - close: Close follow-up sequence when customer responds or shows disinterest
29
+
30
+ Args:
31
+ context: Execution context
32
+
33
+ Returns:
34
+ Stage execution result
35
+ """
36
+ try:
37
+ # Get action from input data (matching server schema)
38
+ input_data = context.get('input_data', {})
39
+ action = input_data.get('action', 'draft_write') # Default to draft_write
40
+
41
+ self.logger.info(f"Executing follow-up with action: {action}")
42
+
43
+ # Validate required fields based on action
44
+ self._validate_action_input(action, input_data)
45
+
46
+ # Route based on action type (matching server executor schema)
47
+ if action == 'draft_write':
48
+ return self._handle_draft_write(context)
49
+ elif action == 'draft_rewrite':
50
+ return self._handle_draft_rewrite(context)
51
+ elif action == 'send':
52
+ return self._handle_send(context)
53
+ elif action == 'close':
54
+ return self._handle_close(context)
55
+ else:
56
+ raise ValueError(f"Invalid action: {action}. Must be one of: draft_write, draft_rewrite, send, close")
57
+
58
+ except Exception as e:
59
+ self.log_stage_error(context, e)
60
+ return self.handle_stage_error(e, context)
61
+
62
+ def _validate_action_input(self, action: str, input_data: Dict[str, Any]) -> None:
63
+ """
64
+ Validate required fields based on action type.
65
+
66
+ Args:
67
+ action: Action type
68
+ input_data: Input data
69
+
70
+ Raises:
71
+ ValueError: If required fields are missing
72
+ """
73
+ if action in ['draft_rewrite', 'send']:
74
+ if not input_data.get('selected_draft_id'):
75
+ raise ValueError(f"selected_draft_id is required for {action} action")
76
+
77
+ if action == 'send':
78
+ if not input_data.get('recipient_address'):
79
+ raise ValueError("recipient_address is required for send action")
80
+
81
+ def _handle_draft_write(self, context: Dict[str, Any]) -> Dict[str, Any]:
82
+ """
83
+ Handle draft_write action - Generate new follow-up drafts based on interaction history.
84
+
85
+ Args:
86
+ context: Execution context
87
+
88
+ Returns:
89
+ Stage execution result with new follow-up drafts
90
+ """
91
+ # Analyze interaction history and determine follow-up strategy
92
+ interaction_analysis = self._analyze_interaction_history(context)
93
+
94
+ # Determine if follow-up is needed and what type
95
+ follow_up_strategy = self._determine_follow_up_strategy(interaction_analysis, context)
96
+
97
+ if not follow_up_strategy['should_follow_up']:
98
+ return self.create_success_result({
99
+ 'action': 'draft_write',
100
+ 'status': 'follow_up_not_needed',
101
+ 'reason': follow_up_strategy['reason'],
102
+ 'interaction_analysis': interaction_analysis,
103
+ 'recommendation': 'No follow-up required at this time',
104
+ 'customer_id': context.get('execution_id')
105
+ }, context)
106
+
107
+ # Get data from previous stages or context
108
+ customer_data = self._get_customer_data(context)
109
+ scoring_data = self._get_scoring_data(context)
110
+
111
+ # Get the recommended product (same as initial outreach)
112
+ recommended_product = self._get_recommended_product(scoring_data)
113
+
114
+ if not recommended_product:
115
+ raise ValueError("No product recommendation available for follow-up email generation")
116
+
117
+ # Generate follow-up email drafts based on strategy
118
+ follow_up_drafts = self._generate_follow_up_drafts(
119
+ customer_data, recommended_product, scoring_data,
120
+ interaction_analysis, follow_up_strategy, context
121
+ )
122
+
123
+ # Save drafts to local files and database
124
+ saved_drafts = self._save_follow_up_drafts(context, follow_up_drafts)
125
+
126
+ # Prepare final output
127
+ follow_up_data = {
128
+ 'action': 'draft_write',
129
+ 'status': 'follow_up_drafts_generated',
130
+ 'follow_up_drafts': saved_drafts,
131
+ 'follow_up_strategy': follow_up_strategy,
132
+ 'interaction_analysis': interaction_analysis,
133
+ 'recommended_product': recommended_product,
134
+ 'customer_summary': self._create_customer_summary(customer_data),
135
+ 'total_drafts_generated': len(saved_drafts),
136
+ 'generation_timestamp': datetime.now().isoformat(),
137
+ 'customer_id': context.get('execution_id'),
138
+ 'sequence_number': follow_up_strategy.get('sequence_number', 1)
139
+ }
140
+
141
+ # Save to database
142
+ self.save_stage_result(context, follow_up_data)
143
+
144
+ result = self.create_success_result(follow_up_data, context)
145
+ # Logging handled by execute_with_timing wrapper
146
+
147
+ return result
148
+
149
+ def _analyze_interaction_history(self, context: Dict[str, Any]) -> Dict[str, Any]:
150
+ """
151
+ Analyze interaction history to understand customer engagement and response patterns.
152
+
153
+ Args:
154
+ context: Execution context
155
+
156
+ Returns:
157
+ Interaction analysis results
158
+ """
159
+ try:
160
+ input_data = context.get('input_data', {})
161
+ execution_id = context.get('execution_id')
162
+
163
+ # Get data manager for database operations
164
+ data_manager = self.get_data_manager()
165
+
166
+ # Get previous email drafts and interactions for this customer
167
+ previous_drafts = data_manager.get_email_drafts_by_execution(execution_id)
168
+
169
+ # Analyze interaction patterns
170
+ interaction_analysis = {
171
+ 'total_emails_sent': 0,
172
+ 'total_follow_ups': 0,
173
+ 'last_interaction_date': None,
174
+ 'days_since_last_interaction': 0,
175
+ 'response_received': False,
176
+ 'engagement_level': 'unknown',
177
+ 'interaction_timeline': [],
178
+ 'follow_up_sequence': [],
179
+ 'customer_sentiment': 'neutral',
180
+ 'recommended_approach': 'gentle_reminder'
181
+ }
182
+
183
+ # Count emails and follow-ups
184
+ initial_emails = [d for d in previous_drafts if d.get('draft_type') == 'initial']
185
+ follow_up_emails = [d for d in previous_drafts if d.get('draft_type') in ['follow_up', 'initial_rewrite']]
186
+
187
+ interaction_analysis['total_emails_sent'] = len(initial_emails)
188
+ interaction_analysis['total_follow_ups'] = len(follow_up_emails)
189
+
190
+ # Determine last interaction
191
+ all_emails = sorted(previous_drafts, key=lambda x: x.get('created_at', ''), reverse=True)
192
+ if all_emails:
193
+ last_email = all_emails[0]
194
+ interaction_analysis['last_interaction_date'] = last_email.get('created_at')
195
+
196
+ # Calculate days since last interaction
197
+ try:
198
+ last_date = datetime.fromisoformat(last_email.get('created_at', '').replace('Z', '+00:00'))
199
+ days_diff = (datetime.now() - last_date.replace(tzinfo=None)).days
200
+ interaction_analysis['days_since_last_interaction'] = days_diff
201
+ except:
202
+ interaction_analysis['days_since_last_interaction'] = 0
203
+
204
+ # Analyze engagement level based on interaction patterns
205
+ interaction_analysis['engagement_level'] = self._determine_engagement_level(interaction_analysis)
206
+
207
+ # Determine customer sentiment (simplified - in real implementation, this could analyze responses)
208
+ interaction_analysis['customer_sentiment'] = self._analyze_customer_sentiment(context, interaction_analysis)
209
+
210
+ # Recommend approach based on analysis
211
+ interaction_analysis['recommended_approach'] = self._recommend_follow_up_approach(interaction_analysis)
212
+
213
+ # Create interaction timeline
214
+ interaction_analysis['interaction_timeline'] = self._create_interaction_timeline(previous_drafts)
215
+
216
+ return interaction_analysis
217
+
218
+ except Exception as e:
219
+ self.logger.error(f"Failed to analyze interaction history: {str(e)}")
220
+ # Return default analysis
221
+ return {
222
+ 'total_emails_sent': 0,
223
+ 'total_follow_ups': 0,
224
+ 'days_since_last_interaction': 0,
225
+ 'engagement_level': 'unknown',
226
+ 'customer_sentiment': 'neutral',
227
+ 'recommended_approach': 'gentle_reminder',
228
+ 'analysis_error': str(e)
229
+ }
230
+
231
+ def _determine_follow_up_strategy(self, interaction_analysis: Dict[str, Any], context: Dict[str, Any]) -> Dict[str, Any]:
232
+ """
233
+ Determine the appropriate follow-up strategy based on interaction analysis.
234
+
235
+ Args:
236
+ interaction_analysis: Results from interaction history analysis
237
+ context: Execution context
238
+
239
+ Returns:
240
+ Follow-up strategy recommendations
241
+ """
242
+ try:
243
+ total_follow_ups = interaction_analysis.get('total_follow_ups', 0)
244
+ days_since_last = interaction_analysis.get('days_since_last_interaction', 0)
245
+ engagement_level = interaction_analysis.get('engagement_level', 'unknown')
246
+ customer_sentiment = interaction_analysis.get('customer_sentiment', 'neutral')
247
+
248
+ strategy = {
249
+ 'should_follow_up': True,
250
+ 'sequence_number': total_follow_ups + 1,
251
+ 'strategy_type': 'gentle_reminder',
252
+ 'timing_recommendation': 'immediate',
253
+ 'approach_tone': 'professional',
254
+ 'content_focus': 'value_add',
255
+ 'reason': 'Standard follow-up sequence'
256
+ }
257
+
258
+ # Determine if follow-up should continue
259
+ if total_follow_ups >= 5:
260
+ strategy.update({
261
+ 'should_follow_up': False,
262
+ 'reason': 'Maximum follow-up attempts reached (5)',
263
+ 'recommendation': 'Consider closing this lead or trying a different approach'
264
+ })
265
+ return strategy
266
+
267
+ if customer_sentiment == 'negative':
268
+ strategy.update({
269
+ 'should_follow_up': False,
270
+ 'reason': 'Customer sentiment is negative',
271
+ 'recommendation': 'Respect customer preference and close follow-up sequence'
272
+ })
273
+ return strategy
274
+
275
+ if days_since_last < 3 and total_follow_ups > 0:
276
+ strategy.update({
277
+ 'should_follow_up': False,
278
+ 'reason': 'Too soon since last interaction (less than 3 days)',
279
+ 'recommendation': 'Wait at least 3 days between follow-ups'
280
+ })
281
+ return strategy
282
+
283
+ # Determine strategy type based on sequence number
284
+ if total_follow_ups == 0:
285
+ strategy.update({
286
+ 'strategy_type': 'gentle_reminder',
287
+ 'content_focus': 'gentle_nudge',
288
+ 'approach_tone': 'friendly',
289
+ 'timing_recommendation': 'after_5_days'
290
+ })
291
+ elif total_follow_ups == 1:
292
+ strategy.update({
293
+ 'strategy_type': 'value_add',
294
+ 'content_focus': 'additional_insights',
295
+ 'approach_tone': 'helpful',
296
+ 'timing_recommendation': 'after_1_week'
297
+ })
298
+ elif total_follow_ups == 2:
299
+ strategy.update({
300
+ 'strategy_type': 'alternative_approach',
301
+ 'content_focus': 'different_angle',
302
+ 'approach_tone': 'consultative',
303
+ 'timing_recommendation': 'after_2_weeks'
304
+ })
305
+ elif total_follow_ups == 3:
306
+ strategy.update({
307
+ 'strategy_type': 'final_attempt',
308
+ 'content_focus': 'last_chance',
309
+ 'approach_tone': 'respectful_closure',
310
+ 'timing_recommendation': 'after_1_month'
311
+ })
312
+ else:
313
+ strategy.update({
314
+ 'strategy_type': 'graceful_close',
315
+ 'content_focus': 'relationship_maintenance',
316
+ 'approach_tone': 'professional_farewell',
317
+ 'timing_recommendation': 'final_attempt'
318
+ })
319
+
320
+ # Adjust based on engagement level
321
+ if engagement_level == 'high':
322
+ strategy['approach_tone'] = 'enthusiastic'
323
+ strategy['content_focus'] = 'detailed_proposal'
324
+ elif engagement_level == 'low':
325
+ strategy['approach_tone'] = 'gentle'
326
+ strategy['content_focus'] = 'simple_question'
327
+
328
+ return strategy
329
+
330
+ except Exception as e:
331
+ self.logger.error(f"Failed to determine follow-up strategy: {str(e)}")
332
+ return {
333
+ 'should_follow_up': True,
334
+ 'sequence_number': 1,
335
+ 'strategy_type': 'gentle_reminder',
336
+ 'approach_tone': 'professional',
337
+ 'content_focus': 'value_add',
338
+ 'reason': 'Default strategy due to analysis error'
339
+ }
340
+
341
+ def _generate_follow_up_drafts(self, customer_data: Dict[str, Any], recommended_product: Dict[str, Any],
342
+ scoring_data: Dict[str, Any], interaction_analysis: Dict[str, Any],
343
+ follow_up_strategy: Dict[str, Any], context: Dict[str, Any]) -> List[Dict[str, Any]]:
344
+ """
345
+ Generate multiple follow-up email drafts based on strategy and interaction history.
346
+
347
+ Args:
348
+ customer_data: Customer information
349
+ recommended_product: Product recommendation
350
+ scoring_data: Lead scoring results
351
+ interaction_analysis: Interaction history analysis
352
+ follow_up_strategy: Follow-up strategy
353
+ context: Execution context
354
+
355
+ Returns:
356
+ List of generated follow-up drafts
357
+ """
358
+ if self.is_dry_run():
359
+ return self._get_mock_follow_up_drafts(customer_data, follow_up_strategy, context)
360
+
361
+ try:
362
+ input_data = context.get('input_data', {})
363
+ company_info = customer_data.get('companyInfo', {})
364
+ contact_info = customer_data.get('primaryContact', {})
365
+
366
+ # Define follow-up approaches based on strategy
367
+ follow_up_approaches = self._get_follow_up_approaches(follow_up_strategy)
368
+
369
+ generated_drafts = []
370
+
371
+ for approach in follow_up_approaches:
372
+ try:
373
+ # Generate follow-up content for this approach
374
+ email_content = self._generate_single_follow_up_draft(
375
+ customer_data, recommended_product, scoring_data,
376
+ interaction_analysis, follow_up_strategy, approach, context
377
+ )
378
+
379
+ # Generate subject lines for this approach
380
+ subject_lines = self._generate_follow_up_subject_lines(
381
+ customer_data, recommended_product, interaction_analysis,
382
+ follow_up_strategy, approach, context
383
+ )
384
+
385
+ draft_id = f"uuid:{str(uuid.uuid4())}"
386
+ draft_approach = approach['name']
387
+ draft_type = "followup"
388
+
389
+ draft = {
390
+ 'draft_id': draft_id,
391
+ 'approach': approach['name'],
392
+ 'strategy_type': follow_up_strategy.get('strategy_type', 'gentle_reminder'),
393
+ 'sequence_number': follow_up_strategy.get('sequence_number', 1),
394
+ 'tone': approach['tone'],
395
+ 'focus': approach['focus'],
396
+ 'subject_lines': subject_lines,
397
+ 'email_body': email_content,
398
+ 'call_to_action': self._extract_call_to_action(email_content),
399
+ 'personalization_score': self._calculate_personalization_score(email_content, customer_data),
400
+ 'follow_up_context': {
401
+ 'days_since_last_interaction': interaction_analysis.get('days_since_last_interaction', 0),
402
+ 'total_previous_attempts': interaction_analysis.get('total_follow_ups', 0),
403
+ 'engagement_level': interaction_analysis.get('engagement_level', 'unknown'),
404
+ 'recommended_timing': follow_up_strategy.get('timing_recommendation', 'immediate')
405
+ },
406
+ 'generated_at': datetime.now().isoformat(),
407
+ 'status': 'draft',
408
+ 'metadata': {
409
+ 'customer_company': company_info.get('name', 'Unknown'),
410
+ 'contact_name': contact_info.get('name', 'Unknown'),
411
+ 'recommended_product': recommended_product.get('product_name', 'Unknown'),
412
+ 'follow_up_type': follow_up_strategy.get('strategy_type', 'gentle_reminder'),
413
+ 'generation_method': 'llm_powered_followup'
414
+ }
415
+ }
416
+
417
+ generated_drafts.append(draft)
418
+
419
+ except Exception as e:
420
+ self.logger.warning(f"Failed to generate follow-up draft for approach {approach['name']}: {str(e)}")
421
+ continue
422
+
423
+ if not generated_drafts:
424
+ # Fallback to simple template if all LLM generations fail
425
+ self.logger.warning("All LLM follow-up draft generations failed, using fallback template")
426
+ return self._generate_fallback_follow_up_draft(customer_data, recommended_product, follow_up_strategy, context)
427
+
428
+ self.logger.info(f"Generated {len(generated_drafts)} follow-up drafts successfully")
429
+ return generated_drafts
430
+
431
+ except Exception as e:
432
+ self.logger.error(f"Follow-up draft generation failed: {str(e)}")
433
+ return self._generate_fallback_follow_up_draft(customer_data, recommended_product, follow_up_strategy, context)
434
+
435
+ def _get_follow_up_approaches(self, follow_up_strategy: Dict[str, Any]) -> List[Dict[str, Any]]:
436
+ """
437
+ Get follow-up approaches based on strategy type.
438
+
439
+ Args:
440
+ follow_up_strategy: Follow-up strategy
441
+
442
+ Returns:
443
+ List of follow-up approaches
444
+ """
445
+ strategy_type = follow_up_strategy.get('strategy_type', 'gentle_reminder')
446
+ sequence_number = follow_up_strategy.get('sequence_number', 1)
447
+
448
+ if strategy_type == 'gentle_reminder':
449
+ return [
450
+ {
451
+ 'name': 'friendly_check_in',
452
+ 'tone': 'friendly and casual',
453
+ 'focus': 'gentle reminder with soft touch',
454
+ 'length': 'short'
455
+ },
456
+ {
457
+ 'name': 'professional_follow_up',
458
+ 'tone': 'professional and respectful',
459
+ 'focus': 'polite follow-up on previous conversation',
460
+ 'length': 'medium'
461
+ }
462
+ ]
463
+ elif strategy_type == 'value_add':
464
+ return [
465
+ {
466
+ 'name': 'insights_sharing',
467
+ 'tone': 'helpful and informative',
468
+ 'focus': 'sharing valuable insights or resources',
469
+ 'length': 'medium'
470
+ },
471
+ {
472
+ 'name': 'industry_trends',
473
+ 'tone': 'expert and consultative',
474
+ 'focus': 'relevant industry trends and opportunities',
475
+ 'length': 'detailed'
476
+ }
477
+ ]
478
+ elif strategy_type == 'alternative_approach':
479
+ return [
480
+ {
481
+ 'name': 'different_angle',
482
+ 'tone': 'creative and engaging',
483
+ 'focus': 'new perspective or different value proposition',
484
+ 'length': 'medium'
485
+ },
486
+ {
487
+ 'name': 'case_study_approach',
488
+ 'tone': 'evidence-based and compelling',
489
+ 'focus': 'success stories and social proof',
490
+ 'length': 'detailed'
491
+ }
492
+ ]
493
+ elif strategy_type == 'final_attempt':
494
+ return [
495
+ {
496
+ 'name': 'respectful_final_reach',
497
+ 'tone': 'respectful and understanding',
498
+ 'focus': 'final attempt with graceful exit option',
499
+ 'length': 'short'
500
+ }
501
+ ]
502
+ else: # graceful_close
503
+ return [
504
+ {
505
+ 'name': 'graceful_farewell',
506
+ 'tone': 'professional and gracious',
507
+ 'focus': 'maintaining relationship for future opportunities',
508
+ 'length': 'short'
509
+ }
510
+ ]
511
+
512
+ def _generate_single_follow_up_draft(self, customer_data: Dict[str, Any], recommended_product: Dict[str, Any],
513
+ scoring_data: Dict[str, Any], interaction_analysis: Dict[str, Any],
514
+ follow_up_strategy: Dict[str, Any], approach: Dict[str, Any],
515
+ context: Dict[str, Any]) -> str:
516
+ """
517
+ Generate a single follow-up email draft using LLM with specific approach.
518
+
519
+ Args:
520
+ customer_data: Customer information
521
+ recommended_product: Product recommendation
522
+ scoring_data: Lead scoring results
523
+ interaction_analysis: Interaction history analysis
524
+ follow_up_strategy: Follow-up strategy
525
+ approach: Specific approach for this draft
526
+ context: Execution context
527
+
528
+ Returns:
529
+ Generated follow-up email content
530
+ """
531
+ try:
532
+ input_data = context.get('input_data', {})
533
+ company_info = customer_data.get('companyInfo', {})
534
+ contact_info = customer_data.get('primaryContact', {})
535
+ pain_points = customer_data.get('painPoints', [])
536
+
537
+ # Prepare context for LLM
538
+ follow_up_context = {
539
+ 'company_name': company_info.get('name', 'the company'),
540
+ 'contact_name': contact_info.get('name', 'there'),
541
+ 'contact_title': contact_info.get('title', ''),
542
+ 'industry': company_info.get('industry', 'technology'),
543
+ 'main_pain_points': [p.get('description', '') for p in pain_points[:3]],
544
+ 'recommended_product': recommended_product.get('product_name', 'our solution'),
545
+ 'sender_name': input_data.get('staff_name', 'Sales Team'),
546
+ 'sender_company': input_data.get('org_name', 'Our Company'),
547
+ 'sequence_number': follow_up_strategy.get('sequence_number', 1),
548
+ 'days_since_last': interaction_analysis.get('days_since_last_interaction', 0),
549
+ 'total_attempts': interaction_analysis.get('total_follow_ups', 0),
550
+ 'strategy_type': follow_up_strategy.get('strategy_type', 'gentle_reminder'),
551
+ 'approach_tone': approach.get('tone', 'professional'),
552
+ 'approach_focus': approach.get('focus', 'follow-up'),
553
+ 'approach_length': approach.get('length', 'medium')
554
+ }
555
+
556
+ # Create LLM prompt for follow-up generation
557
+ prompt = self._create_follow_up_generation_prompt(follow_up_context, approach)
558
+
559
+ # Generate follow-up using LLM
560
+ email_content = self.call_llm(
561
+ prompt=prompt,
562
+ temperature=0.7,
563
+ max_tokens=800
564
+ )
565
+
566
+ # Clean and validate the generated content
567
+ cleaned_content = self._clean_email_content(email_content)
568
+
569
+ return cleaned_content
570
+
571
+ except Exception as e:
572
+ self.logger.error(f"Failed to generate single follow-up draft: {str(e)}")
573
+ return self._generate_template_follow_up_email(customer_data, recommended_product, follow_up_strategy, approach, context)
574
+
575
+ def _create_follow_up_generation_prompt(self, follow_up_context: Dict[str, Any], approach: Dict[str, Any]) -> str:
576
+ """
577
+ Create LLM prompt for follow-up email generation.
578
+
579
+ Args:
580
+ follow_up_context: Context for follow-up generation
581
+ approach: Specific approach details
582
+
583
+ Returns:
584
+ LLM prompt for follow-up generation
585
+ """
586
+
587
+ pain_points_text = ""
588
+ if follow_up_context['main_pain_points']:
589
+ pain_points_text = f"Their key challenges: {', '.join(follow_up_context['main_pain_points'])}"
590
+
591
+ # Create context about previous interactions
592
+ interaction_context = f"""
593
+ FOLLOW-UP CONTEXT:
594
+ - This is follow-up #{follow_up_context['sequence_number']}
595
+ - {follow_up_context['days_since_last']} days since last interaction
596
+ - Total previous attempts: {follow_up_context['total_attempts']}
597
+ - Follow-up strategy: {follow_up_context['strategy_type']}"""
598
+
599
+ prompt = f"""Generate a personalized follow-up email with the following specifications:
600
+
601
+ CUSTOMER INFORMATION:
602
+ - Company: {follow_up_context['company_name']}
603
+ - Contact: {follow_up_context['contact_name']} ({follow_up_context['contact_title']})
604
+ - Industry: {follow_up_context['industry']}
605
+ {pain_points_text}
606
+
607
+ OUR OFFERING:
608
+ - Product/Solution: {follow_up_context['recommended_product']}
609
+
610
+ SENDER INFORMATION:
611
+ - Sender: {follow_up_context['sender_name']}
612
+ - Company: {follow_up_context['sender_company']}
613
+
614
+ {interaction_context}
615
+
616
+ EMAIL APPROACH:
617
+ - Tone: {follow_up_context['approach_tone']}
618
+ - Focus: {follow_up_context['approach_focus']}
619
+ - Length: {follow_up_context['approach_length']}
620
+
621
+ FOLLOW-UP REQUIREMENTS:
622
+ 1. Reference that this is a follow-up (don't repeat everything from initial email)
623
+ 2. Acknowledge the time that has passed since last contact
624
+ 3. Provide new value or perspective (don't just repeat the same message)
625
+ 4. Be respectful of their time and attention
626
+ 5. Include a clear, low-pressure call-to-action
627
+ 6. Match the {follow_up_context['approach_tone']} tone
628
+ 7. Focus on {follow_up_context['approach_focus']}
629
+ 8. Keep it {follow_up_context['approach_length']} in length
630
+
631
+ STRATEGY-SPECIFIC GUIDELINES:
632
+ - If gentle_reminder: Be soft, friendly, and non-pushy
633
+ - If value_add: Provide genuine insights, resources, or industry information
634
+ - If alternative_approach: Try a different angle or value proposition
635
+ - If final_attempt: Be respectful and offer a graceful exit
636
+ - If graceful_close: Focus on maintaining the relationship for future
637
+
638
+ Generate only the email content, no additional commentary:"""
639
+
640
+ return prompt
641
+
642
+ # Import utility methods from initial outreach (they're the same)
643
+ def _extract_call_to_action(self, email_content: str) -> str:
644
+ """Extract the main call-to-action from email content."""
645
+ # Look for common CTA patterns
646
+ cta_patterns = [
647
+ r"Would you be (?:interested in|available for|open to) ([^?]+\?)",
648
+ r"Can we schedule ([^?]+\?)",
649
+ r"I'd love to ([^.]+\.)",
650
+ r"Let's ([^.]+\.)",
651
+ r"Would you like to ([^?]+\?)"
652
+ ]
653
+
654
+ import re
655
+ for pattern in cta_patterns:
656
+ match = re.search(pattern, email_content, re.IGNORECASE)
657
+ if match:
658
+ return match.group(0)
659
+
660
+ # Fallback: look for question marks
661
+ sentences = email_content.split('.')
662
+ for sentence in sentences:
663
+ if '?' in sentence:
664
+ return sentence.strip() + ('.' if not sentence.strip().endswith('?') else '')
665
+
666
+ return "Would you be interested in learning more?"
667
+
668
+ def _calculate_personalization_score(self, email_content: str, customer_data: Dict[str, Any]) -> int:
669
+ """Calculate personalization score based on customer data usage."""
670
+ score = 0
671
+ company_info = customer_data.get('companyInfo', {})
672
+ contact_info = customer_data.get('primaryContact', {})
673
+
674
+ # Check for company name usage
675
+ if company_info.get('name', '').lower() in email_content.lower():
676
+ score += 25
677
+
678
+ # Check for contact name usage
679
+ if contact_info.get('name', '').lower() in email_content.lower():
680
+ score += 25
681
+
682
+ # Check for industry mention
683
+ if company_info.get('industry', '').lower() in email_content.lower():
684
+ score += 20
685
+
686
+ # Check for pain points reference
687
+ pain_points = customer_data.get('painPoints', [])
688
+ for pain_point in pain_points:
689
+ if any(keyword.lower() in email_content.lower() for keyword in pain_point.get('description', '').split()[:3]):
690
+ score += 15
691
+ break
692
+
693
+ # Check for specific details (company size, location, etc.)
694
+ if any(detail in email_content.lower() for detail in [
695
+ company_info.get('size', '').lower(),
696
+ company_info.get('location', '').lower()
697
+ ] if detail):
698
+ score += 15
699
+
700
+ return min(score, 100)
701
+
702
+ def _clean_email_content(self, raw_content: str) -> str:
703
+ """Clean and validate generated email content."""
704
+ # Remove any unwanted prefixes or suffixes
705
+ content = raw_content.strip()
706
+
707
+ # Remove common LLM artifacts
708
+ artifacts_to_remove = [
709
+ "Here's the follow-up email:",
710
+ "Here is the follow-up email:",
711
+ "Follow-up email content:",
712
+ "Generated follow-up email:",
713
+ "Subject:",
714
+ "Email:"
715
+ ]
716
+
717
+ for artifact in artifacts_to_remove:
718
+ if content.startswith(artifact):
719
+ content = content[len(artifact):].strip()
720
+
721
+ # Ensure proper email structure
722
+ if not content.startswith(('Dear', 'Hi', 'Hello', 'Greetings')):
723
+ # Add a greeting if missing
724
+ content = f"Hi there,\n\n{content}"
725
+
726
+ # Ensure proper closing
727
+ if not any(closing in content.lower() for closing in ['best regards', 'sincerely', 'best', 'thanks']):
728
+ content += "\n\nBest regards"
729
+
730
+ return content
731
+
732
+ def _handle_draft_rewrite(self, context: Dict[str, Any]) -> Dict[str, Any]:
733
+ """
734
+ Handle draft_rewrite action - Modify existing follow-up draft.
735
+
736
+ Args:
737
+ context: Execution context
738
+
739
+ Returns:
740
+ Stage execution result with rewritten draft
741
+ """
742
+ input_data = context.get('input_data', {})
743
+ selected_draft_id = input_data.get('selected_draft_id')
744
+ reason = input_data.get('reason', 'No reason provided')
745
+
746
+ # Retrieve existing draft
747
+ existing_draft = self._get_draft_by_id(selected_draft_id)
748
+ if not existing_draft:
749
+ raise ValueError(f"Follow-up draft not found: {selected_draft_id}")
750
+
751
+ # Get customer data for context
752
+ customer_data = self._get_customer_data(context)
753
+ scoring_data = self._get_scoring_data(context)
754
+ interaction_analysis = self._analyze_interaction_history(context)
755
+
756
+ # Rewrite the draft based on reason
757
+ rewritten_draft = self._rewrite_follow_up_draft(existing_draft, reason, customer_data, interaction_analysis, context)
758
+
759
+ # Save the rewritten draft
760
+ saved_draft = self._save_rewritten_follow_up_draft(context, rewritten_draft, selected_draft_id)
761
+
762
+ # Prepare output
763
+ follow_up_data = {
764
+ 'action': 'draft_rewrite',
765
+ 'status': 'follow_up_draft_rewritten',
766
+ 'original_draft_id': selected_draft_id,
767
+ 'rewritten_draft': saved_draft,
768
+ 'rewrite_reason': reason,
769
+ 'generation_timestamp': datetime.now().isoformat(),
770
+ 'customer_id': context.get('execution_id')
771
+ }
772
+
773
+ # Save to database
774
+ self.save_stage_result(context, follow_up_data)
775
+
776
+ result = self.create_success_result(follow_up_data, context)
777
+ # Logging handled by execute_with_timing wrapper
778
+
779
+ return result
780
+
781
+ def _handle_send(self, context: Dict[str, Any]) -> Dict[str, Any]:
782
+ """
783
+ Handle send action - Send approved follow-up draft to recipient.
784
+
785
+ Args:
786
+ context: Execution context
787
+
788
+ Returns:
789
+ Stage execution result with send status
790
+ """
791
+ input_data = context.get('input_data', {})
792
+ selected_draft_id = input_data.get('selected_draft_id')
793
+ recipient_address = input_data.get('recipient_address')
794
+ recipient_name = input_data.get('recipient_name', 'Dear Customer')
795
+ send_immediately = input_data.get('send_immediately', False)
796
+
797
+ # Retrieve the draft to send
798
+ draft_to_send = self._get_draft_by_id(selected_draft_id)
799
+ if not draft_to_send:
800
+ raise ValueError(f"Follow-up draft not found: {selected_draft_id}")
801
+
802
+ # Check if we should send immediately or schedule
803
+ if send_immediately:
804
+ # Send immediately
805
+ send_result = self._send_follow_up_email(draft_to_send, recipient_address, recipient_name, context)
806
+ else:
807
+ # Schedule for optimal timing
808
+ send_result = self._schedule_follow_up_email(draft_to_send, recipient_address, recipient_name, context)
809
+
810
+ # Prepare output
811
+ follow_up_data = {
812
+ 'action': 'send',
813
+ 'status': 'follow_up_sent' if send_immediately else 'follow_up_scheduled',
814
+ 'draft_id': selected_draft_id,
815
+ 'recipient_address': recipient_address,
816
+ 'recipient_name': recipient_name,
817
+ 'send_result': send_result,
818
+ 'sent_immediately': send_immediately,
819
+ 'send_timestamp': datetime.now().isoformat(),
820
+ 'customer_id': context.get('execution_id')
821
+ }
822
+
823
+ # Save to database
824
+ self.save_stage_result(context, follow_up_data)
825
+
826
+ result = self.create_success_result(follow_up_data, context)
827
+ # Logging handled by execute_with_timing wrapper
828
+
829
+ return result
830
+
831
+ def _handle_close(self, context: Dict[str, Any]) -> Dict[str, Any]:
832
+ """
833
+ Handle close action - Close follow-up sequence.
834
+
835
+ Args:
836
+ context: Execution context
837
+
838
+ Returns:
839
+ Stage execution result with close status
840
+ """
841
+ input_data = context.get('input_data', {})
842
+ reason = input_data.get('reason', 'Follow-up sequence closed')
843
+
844
+ # Prepare output
845
+ follow_up_data = {
846
+ 'action': 'close',
847
+ 'status': 'follow_up_closed',
848
+ 'close_reason': reason,
849
+ 'closed_timestamp': datetime.now().isoformat(),
850
+ 'customer_id': context.get('execution_id')
851
+ }
852
+
853
+ # Save to database
854
+ self.save_stage_result(context, follow_up_data)
855
+
856
+ result = self.create_success_result(follow_up_data, context)
857
+ # Logging handled by execute_with_timing wrapper
858
+
859
+ return result
860
+
861
+ def _schedule_follow_up_email(self, draft: Dict[str, Any], recipient_address: str, recipient_name: str, context: Dict[str, Any]) -> Dict[str, Any]:
862
+ """
863
+ Schedule follow-up email event in database for external app to handle.
864
+
865
+ Args:
866
+ draft: Email draft to send
867
+ recipient_address: Email address of recipient
868
+ recipient_name: Name of recipient
869
+ context: Execution context
870
+
871
+ Returns:
872
+ Scheduling result
873
+ """
874
+ try:
875
+ from ..utils.event_scheduler import EventScheduler
876
+
877
+ input_data = context.get('input_data', {})
878
+
879
+ # Initialize event scheduler
880
+ scheduler = EventScheduler(self.config.get('data_dir', './fusesell_data'))
881
+
882
+ # Check if immediate sending is requested
883
+ send_immediately = input_data.get('send_immediately', False)
884
+
885
+ # Schedule the follow-up email event
886
+ schedule_result = scheduler.schedule_email_event(
887
+ draft_id=draft.get('draft_id'),
888
+ recipient_address=recipient_address,
889
+ recipient_name=recipient_name,
890
+ org_id=input_data.get('org_id', 'default'),
891
+ team_id=input_data.get('team_id'),
892
+ customer_timezone=input_data.get('customer_timezone'),
893
+ email_type='follow_up',
894
+ send_immediately=send_immediately
895
+ )
896
+
897
+ if schedule_result['success']:
898
+ self.logger.info(f"Follow-up email event scheduled successfully: {schedule_result['event_id']} for {schedule_result['scheduled_time']}")
899
+ return {
900
+ 'success': True,
901
+ 'message': f'Follow-up email event scheduled for {schedule_result["scheduled_time"]}',
902
+ 'event_id': schedule_result['event_id'],
903
+ 'scheduled_time': schedule_result['scheduled_time'],
904
+ 'service': 'Database Event Scheduler'
905
+ }
906
+ else:
907
+ self.logger.error(f"Follow-up email event scheduling failed: {schedule_result.get('error', 'Unknown error')}")
908
+ return {
909
+ 'success': False,
910
+ 'message': f'Follow-up email event scheduling failed: {schedule_result.get("error", "Unknown error")}',
911
+ 'service': 'Database Event Scheduler'
912
+ }
913
+
914
+ except Exception as e:
915
+ self.logger.error(f"Follow-up email scheduling failed: {str(e)}")
916
+ return {
917
+ 'success': False,
918
+ 'message': f'Follow-up email scheduling failed: {str(e)}',
919
+ 'error': str(e)
920
+ }
921
+ # Data access methods (similar to initial outreach)
922
+ def _get_customer_data(self, context: Dict[str, Any]) -> Dict[str, Any]:
923
+ """Get customer data from previous stages or input."""
924
+ stage_results = context.get('stage_results', {})
925
+
926
+ # Try to get from data preparation stage first
927
+ if 'data_preparation' in stage_results:
928
+ return stage_results['data_preparation']
929
+
930
+ # Fallback to input data
931
+ input_data = context.get('input_data', {})
932
+ return {
933
+ 'companyInfo': input_data.get('companyInfo', {}),
934
+ 'primaryContact': input_data.get('primaryContact', {}),
935
+ 'painPoints': input_data.get('painPoints', [])
936
+ }
937
+
938
+ def _get_scoring_data(self, context: Dict[str, Any]) -> Dict[str, Any]:
939
+ """Get scoring data from previous stages."""
940
+ stage_results = context.get('stage_results', {})
941
+ return stage_results.get('lead_scoring', {})
942
+
943
+ def _get_recommended_product(self, scoring_data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
944
+ """Get the recommended product from scoring data."""
945
+ product_scores = scoring_data.get('product_scores', [])
946
+ if product_scores:
947
+ # Return the highest scoring product
948
+ best_product = max(product_scores, key=lambda x: x.get('overall_score', 0))
949
+ return best_product
950
+ return None
951
+
952
+ def _get_auto_interaction_config(self, team_id: str = None) -> Dict[str, Any]:
953
+ """
954
+ Get auto interaction configuration from team settings.
955
+
956
+ Args:
957
+ team_id: Team ID to get settings for
958
+
959
+ Returns:
960
+ Auto interaction configuration dictionary with from_email, from_name, etc.
961
+ If multiple configs exist, returns the first Email type config.
962
+ """
963
+ default_config = {
964
+ 'from_email': '',
965
+ 'from_name': '',
966
+ 'from_number': '',
967
+ 'tool_type': 'Email',
968
+ 'email_cc': '',
969
+ 'email_bcc': ''
970
+ }
971
+
972
+ if not team_id:
973
+ return default_config
974
+
975
+ try:
976
+ # Get team settings
977
+ auto_interaction_settings = self.get_team_setting('gs_team_auto_interaction', team_id, [])
978
+
979
+ if not auto_interaction_settings or not isinstance(auto_interaction_settings, list):
980
+ self.logger.debug(f"No auto interaction settings found for team {team_id}, using defaults")
981
+ return default_config
982
+
983
+ # Find Email type configuration (preferred for email sending)
984
+ email_config = None
985
+ for config in auto_interaction_settings:
986
+ if config.get('tool_type') == 'Email':
987
+ email_config = config
988
+ break
989
+
990
+ # If no Email config found, use the first one available
991
+ if not email_config and len(auto_interaction_settings) > 0:
992
+ email_config = auto_interaction_settings[0]
993
+ self.logger.warning(f"No Email tool_type found in auto interaction settings, using first config with tool_type: {email_config.get('tool_type')}")
994
+
995
+ if email_config:
996
+ 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')}")
997
+ return email_config
998
+ else:
999
+ return default_config
1000
+
1001
+ except Exception as e:
1002
+ self.logger.error(f"Failed to get auto interaction config for team {team_id}: {str(e)}")
1003
+ return default_config
1004
+
1005
+ def _get_draft_by_id(self, draft_id: str) -> Optional[Dict[str, Any]]:
1006
+ """Retrieve follow-up draft by ID from database."""
1007
+ if self.is_dry_run():
1008
+ return {
1009
+ 'draft_id': draft_id,
1010
+ 'subject_lines': ['Mock Follow-up Subject Line'],
1011
+ 'email_body': 'Mock follow-up email content for testing purposes.',
1012
+ 'approach': 'mock',
1013
+ 'tone': 'professional',
1014
+ 'status': 'mock',
1015
+ 'call_to_action': 'Mock follow-up call to action',
1016
+ 'personalization_score': 75,
1017
+ 'sequence_number': 1
1018
+ }
1019
+
1020
+ try:
1021
+ # Get data manager for database operations
1022
+ data_manager = self.get_data_manager()
1023
+
1024
+ # Query database for draft
1025
+ draft_record = data_manager.get_email_draft_by_id(draft_id)
1026
+
1027
+ if not draft_record:
1028
+ self.logger.warning(f"Follow-up draft not found in database: {draft_id}")
1029
+ return None
1030
+
1031
+ # Parse metadata
1032
+ metadata = {}
1033
+ if draft_record.get('metadata'):
1034
+ try:
1035
+ metadata = json.loads(draft_record['metadata'])
1036
+ except json.JSONDecodeError:
1037
+ self.logger.warning(f"Failed to parse metadata for follow-up draft {draft_id}")
1038
+
1039
+ # Reconstruct draft object
1040
+ draft = {
1041
+ 'draft_id': draft_record['draft_id'],
1042
+ 'execution_id': draft_record['execution_id'],
1043
+ 'customer_id': draft_record['customer_id'],
1044
+ 'subject_lines': metadata.get('all_subject_lines', [draft_record['subject']]),
1045
+ 'email_body': draft_record['content'],
1046
+ 'approach': metadata.get('approach', 'unknown'),
1047
+ 'tone': metadata.get('tone', 'professional'),
1048
+ 'focus': metadata.get('focus', 'general'),
1049
+ 'call_to_action': metadata.get('call_to_action', ''),
1050
+ 'personalization_score': metadata.get('personalization_score', 0),
1051
+ 'status': draft_record['status'],
1052
+ 'version': draft_record['version'],
1053
+ 'draft_type': draft_record['draft_type'],
1054
+ 'sequence_number': metadata.get('sequence_number', 1),
1055
+ 'created_at': draft_record['created_at'],
1056
+ 'updated_at': draft_record['updated_at'],
1057
+ 'metadata': metadata
1058
+ }
1059
+
1060
+ self.logger.info(f"Retrieved follow-up draft {draft_id} from database")
1061
+ return draft
1062
+
1063
+ except Exception as e:
1064
+ self.logger.error(f"Failed to retrieve follow-up draft {draft_id}: {str(e)}")
1065
+ return None
1066
+
1067
+ # Helper methods for analysis
1068
+ def _determine_engagement_level(self, interaction_analysis: Dict[str, Any]) -> str:
1069
+ """Determine customer engagement level based on interaction patterns."""
1070
+ total_emails = interaction_analysis.get('total_emails_sent', 0)
1071
+ total_follow_ups = interaction_analysis.get('total_follow_ups', 0)
1072
+ days_since_last = interaction_analysis.get('days_since_last_interaction', 0)
1073
+
1074
+ # Simple engagement scoring
1075
+ if total_emails == 0:
1076
+ return 'unknown'
1077
+ elif days_since_last <= 3 and total_follow_ups < 2:
1078
+ return 'high'
1079
+ elif days_since_last <= 7 and total_follow_ups < 3:
1080
+ return 'medium'
1081
+ else:
1082
+ return 'low'
1083
+
1084
+ def _analyze_customer_sentiment(self, context: Dict[str, Any], interaction_analysis: Dict[str, Any]) -> str:
1085
+ """Analyze customer sentiment (simplified - could be enhanced with response analysis)."""
1086
+ # In a real implementation, this would analyze actual customer responses
1087
+ # For now, we'll use simple heuristics
1088
+ total_follow_ups = interaction_analysis.get('total_follow_ups', 0)
1089
+
1090
+ if total_follow_ups >= 4:
1091
+ return 'negative' # Too many follow-ups without response
1092
+ elif total_follow_ups >= 2:
1093
+ return 'neutral' # Some follow-ups, neutral response
1094
+ else:
1095
+ return 'positive' # Early in sequence, assume positive
1096
+
1097
+ def _recommend_follow_up_approach(self, interaction_analysis: Dict[str, Any]) -> str:
1098
+ """Recommend follow-up approach based on interaction analysis."""
1099
+ engagement_level = interaction_analysis.get('engagement_level', 'unknown')
1100
+ total_follow_ups = interaction_analysis.get('total_follow_ups', 0)
1101
+
1102
+ if total_follow_ups == 0:
1103
+ return 'gentle_reminder'
1104
+ elif total_follow_ups == 1:
1105
+ return 'value_add'
1106
+ elif total_follow_ups == 2:
1107
+ return 'alternative_approach'
1108
+ elif total_follow_ups >= 3:
1109
+ return 'final_attempt'
1110
+ else:
1111
+ return 'gentle_reminder'
1112
+
1113
+ def _create_interaction_timeline(self, previous_drafts: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
1114
+ """Create interaction timeline from previous drafts."""
1115
+ timeline = []
1116
+
1117
+ for draft in sorted(previous_drafts, key=lambda x: x.get('created_at', '')):
1118
+ timeline.append({
1119
+ 'date': draft.get('created_at'),
1120
+ 'type': draft.get('draft_type', 'unknown'),
1121
+ 'draft_id': draft.get('draft_id'),
1122
+ 'status': draft.get('status', 'unknown')
1123
+ })
1124
+
1125
+ return timeline
1126
+
1127
+ def _create_customer_summary(self, customer_data: Dict[str, Any]) -> Dict[str, Any]:
1128
+ """Create customer summary for follow-up context."""
1129
+ company_info = customer_data.get('companyInfo', {})
1130
+ contact_info = customer_data.get('primaryContact', {})
1131
+
1132
+ return {
1133
+ 'company_name': company_info.get('name', 'Unknown'),
1134
+ 'industry': company_info.get('industry', 'Unknown'),
1135
+ 'contact_name': contact_info.get('name', 'Unknown'),
1136
+ 'contact_email': contact_info.get('email', 'Unknown'),
1137
+ 'follow_up_context': 'Generated for follow-up sequence'
1138
+ }
1139
+
1140
+ # Mock and fallback methods
1141
+ def _get_mock_follow_up_drafts(self, customer_data: Dict[str, Any], follow_up_strategy: Dict[str, Any], context: Dict[str, Any]) -> List[Dict[str, Any]]:
1142
+ """Get mock follow-up drafts for dry run."""
1143
+ company_info = customer_data.get('companyInfo', {})
1144
+
1145
+ return [{
1146
+ 'draft_id': 'mock_followup_001',
1147
+ 'approach': 'gentle_reminder',
1148
+ 'strategy_type': follow_up_strategy.get('strategy_type', 'gentle_reminder'),
1149
+ 'sequence_number': follow_up_strategy.get('sequence_number', 1),
1150
+ 'tone': 'friendly and professional',
1151
+ 'focus': 'gentle follow-up',
1152
+ 'subject_lines': [
1153
+ f"Following up on our conversation - {company_info.get('name', 'Test Company')}",
1154
+ f"Quick check-in with {company_info.get('name', 'Test Company')}",
1155
+ f"Thoughts on our discussion?"
1156
+ ],
1157
+ 'email_body': f"""[DRY RUN] Mock follow-up email content for {company_info.get('name', 'Test Company')}
1158
+
1159
+ This is a mock follow-up email that would be generated for testing purposes. In a real execution, this would contain contextual follow-up content based on the interaction history and follow-up strategy.""",
1160
+ 'call_to_action': 'Mock follow-up call to action',
1161
+ 'personalization_score': 80,
1162
+ 'generated_at': datetime.now().isoformat(),
1163
+ 'status': 'mock',
1164
+ 'metadata': {
1165
+ 'generation_method': 'mock_data',
1166
+ 'note': 'This is mock data for dry run testing'
1167
+ }
1168
+ }]
1169
+
1170
+ def validate_input(self, context: Dict[str, Any]) -> bool:
1171
+ """
1172
+ Validate input data for follow-up stage.
1173
+
1174
+ Args:
1175
+ context: Execution context
1176
+
1177
+ Returns:
1178
+ True if input is valid
1179
+ """
1180
+ # Follow-up stage can work with minimal input since it analyzes history
1181
+ return True
1182
+
1183
+ def _save_follow_up_drafts(self, context: Dict[str, Any], follow_up_drafts: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
1184
+ """Save follow-up drafts to database and files."""
1185
+ try:
1186
+ execution_id = context.get('execution_id')
1187
+ saved_drafts = []
1188
+
1189
+ # Get data manager for database operations
1190
+ data_manager = self.get_data_manager()
1191
+
1192
+ for draft in follow_up_drafts:
1193
+ try:
1194
+ # Prepare draft data for database
1195
+ draft_data = {
1196
+ 'draft_id': draft['draft_id'],
1197
+ 'execution_id': execution_id,
1198
+ 'customer_id': execution_id, # Using execution_id as customer_id for now
1199
+ 'subject': draft['subject_lines'][0] if draft['subject_lines'] else 'Follow-up',
1200
+ 'content': draft['email_body'],
1201
+ 'draft_type': 'follow_up',
1202
+ 'version': 1,
1203
+ 'status': 'draft',
1204
+ 'metadata': json.dumps({
1205
+ 'approach': draft.get('approach', 'unknown'),
1206
+ 'strategy_type': draft.get('strategy_type', 'gentle_reminder'),
1207
+ 'sequence_number': draft.get('sequence_number', 1),
1208
+ 'tone': draft.get('tone', 'professional'),
1209
+ 'focus': draft.get('focus', 'general'),
1210
+ 'all_subject_lines': draft.get('subject_lines', []),
1211
+ 'call_to_action': draft.get('call_to_action', ''),
1212
+ 'personalization_score': draft.get('personalization_score', 0),
1213
+ 'follow_up_context': draft.get('follow_up_context', {}),
1214
+ 'generation_method': 'llm_powered_followup',
1215
+ 'generated_at': draft.get('generated_at', datetime.now().isoformat())
1216
+ })
1217
+ }
1218
+
1219
+ # Save to database
1220
+ if not self.is_dry_run():
1221
+ data_manager.save_email_draft(draft_data)
1222
+ self.logger.info(f"Saved follow-up draft {draft['draft_id']} to database")
1223
+
1224
+ # Save to file system for backup
1225
+ draft_file_path = self._save_draft_to_file(execution_id, draft)
1226
+
1227
+ # Add context to draft
1228
+ draft_with_context = draft.copy()
1229
+ draft_with_context['execution_id'] = execution_id
1230
+ draft_with_context['file_path'] = draft_file_path
1231
+ draft_with_context['database_saved'] = not self.is_dry_run()
1232
+ draft_with_context['saved_at'] = datetime.now().isoformat()
1233
+
1234
+ saved_drafts.append(draft_with_context)
1235
+
1236
+ except Exception as e:
1237
+ self.logger.error(f"Failed to save individual follow-up draft {draft.get('draft_id', 'unknown')}: {str(e)}")
1238
+ # Still add to saved_drafts but mark as failed
1239
+ draft_with_context = draft.copy()
1240
+ draft_with_context['execution_id'] = execution_id
1241
+ draft_with_context['save_error'] = str(e)
1242
+ draft_with_context['database_saved'] = False
1243
+ saved_drafts.append(draft_with_context)
1244
+
1245
+ self.logger.info(f"Successfully saved {len([d for d in saved_drafts if d.get('database_saved', False)])} follow-up drafts to database")
1246
+ return saved_drafts
1247
+
1248
+ except Exception as e:
1249
+ self.logger.error(f"Failed to save follow-up drafts: {str(e)}")
1250
+ # Return drafts with error information
1251
+ for draft in follow_up_drafts:
1252
+ draft['save_error'] = str(e)
1253
+ draft['database_saved'] = False
1254
+ return follow_up_drafts
1255
+
1256
+ def _save_draft_to_file(self, execution_id: str, draft: Dict[str, Any]) -> str:
1257
+ """Save follow-up draft to file system as backup."""
1258
+ try:
1259
+ import os
1260
+
1261
+ # Create drafts directory if it doesn't exist
1262
+ drafts_dir = os.path.join(self.config.get('data_dir', './fusesell_data'), 'drafts')
1263
+ os.makedirs(drafts_dir, exist_ok=True)
1264
+
1265
+ # Create file path
1266
+ file_name = f"{execution_id}_{draft['draft_id']}_followup.json"
1267
+ file_path = os.path.join(drafts_dir, file_name)
1268
+
1269
+ # Save draft to file
1270
+ with open(file_path, 'w', encoding='utf-8') as f:
1271
+ json.dump(draft, f, indent=2, ensure_ascii=False)
1272
+
1273
+ return f"drafts/{file_name}"
1274
+
1275
+ except Exception as e:
1276
+ self.logger.warning(f"Failed to save follow-up draft to file: {str(e)}")
1277
+ return f"drafts/{execution_id}_{draft['draft_id']}_followup.json"
1278
+
1279
+ def _generate_follow_up_subject_lines(self, customer_data: Dict[str, Any], recommended_product: Dict[str, Any],
1280
+ interaction_analysis: Dict[str, Any], follow_up_strategy: Dict[str, Any],
1281
+ approach: Dict[str, Any], context: Dict[str, Any]) -> List[str]:
1282
+ """Generate follow-up subject lines using LLM."""
1283
+ try:
1284
+ input_data = context.get('input_data', {})
1285
+ company_info = customer_data.get('companyInfo', {})
1286
+
1287
+ prompt = f"""Generate 3 compelling follow-up email subject lines for {company_info.get('name', 'a company')} in the {company_info.get('industry', 'technology')} industry.
1288
+
1289
+ CONTEXT:
1290
+ - This is follow-up #{follow_up_strategy.get('sequence_number', 1)}
1291
+ - Days since last contact: {interaction_analysis.get('days_since_last_interaction', 0)}
1292
+ - Follow-up strategy: {follow_up_strategy.get('strategy_type', 'gentle_reminder')}
1293
+ - Target Company: {company_info.get('name', 'the company')}
1294
+ - Our Solution: {recommended_product.get('product_name', 'our solution')}
1295
+ - Approach Tone: {approach.get('tone', 'professional')}
1296
+
1297
+ REQUIREMENTS:
1298
+ 1. Keep subject lines under 50 characters
1299
+ 2. Make them appropriate for a follow-up (not initial contact)
1300
+ 3. Reference the passage of time appropriately
1301
+ 4. Match the {approach.get('tone', 'professional')} tone
1302
+ 5. Avoid being pushy or aggressive
1303
+
1304
+ Generate 3 subject lines, one per line, no numbering or bullets:"""
1305
+
1306
+ response = self.call_llm(
1307
+ prompt=prompt,
1308
+ temperature=0.8,
1309
+ max_tokens=150
1310
+ )
1311
+
1312
+ # Parse subject lines from response
1313
+ subject_lines = [line.strip() for line in response.split('\n') if line.strip()]
1314
+
1315
+ # Ensure we have at least 3 subject lines
1316
+ if len(subject_lines) < 3:
1317
+ subject_lines.extend(self._generate_fallback_follow_up_subject_lines(customer_data, follow_up_strategy))
1318
+
1319
+ return subject_lines[:3] # Return max 3 subject lines
1320
+
1321
+ except Exception as e:
1322
+ self.logger.warning(f"Failed to generate follow-up subject lines: {str(e)}")
1323
+ return self._generate_fallback_follow_up_subject_lines(customer_data, follow_up_strategy)
1324
+
1325
+ def _generate_fallback_follow_up_subject_lines(self, customer_data: Dict[str, Any], follow_up_strategy: Dict[str, Any]) -> List[str]:
1326
+ """Generate fallback follow-up subject lines using templates."""
1327
+ company_info = customer_data.get('companyInfo', {})
1328
+ company_name = company_info.get('name', 'Your Company')
1329
+ sequence_number = follow_up_strategy.get('sequence_number', 1)
1330
+
1331
+ if sequence_number == 1:
1332
+ return [
1333
+ f"Following up on {company_name}",
1334
+ f"Quick check-in",
1335
+ f"Thoughts on our discussion?"
1336
+ ]
1337
+ elif sequence_number == 2:
1338
+ return [
1339
+ f"Additional insights for {company_name}",
1340
+ f"One more thought",
1341
+ f"Quick update for you"
1342
+ ]
1343
+ else:
1344
+ return [
1345
+ f"Final follow-up for {company_name}",
1346
+ f"Last check-in",
1347
+ f"Closing the loop"
1348
+ ]
1349
+
1350
+ def _generate_fallback_follow_up_draft(self, customer_data: Dict[str, Any], recommended_product: Dict[str, Any],
1351
+ follow_up_strategy: Dict[str, Any], context: Dict[str, Any]) -> List[Dict[str, Any]]:
1352
+ """Generate fallback follow-up draft when LLM generation fails."""
1353
+ draft_id = f"uuid:{str(uuid.uuid4())}"
1354
+ draft_approach = "fallback"
1355
+ draft_type = "followup"
1356
+
1357
+ return [{
1358
+ 'draft_id': draft_id,
1359
+ 'approach': 'fallback_template',
1360
+ 'strategy_type': follow_up_strategy.get('strategy_type', 'gentle_reminder'),
1361
+ 'sequence_number': follow_up_strategy.get('sequence_number', 1),
1362
+ 'tone': 'professional',
1363
+ 'focus': 'general follow-up',
1364
+ 'subject_lines': self._generate_fallback_follow_up_subject_lines(customer_data, follow_up_strategy),
1365
+ 'email_body': self._generate_template_follow_up_email(customer_data, recommended_product, follow_up_strategy, {'tone': 'professional'}, context),
1366
+ 'call_to_action': 'Would you be interested in continuing our conversation?',
1367
+ 'personalization_score': 50,
1368
+ 'generated_at': datetime.now().isoformat(),
1369
+ 'status': 'draft',
1370
+ 'metadata': {
1371
+ 'generation_method': 'template_fallback',
1372
+ 'note': 'Generated using template due to LLM failure'
1373
+ }
1374
+ }]
1375
+
1376
+ def _generate_template_follow_up_email(self, customer_data: Dict[str, Any], recommended_product: Dict[str, Any],
1377
+ follow_up_strategy: Dict[str, Any], approach: Dict[str, Any], context: Dict[str, Any]) -> str:
1378
+ """Generate follow-up email using template as fallback."""
1379
+ input_data = context.get('input_data', {})
1380
+ company_info = customer_data.get('companyInfo', {})
1381
+ contact_info = customer_data.get('primaryContact', {})
1382
+ sequence_number = follow_up_strategy.get('sequence_number', 1)
1383
+
1384
+ if sequence_number == 1:
1385
+ return f"""Hi {contact_info.get('name', 'there')},
1386
+
1387
+ I wanted to follow up on my previous message regarding {company_info.get('name', 'your company')} and how our {recommended_product.get('product_name', 'solution')} might be able to help.
1388
+
1389
+ I understand you're probably busy, but I'd love to hear your thoughts when you have a moment.
1390
+
1391
+ Would you be available for a brief 15-minute call this week?
1392
+
1393
+ Best regards,
1394
+ {input_data.get('staff_name', 'Sales Team')}
1395
+ {input_data.get('org_name', 'Our Company')}"""
1396
+ else:
1397
+ return f"""Hi {contact_info.get('name', 'there')},
1398
+
1399
+ I hope this message finds you well. I wanted to reach out one more time regarding the opportunity to help {company_info.get('name', 'your company')} with {recommended_product.get('product_name', 'our solution')}.
1400
+
1401
+ If now isn't the right time, I completely understand. Please feel free to reach out whenever it makes sense for your team.
1402
+
1403
+ Best regards,
1404
+ {input_data.get('staff_name', 'Sales Team')}
1405
+ {input_data.get('org_name', 'Our Company')}"""
1406
+
1407
+ def _rewrite_follow_up_draft(self, existing_draft: Dict[str, Any], reason: str, customer_data: Dict[str, Any],
1408
+ interaction_analysis: Dict[str, Any], context: Dict[str, Any]) -> Dict[str, Any]:
1409
+ """Rewrite existing follow-up draft based on reason using LLM."""
1410
+ try:
1411
+ if self.is_dry_run():
1412
+ rewritten = existing_draft.copy()
1413
+ rewritten['draft_id'] = f"uuid:{str(uuid.uuid4())}"
1414
+ rewritten['draft_approach'] = "rewrite"
1415
+ rewritten['draft_type'] = "followup_rewrite"
1416
+ rewritten['email_body'] = f"[DRY RUN - REWRITTEN: {reason}] " + existing_draft.get('email_body', '')
1417
+ rewritten['rewrite_reason'] = reason
1418
+ rewritten['rewritten_at'] = datetime.now().isoformat()
1419
+ return rewritten
1420
+
1421
+ # Use similar logic to initial outreach rewrite but with follow-up context
1422
+ input_data = context.get('input_data', {})
1423
+ company_info = customer_data.get('companyInfo', {})
1424
+ contact_info = customer_data.get('primaryContact', {})
1425
+
1426
+ # Create rewrite prompt with follow-up context
1427
+ rewrite_prompt = f"""Rewrite the following follow-up email based on the feedback provided. Keep the follow-up nature and context but address the specific concerns mentioned.
1428
+
1429
+ ORIGINAL FOLLOW-UP EMAIL:
1430
+ {existing_draft.get('email_body', '')}
1431
+
1432
+ FEEDBACK/REASON FOR REWRITE:
1433
+ {reason}
1434
+
1435
+ FOLLOW-UP CONTEXT:
1436
+ - This is follow-up #{existing_draft.get('sequence_number', 1)}
1437
+ - Days since last interaction: {interaction_analysis.get('days_since_last_interaction', 0)}
1438
+ - Company: {company_info.get('name', 'the company')}
1439
+ - Contact: {contact_info.get('name', 'the contact')}
1440
+
1441
+ REQUIREMENTS:
1442
+ 1. Address the feedback/reason provided
1443
+ 2. Maintain the follow-up context and tone
1444
+ 3. Keep it respectful and professional
1445
+ 4. Don't be pushy or aggressive
1446
+ 5. Include a clear but gentle call-to-action
1447
+
1448
+ Generate only the rewritten follow-up email content:"""
1449
+
1450
+ # Generate rewritten content using LLM
1451
+ rewritten_content = self.call_llm(
1452
+ prompt=rewrite_prompt,
1453
+ temperature=0.6,
1454
+ max_tokens=800
1455
+ )
1456
+
1457
+ # Clean the rewritten content
1458
+ cleaned_content = self._clean_email_content(rewritten_content)
1459
+
1460
+ # Create rewritten draft object
1461
+ rewritten = existing_draft.copy()
1462
+ rewritten['draft_id'] = f"uuid:{str(uuid.uuid4())}"
1463
+ rewritten['draft_approach'] = "rewrite"
1464
+ rewritten['draft_type'] = "followup_rewrite"
1465
+ rewritten['email_body'] = cleaned_content
1466
+ rewritten['rewrite_reason'] = reason
1467
+ rewritten['rewritten_at'] = datetime.now().isoformat()
1468
+ rewritten['version'] = existing_draft.get('version', 1) + 1
1469
+ rewritten['call_to_action'] = self._extract_call_to_action(cleaned_content)
1470
+ rewritten['personalization_score'] = self._calculate_personalization_score(cleaned_content, customer_data)
1471
+
1472
+ return rewritten
1473
+
1474
+ except Exception as e:
1475
+ self.logger.error(f"Failed to rewrite follow-up draft: {str(e)}")
1476
+ # Fallback to simple modification
1477
+ rewritten = existing_draft.copy()
1478
+ rewritten['draft_id'] = f"uuid:{str(uuid.uuid4())}"
1479
+ rewritten['draft_approach'] = "rewrite"
1480
+ rewritten['draft_type'] = "followup_rewrite"
1481
+ rewritten['email_body'] = f"[REWRITTEN: {reason}]\n\n" + existing_draft.get('email_body', '')
1482
+ rewritten['rewrite_reason'] = reason
1483
+ rewritten['rewritten_at'] = datetime.now().isoformat()
1484
+ return rewritten
1485
+
1486
+ def _save_rewritten_follow_up_draft(self, context: Dict[str, Any], rewritten_draft: Dict[str, Any], original_draft_id: str) -> Dict[str, Any]:
1487
+ """Save rewritten follow-up draft to database and file system."""
1488
+ # Use similar logic to initial outreach but mark as follow-up rewrite
1489
+ rewritten_draft['original_draft_id'] = original_draft_id
1490
+ rewritten_draft['execution_id'] = context.get('execution_id')
1491
+ rewritten_draft['draft_type'] = 'follow_up_rewrite'
1492
+
1493
+ # Save using the same method as regular drafts
1494
+ saved_drafts = self._save_follow_up_drafts(context, [rewritten_draft])
1495
+ return saved_drafts[0] if saved_drafts else rewritten_draft
1496
+
1497
+ def _send_follow_up_email(self, draft: Dict[str, Any], recipient_address: str, recipient_name: str, context: Dict[str, Any]) -> Dict[str, Any]:
1498
+ """Send follow-up email immediately (similar to initial outreach but for follow-up)."""
1499
+ if self.is_dry_run():
1500
+ return {
1501
+ 'success': True,
1502
+ 'message': f'[DRY RUN] Would send follow-up email to {recipient_address}',
1503
+ 'email_id': f'mock_followup_email_{uuid.uuid4().hex[:8]}'
1504
+ }
1505
+
1506
+ try:
1507
+ # Use similar email sending logic as initial outreach
1508
+ # This would integrate with the RTA email service
1509
+ input_data = context.get('input_data', {})
1510
+
1511
+ # Get auto interaction settings from team settings
1512
+ auto_interaction_config = self._get_auto_interaction_config(input_data.get('team_id'))
1513
+
1514
+ # Prepare email payload for RTA email service (matching trigger_auto_interaction)
1515
+ email_payload = {
1516
+ "project_code": input_data.get('project_code', ''),
1517
+ "event_type": "custom",
1518
+ "event_id": input_data.get('customer_id', context.get('execution_id')),
1519
+ "type": "interaction",
1520
+ "family": "GLOBALSELL_INTERACT_EVENT_ADHOC",
1521
+ "language": input_data.get('language', 'english'),
1522
+ "submission_date": datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
1523
+ "instanceName": f"Send follow-up email to {recipient_name} ({recipient_address}) from {auto_interaction_config.get('from_name', input_data.get('org_name', 'Unknown'))}",
1524
+ "instanceID": f"{context.get('execution_id')}_{input_data.get('customer_id', 'unknown')}",
1525
+ "uuid": f"{context.get('execution_id')}_{input_data.get('customer_id', 'unknown')}",
1526
+ "action_type": auto_interaction_config.get('tool_type', 'email').lower(),
1527
+ "email": recipient_address,
1528
+ "number": auto_interaction_config.get('from_number', input_data.get('customer_phone', '')),
1529
+ "subject": draft['subject_lines'][0] if draft.get('subject_lines') else 'Follow-up',
1530
+ "content": draft.get('email_body', ''),
1531
+ "team_id": input_data.get('team_id', ''),
1532
+ "from_email": auto_interaction_config.get('from_email', ''),
1533
+ "from_name": auto_interaction_config.get('from_name', ''),
1534
+ "email_cc": auto_interaction_config.get('email_cc', ''),
1535
+ "email_bcc": auto_interaction_config.get('email_bcc', ''),
1536
+ "extraData": {
1537
+ "org_id": input_data.get('org_id'),
1538
+ "human_action_id": input_data.get('human_action_id', ''),
1539
+ "email_tags": "gs_162_follow_up",
1540
+ "task_id": input_data.get('customer_id', context.get('execution_id'))
1541
+ }
1542
+ }
1543
+
1544
+ # Send to RTA email service
1545
+ headers = {
1546
+ 'Content-Type': 'application/json'
1547
+ }
1548
+
1549
+ response = requests.post(
1550
+ 'https://automation.rta.vn/webhook/autoemail-trigger-by-inst-check',
1551
+ json=email_payload,
1552
+ headers=headers,
1553
+ timeout=30
1554
+ )
1555
+
1556
+ if response.status_code == 200:
1557
+ result = response.json()
1558
+ execution_id = result.get('executionID', '')
1559
+
1560
+ if execution_id:
1561
+ self.logger.info(f"Follow-up email sent successfully via RTA service: {execution_id}")
1562
+ return {
1563
+ 'success': True,
1564
+ 'message': f'Follow-up email sent to {recipient_address}',
1565
+ 'email_id': execution_id,
1566
+ 'service': 'RTA_email_service',
1567
+ 'response': result
1568
+ }
1569
+ else:
1570
+ self.logger.warning("Email service returned success but no execution ID")
1571
+ return {
1572
+ 'success': False,
1573
+ 'message': 'Email service returned success but no execution ID',
1574
+ 'response': result
1575
+ }
1576
+ else:
1577
+ self.logger.error(f"Email service returned error: {response.status_code} - {response.text}")
1578
+ return {
1579
+ 'success': False,
1580
+ 'message': f'Email service error: {response.status_code}',
1581
+ 'error': response.text
1582
+ }
1583
+
1584
+ except Exception as e:
1585
+ self.logger.error(f"Follow-up email sending failed: {str(e)}")
1586
+ return {
1587
+ 'success': False,
1588
+ 'message': f'Follow-up email sending failed: {str(e)}',
1589
+ 'error': str(e)
1590
+ }