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