fusesell 1.2.6__tar.gz → 1.2.8__tar.gz

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.

Files changed (43) hide show
  1. {fusesell-1.2.6 → fusesell-1.2.8}/CHANGELOG.md +19 -0
  2. {fusesell-1.2.6/fusesell.egg-info → fusesell-1.2.8}/PKG-INFO +1 -1
  3. {fusesell-1.2.6 → fusesell-1.2.8/fusesell.egg-info}/PKG-INFO +1 -1
  4. {fusesell-1.2.6 → fusesell-1.2.8}/fusesell_local/__init__.py +1 -1
  5. {fusesell-1.2.6 → fusesell-1.2.8}/fusesell_local/pipeline.py +23 -19
  6. {fusesell-1.2.6 → fusesell-1.2.8}/fusesell_local/stages/initial_outreach.py +210 -83
  7. {fusesell-1.2.6 → fusesell-1.2.8}/pyproject.toml +1 -1
  8. {fusesell-1.2.6 → fusesell-1.2.8}/LICENSE +0 -0
  9. {fusesell-1.2.6 → fusesell-1.2.8}/MANIFEST.in +0 -0
  10. {fusesell-1.2.6 → fusesell-1.2.8}/README.md +0 -0
  11. {fusesell-1.2.6 → fusesell-1.2.8}/fusesell.egg-info/SOURCES.txt +0 -0
  12. {fusesell-1.2.6 → fusesell-1.2.8}/fusesell.egg-info/dependency_links.txt +0 -0
  13. {fusesell-1.2.6 → fusesell-1.2.8}/fusesell.egg-info/entry_points.txt +0 -0
  14. {fusesell-1.2.6 → fusesell-1.2.8}/fusesell.egg-info/requires.txt +0 -0
  15. {fusesell-1.2.6 → fusesell-1.2.8}/fusesell.egg-info/top_level.txt +0 -0
  16. {fusesell-1.2.6 → fusesell-1.2.8}/fusesell.py +0 -0
  17. {fusesell-1.2.6 → fusesell-1.2.8}/fusesell_local/api.py +0 -0
  18. {fusesell-1.2.6 → fusesell-1.2.8}/fusesell_local/cli.py +0 -0
  19. {fusesell-1.2.6 → fusesell-1.2.8}/fusesell_local/config/__init__.py +0 -0
  20. {fusesell-1.2.6 → fusesell-1.2.8}/fusesell_local/config/prompts.py +0 -0
  21. {fusesell-1.2.6 → fusesell-1.2.8}/fusesell_local/config/settings.py +0 -0
  22. {fusesell-1.2.6 → fusesell-1.2.8}/fusesell_local/stages/__init__.py +0 -0
  23. {fusesell-1.2.6 → fusesell-1.2.8}/fusesell_local/stages/base_stage.py +0 -0
  24. {fusesell-1.2.6 → fusesell-1.2.8}/fusesell_local/stages/data_acquisition.py +0 -0
  25. {fusesell-1.2.6 → fusesell-1.2.8}/fusesell_local/stages/data_preparation.py +0 -0
  26. {fusesell-1.2.6 → fusesell-1.2.8}/fusesell_local/stages/follow_up.py +0 -0
  27. {fusesell-1.2.6 → fusesell-1.2.8}/fusesell_local/stages/lead_scoring.py +0 -0
  28. {fusesell-1.2.6 → fusesell-1.2.8}/fusesell_local/tests/conftest.py +0 -0
  29. {fusesell-1.2.6 → fusesell-1.2.8}/fusesell_local/tests/test_api.py +0 -0
  30. {fusesell-1.2.6 → fusesell-1.2.8}/fusesell_local/tests/test_cli.py +0 -0
  31. {fusesell-1.2.6 → fusesell-1.2.8}/fusesell_local/tests/test_data_manager_products.py +0 -0
  32. {fusesell-1.2.6 → fusesell-1.2.8}/fusesell_local/tests/test_data_manager_sales_process.py +0 -0
  33. {fusesell-1.2.6 → fusesell-1.2.8}/fusesell_local/tests/test_data_manager_teams.py +0 -0
  34. {fusesell-1.2.6 → fusesell-1.2.8}/fusesell_local/utils/__init__.py +0 -0
  35. {fusesell-1.2.6 → fusesell-1.2.8}/fusesell_local/utils/birthday_email_manager.py +0 -0
  36. {fusesell-1.2.6 → fusesell-1.2.8}/fusesell_local/utils/data_manager.py +0 -0
  37. {fusesell-1.2.6 → fusesell-1.2.8}/fusesell_local/utils/event_scheduler.py +0 -0
  38. {fusesell-1.2.6 → fusesell-1.2.8}/fusesell_local/utils/llm_client.py +0 -0
  39. {fusesell-1.2.6 → fusesell-1.2.8}/fusesell_local/utils/logger.py +0 -0
  40. {fusesell-1.2.6 → fusesell-1.2.8}/fusesell_local/utils/timezone_detector.py +0 -0
  41. {fusesell-1.2.6 → fusesell-1.2.8}/fusesell_local/utils/validators.py +0 -0
  42. {fusesell-1.2.6 → fusesell-1.2.8}/requirements.txt +0 -0
  43. {fusesell-1.2.6 → fusesell-1.2.8}/setup.cfg +0 -0
@@ -2,6 +2,25 @@
2
2
 
3
3
  All notable changes to FuseSell Local will be documented in this file.
4
4
 
5
+ # [1.2.8] - 2025-10-24
6
+
7
+ ### Changed
8
+ - Initial outreach resolves the primary sales rep from `gs_team_rep` and injects their identity into prompts, reminders, and draft metadata so outreach reflects real team settings.
9
+
10
+ ### Fixed
11
+ - Sanitizes generated email bodies to replace or remove `[Your …]` placeholders, ensuring signatures contain actual values even when optional rep fields are missing.
12
+ - Reminder scheduling now preserves merged contact emails so follow-up records always carry `customer_email` for downstream automations.
13
+
14
+ # [1.2.7] - 2025-10-24
15
+
16
+ ### Changed
17
+ - RealTimeX sales-process normalization now forwards `recipient_address`, `recipient_name`, and `customer_email` into the FuseSell pipeline so prompt generation and scheduling have complete context.
18
+ - Default outreach prompt replacements enrich customer metadata with toolkit-derived contact details and enforce first-name greetings to match server quality.
19
+
20
+ ### Fixed
21
+ - Initial outreach stage now records generated drafts in the pipeline summary, seeds reminder_task rows with toolkit credentials, and schedules follow-up events when `send_immediately` is false.
22
+ - Prompt-based draft generation no longer skips scheduling due to missing email fields and guarantees outputs without unresolved placeholders.
23
+
5
24
  # [1.2.6] - 2025-10-24
6
25
 
7
26
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fusesell
3
- Version: 1.2.6
3
+ Version: 1.2.8
4
4
  Summary: Local implementation of FuseSell AI sales automation pipeline
5
5
  Author-email: FuseSell Team <team@fusesell.ai>
6
6
  License-Expression: MIT
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fusesell
3
- Version: 1.2.6
3
+ Version: 1.2.8
4
4
  Summary: Local implementation of FuseSell AI sales automation pipeline
5
5
  Author-email: FuseSell Team <team@fusesell.ai>
6
6
  License-Expression: MIT
@@ -32,6 +32,6 @@ __all__ = [
32
32
  "validate_config",
33
33
  ]
34
34
 
35
- __version__ = "1.2.6"
35
+ __version__ = "1.2.8"
36
36
  __author__ = "FuseSell Team"
37
37
  __description__ = "Local implementation of FuseSell AI sales automation pipeline"
@@ -377,15 +377,16 @@ class FuseSellPipeline:
377
377
  return {
378
378
  'org_id': self.config['org_id'],
379
379
  'org_name': self.config['org_name'],
380
- 'team_id': self.config.get('team_id'),
381
- 'team_name': self.config.get('team_name'),
382
- 'project_code': self.config.get('project_code'),
383
- 'staff_name': self.config.get('staff_name', 'Sales Team'),
384
- 'language': self.config.get('language', 'english'),
385
- # Data sources (matching executor schema)
386
- 'input_website': self.config.get('input_website', ''),
387
- 'input_description': self.config.get('input_description', ''),
388
- 'input_business_card': self.config.get('input_business_card', ''),
380
+ 'team_id': self.config.get('team_id'),
381
+ 'team_name': self.config.get('team_name'),
382
+ 'project_code': self.config.get('project_code'),
383
+ 'staff_name': self.config.get('staff_name', 'Sales Team'),
384
+ 'customer_name': self.config.get('customer_name', ''),
385
+ 'language': self.config.get('language', 'english'),
386
+ # Data sources (matching executor schema)
387
+ 'input_website': self.config.get('input_website', ''),
388
+ 'input_description': self.config.get('input_description', ''),
389
+ 'input_business_card': self.config.get('input_business_card', ''),
389
390
  'input_linkedin_url': self.config.get('input_linkedin_url', ''),
390
391
  'input_facebook_url': self.config.get('input_facebook_url', ''),
391
392
  'input_freetext': self.config.get('input_freetext', ''),
@@ -396,10 +397,11 @@ class FuseSellPipeline:
396
397
 
397
398
  # Action and continuation fields (for server executor compatibility)
398
399
  'action': self.config.get('action', 'draft_write'),
399
- 'selected_draft_id': self.config.get('selected_draft_id', ''),
400
- 'reason': self.config.get('reason', ''),
400
+ 'selected_draft_id': self.config.get('selected_draft_id', ''),
401
+ 'reason': self.config.get('reason', ''),
401
402
  'recipient_address': self.config.get('recipient_address', ''),
402
403
  'recipient_name': self.config.get('recipient_name', ''),
404
+ 'customer_email': self.config.get('customer_email', ''),
403
405
  'interaction_type': self.config.get('interaction_type', 'email'),
404
406
  'human_action_id': self.config.get('human_action_id', ''),
405
407
 
@@ -600,14 +602,16 @@ class FuseSellPipeline:
600
602
  if result.get('status') == 'success':
601
603
  data = result.get('data', {})
602
604
 
603
- if stage_name == 'data_preparation':
604
- customer_data = data
605
- elif stage_name == 'lead_scoring':
606
- lead_scores = data.get('scores', [])
607
- elif stage_name == 'initial_outreach':
608
- email_drafts.extend(data.get('drafts', []))
609
- elif stage_name == 'follow_up':
610
- email_drafts.extend(data.get('drafts', []))
605
+ if stage_name == 'data_preparation':
606
+ customer_data = data
607
+ elif stage_name == 'lead_scoring':
608
+ lead_scores = data.get('scores', [])
609
+ elif stage_name == 'initial_outreach':
610
+ drafts = data.get('drafts') or data.get('email_drafts') or []
611
+ email_drafts.extend(drafts)
612
+ elif stage_name == 'follow_up':
613
+ drafts = data.get('drafts') or data.get('email_drafts') or []
614
+ email_drafts.extend(drafts)
611
615
 
612
616
  # Generate performance analytics
613
617
  performance_analytics = self._generate_performance_analytics(duration)
@@ -12,13 +12,17 @@ from datetime import datetime
12
12
  from .base_stage import BaseStage
13
13
 
14
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]:
15
+ class InitialOutreachStage(BaseStage):
16
+ """
17
+ Initial Outreach stage with full server executor schema compliance.
18
+ Supports: draft_write, draft_rewrite, send, close actions.
19
+ """
20
+
21
+ def __init__(self, *args, **kwargs):
22
+ super().__init__(*args, **kwargs)
23
+ self._active_rep_profile: Dict[str, Any] = {}
24
+
25
+ def execute(self, context: Dict[str, Any]) -> Dict[str, Any]:
22
26
  """
23
27
  Execute initial outreach stage with action-based routing (matching server executor).
24
28
 
@@ -99,9 +103,21 @@ class InitialOutreachStage(BaseStage):
99
103
  if not recommended_product:
100
104
  raise ValueError("No product recommendation available for email generation")
101
105
 
102
- # Generate multiple email drafts
103
- email_drafts = self._generate_email_drafts(customer_data, recommended_product, scoring_data, context)
104
-
106
+ rep_profile = self._resolve_primary_sales_rep(context)
107
+ self._active_rep_profile = rep_profile or {}
108
+
109
+ try:
110
+ # Generate multiple email drafts
111
+ email_drafts = self._generate_email_drafts(
112
+ customer_data,
113
+ recommended_product,
114
+ scoring_data,
115
+ context,
116
+ rep_profile=self._active_rep_profile
117
+ )
118
+ finally:
119
+ self._active_rep_profile = {}
120
+
105
121
  # Save drafts to local files and database
106
122
  saved_drafts = self._save_email_drafts(context, email_drafts)
107
123
 
@@ -115,10 +131,11 @@ class InitialOutreachStage(BaseStage):
115
131
  outreach_data = {
116
132
  'action': 'draft_write',
117
133
  'status': 'drafts_generated',
118
- 'email_drafts': saved_drafts,
119
- 'recommended_product': recommended_product,
120
- 'customer_summary': self._create_customer_summary(customer_data),
121
- 'total_drafts_generated': len(saved_drafts),
134
+ 'email_drafts': saved_drafts,
135
+ 'drafts': saved_drafts,
136
+ 'recommended_product': recommended_product,
137
+ 'customer_summary': self._create_customer_summary(customer_data),
138
+ 'total_drafts_generated': len(saved_drafts),
122
139
  'generation_timestamp': datetime.now().isoformat(),
123
140
  'customer_id': context.get('execution_id')
124
141
  }
@@ -327,8 +344,8 @@ class InitialOutreachStage(BaseStage):
327
344
  team_id = input_data.get('team_id')
328
345
  team_name = input_data.get('team_name')
329
346
  language = input_data.get('language')
330
- customer_name = input_data.get('customer_name')
331
- staff_name = input_data.get('staff_name')
347
+ customer_name = input_data.get('customer_name') or input_data.get('recipient_name')
348
+ staff_name = input_data.get('staff_name') or self.config.get('staff_name') or 'Sales Team'
332
349
  reminder_room = self.config.get('reminder_room_id') or input_data.get('reminder_room_id')
333
350
  draft_id = draft.get('draft_id') or 'unknown_draft'
334
351
 
@@ -355,6 +372,8 @@ class InitialOutreachStage(BaseStage):
355
372
  customextra['approach'] = draft.get('approach')
356
373
  if draft.get('mail_tone'):
357
374
  customextra['mail_tone'] = draft.get('mail_tone')
375
+ if recipient_address and 'customer_email' not in customextra:
376
+ customextra['customer_email'] = recipient_address
358
377
 
359
378
  return {
360
379
  'status': 'published',
@@ -368,6 +387,7 @@ class InitialOutreachStage(BaseStage):
368
387
  'team_name': team_name,
369
388
  'language': language,
370
389
  'customer_name': customer_name,
390
+ 'customer_email': recipient_address,
371
391
  'staff_name': staff_name,
372
392
  'customextra': customextra
373
393
  }
@@ -394,11 +414,18 @@ class InitialOutreachStage(BaseStage):
394
414
  return None
395
415
 
396
416
  contact_info = customer_data.get('primaryContact', {}) or {}
417
+ stage_results = context.get('stage_results', {}) or {}
418
+ data_acquisition = {}
419
+ if isinstance(stage_results, dict):
420
+ data_acquisition = stage_results.get('data_acquisition', {}).get('data', {}) or {}
397
421
 
398
422
  recipient_address = (
399
423
  input_data.get('recipient_address')
400
424
  or contact_info.get('email')
401
425
  or contact_info.get('emailAddress')
426
+ or data_acquisition.get('customer_email')
427
+ or data_acquisition.get('contact_email')
428
+ or input_data.get('customer_email')
402
429
  )
403
430
  if not recipient_address:
404
431
  self.logger.info("Skipping reminder scheduling: recipient email not available")
@@ -408,6 +435,8 @@ class InitialOutreachStage(BaseStage):
408
435
  input_data.get('recipient_name')
409
436
  or contact_info.get('name')
410
437
  or contact_info.get('fullName')
438
+ or data_acquisition.get('contact_name')
439
+ or data_acquisition.get('customer_name')
411
440
  or ''
412
441
  )
413
442
 
@@ -611,16 +640,31 @@ class InitialOutreachStage(BaseStage):
611
640
  self.logger.error(f"Failed to get auto interaction config for team {team_id}: {str(e)}")
612
641
  return default_config
613
642
 
614
- 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]]:
615
- """Generate multiple personalized email drafts using LLM."""
616
- if self.is_dry_run():
617
- return self._get_mock_email_drafts(customer_data, recommended_product, context)
618
-
619
- try:
620
- input_data = context.get('input_data', {})
621
- company_info = customer_data.get('companyInfo', {})
622
- contact_info = customer_data.get('primaryContact', {})
623
- pain_points = customer_data.get('painPoints', [])
643
+ def _generate_email_drafts(self, customer_data: Dict[str, Any], recommended_product: Dict[str, Any], scoring_data: Dict[str, Any], context: Dict[str, Any], rep_profile: Optional[Dict[str, Any]] = None) -> List[Dict[str, Any]]:
644
+ """Generate multiple personalized email drafts using LLM."""
645
+ if self.is_dry_run():
646
+ return self._get_mock_email_drafts(customer_data, recommended_product, context)
647
+
648
+ try:
649
+ input_data = context.get('input_data', {})
650
+ rep_profile = rep_profile or {}
651
+ if rep_profile:
652
+ primary_name = rep_profile.get('name')
653
+ if primary_name:
654
+ input_data['staff_name'] = primary_name
655
+ self.config['staff_name'] = primary_name
656
+ if rep_profile.get('email'):
657
+ input_data.setdefault('staff_email', rep_profile.get('email'))
658
+ if rep_profile.get('phone') or rep_profile.get('primary_phone'):
659
+ input_data.setdefault('staff_phone', rep_profile.get('phone') or rep_profile.get('primary_phone'))
660
+ if rep_profile.get('position'):
661
+ input_data.setdefault('staff_title', rep_profile.get('position'))
662
+ if rep_profile.get('website'):
663
+ input_data.setdefault('staff_website', rep_profile.get('website'))
664
+
665
+ company_info = customer_data.get('companyInfo', {})
666
+ contact_info = customer_data.get('primaryContact', {})
667
+ pain_points = customer_data.get('painPoints', [])
624
668
 
625
669
  prompt_drafts = self._generate_email_drafts_from_prompt(
626
670
  customer_data,
@@ -819,46 +863,80 @@ class InitialOutreachStage(BaseStage):
819
863
 
820
864
  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]:
821
865
  input_data = context.get('input_data', {})
822
- company_info = customer_data.get('companyInfo', {}) or {}
823
- contact_info = customer_data.get('primaryContact', {}) or {}
824
- language = input_data.get('language') or company_info.get('language') or 'English'
825
- contact_name = contact_info.get('name') or input_data.get('customer_name') or input_data.get('recipient_name') or 'there'
826
- company_name = company_info.get('name') or input_data.get('company_name') or 'the company'
827
- staff_name = input_data.get('staff_name') or input_data.get('sender_name') or 'Sales Team'
828
- org_name = input_data.get('org_name') or 'Our Company'
829
- selected_product_name = recommended_product.get('product_name') if recommended_product else None
830
-
831
- action = input_data.get('action', 'draft_write')
832
- action_labels = {
833
- 'draft_write': 'email drafts',
834
- 'draft_rewrite': 'email rewrites',
866
+ company_info = customer_data.get('companyInfo', {}) or {}
867
+ contact_info = dict(customer_data.get('primaryContact', {}) or {})
868
+ stage_results = context.get('stage_results', {}) or {}
869
+ data_acquisition = {}
870
+ if isinstance(stage_results, dict):
871
+ data_acquisition = stage_results.get('data_acquisition', {}).get('data', {}) or {}
872
+ if not contact_info.get('name'):
873
+ fallback_contact_name = (
874
+ data_acquisition.get('contact_name')
875
+ or data_acquisition.get('customer_contact')
876
+ or input_data.get('recipient_name')
877
+ or input_data.get('customer_name')
878
+ )
879
+ if fallback_contact_name:
880
+ contact_info['name'] = fallback_contact_name
881
+ if not contact_info.get('email'):
882
+ fallback_email = (
883
+ data_acquisition.get('customer_email')
884
+ or data_acquisition.get('contact_email')
885
+ or input_data.get('recipient_address')
886
+ or input_data.get('customer_email')
887
+ )
888
+ if fallback_email:
889
+ contact_info['email'] = fallback_email
890
+ customer_data = dict(customer_data)
891
+ customer_data['primaryContact'] = contact_info
892
+ language = input_data.get('language') or company_info.get('language') or 'English'
893
+ contact_name = contact_info.get('name') or input_data.get('customer_name') or input_data.get('recipient_name') or 'there'
894
+ company_name = company_info.get('name') or input_data.get('company_name') or 'the company'
895
+ staff_name = input_data.get('staff_name') or input_data.get('sender_name') or 'Sales Team'
896
+ org_name = input_data.get('org_name') or 'Our Company'
897
+ selected_product_name = recommended_product.get('product_name') if recommended_product else None
898
+ language_lower = language.lower() if isinstance(language, str) else ''
899
+ name_parts = contact_name.split() if isinstance(contact_name, str) else []
900
+ if name_parts:
901
+ if language_lower in ('vietnamese', 'vi'):
902
+ first_name = name_parts[-1]
903
+ else:
904
+ first_name = name_parts[0]
905
+ else:
906
+ first_name = contact_name or ''
907
+
908
+ action = input_data.get('action', 'draft_write')
909
+ action_labels = {
910
+ 'draft_write': 'email drafts',
911
+ 'draft_rewrite': 'email rewrites',
835
912
  'send': 'email sends',
836
913
  'close': 'email workflow'
837
914
  }
838
915
  action_type = action_labels.get(action, action.replace('_', ' '))
839
916
 
840
- company_summary = self._build_company_info_summary(
841
- company_info,
842
- contact_info,
843
- customer_data.get('painPoints', []),
844
- scoring_data
845
- )
846
- product_summary = self._build_product_info_summary(recommended_product)
847
- first_name_guide = self._build_first_name_guide(language, contact_name)
848
-
849
- replacements = {
850
- '##action_type##': action_type,
851
- '##language##': language.title() if isinstance(language, str) else 'English',
852
- '##customer_name##': contact_name,
917
+ company_summary = self._build_company_info_summary(
918
+ company_info,
919
+ contact_info,
920
+ customer_data.get('painPoints', []),
921
+ scoring_data
922
+ )
923
+ product_summary = self._build_product_info_summary(recommended_product)
924
+ first_name_guide = self._build_first_name_guide(language, contact_name)
925
+
926
+ replacements = {
927
+ '##action_type##': action_type,
928
+ '##language##': language.title() if isinstance(language, str) else 'English',
929
+ '##customer_name##': contact_name,
853
930
  '##company_name##': company_name,
854
- '##staff_name##': staff_name,
855
- '##org_name##': org_name,
856
- '##first_name_guide##': first_name_guide,
857
- '##selected_product##': selected_product_name or 'our solution',
858
- '##company_info##': company_summary,
859
- '##selected_product_info##': product_summary
860
- }
861
-
931
+ '##staff_name##': staff_name,
932
+ '##org_name##': org_name,
933
+ '##first_name_guide##': first_name_guide,
934
+ '##customer_first_name##': first_name or contact_name,
935
+ '##selected_product##': selected_product_name or 'our solution',
936
+ '##company_info##': company_summary,
937
+ '##selected_product_info##': product_summary
938
+ }
939
+
862
940
  return {key: (value if value is not None else '') for key, value in replacements.items()}
863
941
 
864
942
  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:
@@ -932,26 +1010,72 @@ class InitialOutreachStage(BaseStage):
932
1010
  summary = "\n".join(lines).strip()
933
1011
  return summary or 'Product details unavailable.'
934
1012
 
935
- def _build_first_name_guide(self, language: str, contact_name: str) -> str:
936
- if not language:
937
- return ''
938
-
939
- language_lower = language.lower()
940
- if language_lower in ('vietnamese', 'vi'):
941
- if not contact_name or contact_name.lower() == 'a person':
942
- return "If the recipient's name is unknown, use `anh/chi` in the greeting."
943
- first_name = self._extract_first_name(contact_name)
944
- if first_name:
945
- return f"For Vietnamese recipients, use `anh/chi {first_name}` in the greeting to keep it respectful."
946
- return "For Vietnamese recipients, use `anh/chi` followed by the recipient's first name in the greeting."
947
-
948
- return ''
949
-
950
- def _extract_first_name(self, full_name: str) -> str:
951
- if not full_name:
952
- return ''
953
- parts = full_name.strip().split()
954
- return parts[-1] if parts else full_name
1013
+ def _build_first_name_guide(self, language: str, contact_name: str) -> str:
1014
+ if not language:
1015
+ return ''
1016
+
1017
+ language_lower = language.lower()
1018
+ name_parts = contact_name.split() if contact_name else []
1019
+ if language_lower in ('vietnamese', 'vi'):
1020
+ if not contact_name or contact_name.lower() == 'a person':
1021
+ return "If the recipient's name is unknown, use `anh/chi` in the greeting."
1022
+ vn_name = name_parts[-1] if name_parts else contact_name
1023
+ if vn_name:
1024
+ return f"For Vietnamese recipients, use `anh/chi {vn_name}` in the greeting to keep it respectful. Do not use placeholders or omit the honorific."
1025
+ return "For Vietnamese recipients, use `anh/chi` followed by the recipient's first name in the greeting."
1026
+
1027
+ if name_parts:
1028
+ en_name = name_parts[0]
1029
+ return (
1030
+ f'Use only the recipient\'s first name "{en_name}" in the greeting. '
1031
+ f'Start with "Hi {en_name}," or "Hello {en_name}," and do not use the surname or placeholders.'
1032
+ )
1033
+ return 'If the recipient name is unknown, use a neutral greeting like "Hi there," without placeholders.'
1034
+
1035
+ def _resolve_primary_sales_rep(self, context: Dict[str, Any]) -> Dict[str, Any]:
1036
+ team_id = context.get('input_data', {}).get('team_id') or self.config.get('team_id')
1037
+ if not team_id:
1038
+ return {}
1039
+ reps = self.get_team_setting('gs_team_rep', team_id, [])
1040
+ if not isinstance(reps, list):
1041
+ return {}
1042
+ for rep in reps:
1043
+ if rep and rep.get('is_primary'):
1044
+ return rep
1045
+ return reps[0] if reps else {}
1046
+
1047
+ def _sanitize_email_body(self, html: str, staff_name: str, rep_profile: Dict[str, Any]) -> str:
1048
+ if not html:
1049
+ return ''
1050
+
1051
+ replacements = {
1052
+ '[Your Name]': rep_profile.get('name') or staff_name,
1053
+ '[Your Email]': rep_profile.get('email'),
1054
+ '[Your Phone Number]': rep_profile.get('phone') or rep_profile.get('primary_phone'),
1055
+ '[Your Phone]': rep_profile.get('phone') or rep_profile.get('primary_phone'),
1056
+ '[Your Title]': rep_profile.get('position'),
1057
+ '[Your LinkedIn Profile]': rep_profile.get('linkedin') or rep_profile.get('linkedin_profile'),
1058
+ '[Your LinkedIn Profile URL]': rep_profile.get('linkedin') or rep_profile.get('linkedin_profile'),
1059
+ '[Your Website]': rep_profile.get('website'),
1060
+ }
1061
+
1062
+ for placeholder, value in replacements.items():
1063
+ if value:
1064
+ html = html.replace(placeholder, str(value))
1065
+ else:
1066
+ html = html.replace(placeholder, '')
1067
+
1068
+ # Remove any lingering placeholder fragments such as "[Your LinkedIn Profile"
1069
+ html = re.sub(r'\[Your[^<\]]+\]?', '', html, flags=re.IGNORECASE)
1070
+ # Collapse empty paragraphs created by placeholder removal
1071
+ html = re.sub(r'(<p>\s*</p>)+', '', html, flags=re.IGNORECASE)
1072
+ return html
1073
+
1074
+ def _extract_first_name(self, full_name: str) -> str:
1075
+ if not full_name:
1076
+ return ''
1077
+ parts = full_name.strip().split()
1078
+ return parts[-1] if parts else full_name
955
1079
 
956
1080
  def _strip_code_fences(self, text: str) -> str:
957
1081
  if not text:
@@ -1048,9 +1172,12 @@ class InitialOutreachStage(BaseStage):
1048
1172
  tags = [tags]
1049
1173
  tags = [str(tag).strip() for tag in tags if str(tag).strip()]
1050
1174
 
1051
- call_to_action = self._extract_call_to_action(email_body)
1052
- personalization_score = self._calculate_personalization_score(email_body, customer_data)
1053
- message_type = entry.get('message_type') or 'Email'
1175
+ call_to_action = self._extract_call_to_action(email_body)
1176
+ personalization_score = self._calculate_personalization_score(email_body, customer_data)
1177
+ message_type = entry.get('message_type') or 'Email'
1178
+ rep_profile = getattr(self, '_active_rep_profile', {}) or {}
1179
+ staff_name = context.get('input_data', {}).get('staff_name') or self.config.get('staff_name', 'Sales Team')
1180
+ email_body = self._sanitize_email_body(email_body, staff_name, rep_profile)
1054
1181
 
1055
1182
  metadata = {
1056
1183
  'customer_company': customer_data.get('companyInfo', {}).get('name', 'Unknown'),
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "fusesell"
7
- version = "1.2.6"
7
+ version = "1.2.8"
8
8
  description = "Local implementation of FuseSell AI sales automation pipeline"
9
9
  readme = "README.md"
10
10
  license = "MIT"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes