fusesell 1.2.9__py3-none-any.whl → 1.3.1__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.

@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fusesell
3
- Version: 1.2.9
3
+ Version: 1.3.1
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
  fusesell.py,sha256=t5PjkhWEJGINp4k517u0EX0ge7lzuHOUHHro-BE1mGk,596
2
- fusesell-1.2.9.dist-info/licenses/LICENSE,sha256=GDz1ZoC4lB0kwjERpzqc_OdA_awYVso2aBnUH-ErW_w,1070
3
- fusesell_local/__init__.py,sha256=Ajbk9gus0-G2gR6WAo1F_QTApGpLzz01I2LL2iE6t8c,967
2
+ fusesell-1.3.1.dist-info/licenses/LICENSE,sha256=GDz1ZoC4lB0kwjERpzqc_OdA_awYVso2aBnUH-ErW_w,1070
3
+ fusesell_local/__init__.py,sha256=4eRHhad_hjqLaoWSd_wZzPRBuX_V_Aqqf2_o93NeHao,967
4
4
  fusesell_local/api.py,sha256=AcPune5YJdgi7nsMeusCUqc49z5UiycsQb6n3yiV_No,10839
5
5
  fusesell_local/cli.py,sha256=MYnVxuEf5KTR4VcO3sc-VtP9NkWlSixJsYfOWST2Ds0,65859
6
6
  fusesell_local/pipeline.py,sha256=RMF_kgwNEc1ka8-CDJyzIOTSo8PGtR_zPKAgRevhlNo,39913
@@ -12,7 +12,7 @@ fusesell_local/stages/base_stage.py,sha256=ldo5xuHZto7ceEg3i_3rxAx0xPccK4n2jaxEJ
12
12
  fusesell_local/stages/data_acquisition.py,sha256=Td3mwakJRoEYbi3od4v2ZzKOHLgLSgccZVxH3ezs1_4,71081
13
13
  fusesell_local/stages/data_preparation.py,sha256=XWLg9b1w2NrMxLcrWDqB95mRmLQmVIMXpKNaBNr98TQ,52751
14
14
  fusesell_local/stages/follow_up.py,sha256=H9Xek6EoIbHrerQvGTRswXDNFH6zq71DcRxxj0zpo2g,77747
15
- fusesell_local/stages/initial_outreach.py,sha256=yoXAVaPgQXZc3bMq4U363Z4ARTsnSzOqbodKB3tke3A,123593
15
+ fusesell_local/stages/initial_outreach.py,sha256=8Ra-Nmq4njAG1iMNiUW7FQbYnX0h9p5F59OXlyGdFdU,135285
16
16
  fusesell_local/stages/lead_scoring.py,sha256=ir3l849eMGrGLf0OYUcmA1F3FwyYhAplS4niU3R2GRY,60658
17
17
  fusesell_local/tests/conftest.py,sha256=TWUtlP6cNPVOYkTPz-j9BzS_KnXdPWy8D-ObPLHvXYs,366
18
18
  fusesell_local/tests/test_api.py,sha256=763rUVb5pAuAQOovug6Ka0T9eGK8-WVOC_J08M7TETo,1827
@@ -22,14 +22,14 @@ fusesell_local/tests/test_data_manager_sales_process.py,sha256=NbwxQ9oBKCZfrkRQY
22
22
  fusesell_local/tests/test_data_manager_teams.py,sha256=kjk4V4r9ja4EVREIiQMxkuZd470SSwRHJAvpHln9KO4,4578
23
23
  fusesell_local/utils/__init__.py,sha256=TVemlo0wpckhNUxP3a1Tky3ekswy8JdIHaXBlkKXKBQ,330
24
24
  fusesell_local/utils/birthday_email_manager.py,sha256=NKLoUyzPedyhewZPma21SOoU8p9wPquehloer7TRA9U,20478
25
- fusesell_local/utils/data_manager.py,sha256=60CLOVkVB76AQx1wQyja0PFmA1t-YJITiGNni14IPOs,188449
26
- fusesell_local/utils/event_scheduler.py,sha256=tP-rnx9Hixfcm6ZTqloLy_EgSPII89v5dSycRHrCTLE,39824
25
+ fusesell_local/utils/data_manager.py,sha256=FHW9nvLXDgf-HYNFwxZlegZp0OgB3altszW6INIgyLM,188910
26
+ fusesell_local/utils/event_scheduler.py,sha256=TDk1v19cNgLhn2aJriQfpvZnwBcRpOWyHLDvkefW110,39834
27
27
  fusesell_local/utils/llm_client.py,sha256=FVc25UlGt6hro7h5Iw7PHSXY3E3_67Xc-SUbHuMSRs0,10437
28
28
  fusesell_local/utils/logger.py,sha256=sWlV8Tjyz_Z8J4zXKOnNalh8_iD6ytfrwPZpD-wcEOs,6259
29
29
  fusesell_local/utils/timezone_detector.py,sha256=0cAE4c8ZXqCA8AvxRKm6PrFKmAmsbq3HOHR6w-mW3KQ,39997
30
30
  fusesell_local/utils/validators.py,sha256=Z1VzeoxFsnuzlIA_ZaMWoy-0Cgyqupd47kIdljlMDbs,15438
31
- fusesell-1.2.9.dist-info/METADATA,sha256=lsdyRarHbom144vFp0amieA9fQuDQF3-ZL9H-5A04DY,35074
32
- fusesell-1.2.9.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
33
- fusesell-1.2.9.dist-info/entry_points.txt,sha256=Vqek7tbiX7iF4rQkCRBZvT5WrB0HUduqKTsI2ZjhsXo,53
34
- fusesell-1.2.9.dist-info/top_level.txt,sha256=VP9y1K6DEq6gNq2UgLd7ChujxViF6OzeAVCK7IUBXPA,24
35
- fusesell-1.2.9.dist-info/RECORD,,
31
+ fusesell-1.3.1.dist-info/METADATA,sha256=hYcoE3sJK-2TPuxHSUv5yxaoNYu1_B5gW1bfScica5w,35074
32
+ fusesell-1.3.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
33
+ fusesell-1.3.1.dist-info/entry_points.txt,sha256=Vqek7tbiX7iF4rQkCRBZvT5WrB0HUduqKTsI2ZjhsXo,53
34
+ fusesell-1.3.1.dist-info/top_level.txt,sha256=VP9y1K6DEq6gNq2UgLd7ChujxViF6OzeAVCK7IUBXPA,24
35
+ fusesell-1.3.1.dist-info/RECORD,,
@@ -32,6 +32,6 @@ __all__ = [
32
32
  "validate_config",
33
33
  ]
34
34
 
35
- __version__ = "1.2.9"
35
+ __version__ = "1.3.1"
36
36
  __author__ = "FuseSell Team"
37
37
  __description__ = "Local implementation of FuseSell AI sales automation pipeline"
@@ -7,7 +7,7 @@ import json
7
7
  import uuid
8
8
  import requests
9
9
  import re
10
- from typing import Dict, Any, List, Optional
10
+ from typing import Dict, Any, List, Optional
11
11
  from datetime import datetime
12
12
  from .base_stage import BaseStage
13
13
 
@@ -169,11 +169,17 @@ class InitialOutreachStage(BaseStage):
169
169
  raise ValueError(f"Draft not found: {selected_draft_id}")
170
170
 
171
171
  # Get customer data for context
172
- customer_data = self._get_customer_data(context)
173
- scoring_data = self._get_scoring_data(context)
174
-
175
- # Rewrite the draft based on reason
176
- rewritten_draft = self._rewrite_draft(existing_draft, reason, customer_data, scoring_data, context)
172
+ customer_data = self._get_customer_data(context)
173
+ scoring_data = self._get_scoring_data(context)
174
+
175
+ recipient_identity = self._resolve_recipient_identity(customer_data, context)
176
+ context.setdefault('_recipient_identity', recipient_identity)
177
+ context.setdefault('_recipient_identity', recipient_identity)
178
+ if recipient_identity.get('first_name') and not context.get('customer_first_name'):
179
+ context['customer_first_name'] = recipient_identity['first_name']
180
+
181
+ # Rewrite the draft based on reason
182
+ rewritten_draft = self._rewrite_draft(existing_draft, reason, customer_data, scoring_data, context)
177
183
 
178
184
  # Save the rewritten draft
179
185
  saved_draft = self._save_rewritten_draft(context, rewritten_draft, selected_draft_id)
@@ -563,11 +569,11 @@ class InitialOutreachStage(BaseStage):
563
569
  'lead_scoring': input_data.get('lead_scoring', [])
564
570
  }
565
571
 
566
- def _get_recommended_product(self, scoring_data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
567
- """Get recommended product from scoring data."""
568
- try:
569
- # Try to get from analysis first
570
- analysis = scoring_data.get('analysis', {})
572
+ def _get_recommended_product(self, scoring_data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
573
+ """Get recommended product from scoring data."""
574
+ try:
575
+ # Try to get from analysis first
576
+ analysis = scoring_data.get('analysis', {})
571
577
  if 'recommended_product' in analysis:
572
578
  return analysis['recommended_product']
573
579
 
@@ -584,14 +590,73 @@ class InitialOutreachStage(BaseStage):
584
590
 
585
591
  return None
586
592
  except Exception as e:
587
- self.logger.error(f"Failed to get recommended product: {str(e)}")
588
- return None
589
-
590
- def _get_auto_interaction_config(self, team_id: str = None) -> Dict[str, Any]:
591
- """
592
- Get auto interaction configuration from team settings.
593
-
594
- Args:
593
+ self.logger.error(f"Failed to get recommended product: {str(e)}")
594
+ return None
595
+
596
+ def _resolve_recipient_identity(
597
+ self,
598
+ customer_data: Dict[str, Any],
599
+ context: Dict[str, Any]
600
+ ) -> Dict[str, Optional[str]]:
601
+ """
602
+ Resolve recipient contact information and derive a safe first name.
603
+ """
604
+ input_data = context.get('input_data', {}) or {}
605
+ stage_results = context.get('stage_results', {}) or {}
606
+ data_acquisition: Dict[str, Any] = {}
607
+ if isinstance(stage_results, dict):
608
+ data_acquisition = stage_results.get('data_acquisition', {}).get('data', {}) or {}
609
+
610
+ primary_contact = dict(customer_data.get('primaryContact', {}) or {})
611
+
612
+ recipient_email = (
613
+ input_data.get('recipient_address')
614
+ or primary_contact.get('email')
615
+ or primary_contact.get('emailAddress')
616
+ or data_acquisition.get('contact_email')
617
+ or data_acquisition.get('customer_email')
618
+ or input_data.get('customer_email')
619
+ )
620
+
621
+ recipient_name = (
622
+ input_data.get('recipient_name')
623
+ or primary_contact.get('name')
624
+ or primary_contact.get('fullName')
625
+ or data_acquisition.get('contact_name')
626
+ or data_acquisition.get('customer_contact')
627
+ or input_data.get('customer_name')
628
+ )
629
+
630
+ first_name_source = (
631
+ context.get('customer_first_name')
632
+ or input_data.get('customer_first_name')
633
+ or recipient_name
634
+ )
635
+
636
+ first_name = ''
637
+ if isinstance(first_name_source, str) and first_name_source.strip():
638
+ first_name = self._extract_first_name(first_name_source.strip())
639
+ if not first_name and isinstance(recipient_name, str) and recipient_name.strip():
640
+ first_name = self._extract_first_name(recipient_name.strip())
641
+
642
+ if recipient_name and not primary_contact.get('name'):
643
+ primary_contact['name'] = recipient_name
644
+ if recipient_email and not primary_contact.get('email'):
645
+ primary_contact['email'] = recipient_email
646
+ if primary_contact and isinstance(customer_data, dict):
647
+ customer_data['primaryContact'] = primary_contact
648
+
649
+ return {
650
+ 'email': recipient_email,
651
+ 'full_name': recipient_name,
652
+ 'first_name': first_name
653
+ }
654
+
655
+ def _get_auto_interaction_config(self, team_id: str = None) -> Dict[str, Any]:
656
+ """
657
+ Get auto interaction configuration from team settings.
658
+
659
+ Args:
595
660
  team_id: Team ID to get settings for
596
661
 
597
662
  Returns:
@@ -648,6 +713,10 @@ class InitialOutreachStage(BaseStage):
648
713
  try:
649
714
  input_data = context.get('input_data', {})
650
715
  rep_profile = rep_profile or {}
716
+ recipient_identity = self._resolve_recipient_identity(customer_data, context)
717
+ if recipient_identity.get('first_name') and not context.get('customer_first_name'):
718
+ context['customer_first_name'] = recipient_identity['first_name']
719
+ context.setdefault('_recipient_identity', recipient_identity)
651
720
  if rep_profile:
652
721
  primary_name = rep_profile.get('name')
653
722
  if primary_name:
@@ -707,12 +776,13 @@ class InitialOutreachStage(BaseStage):
707
776
 
708
777
  for approach in draft_approaches:
709
778
  try:
710
- # Generate email content for this approach
711
- email_content = self._generate_single_email_draft(
712
- customer_data, recommended_product, scoring_data,
713
- approach, context
714
- )
715
-
779
+ # Generate email content for this approach
780
+ email_content = self._generate_single_email_draft(
781
+ customer_data, recommended_product, scoring_data,
782
+ approach, context
783
+ )
784
+ email_content = self._ensure_html_email(email_content, context)
785
+
716
786
  # Generate subject lines for this approach
717
787
  subject_lines = self._generate_subject_lines(
718
788
  customer_data, recommended_product, approach, context
@@ -725,26 +795,33 @@ class InitialOutreachStage(BaseStage):
725
795
  # Select the best subject line (first one, or most relevant)
726
796
  selected_subject = subject_lines[0] if subject_lines else f"Partnership opportunity for {company_info.get('name', 'your company')}"
727
797
 
728
- draft = {
729
- 'draft_id': draft_id,
730
- 'approach': approach['name'],
731
- 'tone': approach['tone'],
732
- 'focus': approach['focus'],
733
- 'subject': selected_subject, # Single subject instead of array
734
- 'subject_alternatives': subject_lines[1:4] if len(subject_lines) > 1 else [], # Store alternatives separately
735
- 'email_body': email_content,
736
- 'call_to_action': self._extract_call_to_action(email_content),
737
- 'personalization_score': self._calculate_personalization_score(email_content, customer_data),
738
- 'generated_at': datetime.now().isoformat(),
739
- 'status': 'draft',
740
- 'metadata': {
741
- 'customer_company': company_info.get('name', 'Unknown'),
742
- 'contact_name': contact_info.get('name', 'Unknown'),
743
- 'recommended_product': recommended_product.get('product_name', 'Unknown'),
744
- 'pain_points_addressed': len([p for p in pain_points if p.get('severity') in ['high', 'medium']]),
745
- 'generation_method': 'llm_powered'
746
- }
747
- }
798
+ draft = {
799
+ 'draft_id': draft_id,
800
+ 'approach': approach['name'],
801
+ 'tone': approach['tone'],
802
+ 'focus': approach['focus'],
803
+ 'subject': selected_subject, # Single subject instead of array
804
+ 'subject_alternatives': subject_lines[1:4] if len(subject_lines) > 1 else [], # Store alternatives separately
805
+ 'email_body': email_content,
806
+ 'email_format': 'html',
807
+ 'recipient_email': recipient_identity.get('email'),
808
+ 'recipient_name': recipient_identity.get('full_name'),
809
+ 'customer_first_name': recipient_identity.get('first_name'),
810
+ 'call_to_action': self._extract_call_to_action(email_content),
811
+ 'personalization_score': self._calculate_personalization_score(email_content, customer_data),
812
+ 'generated_at': datetime.now().isoformat(),
813
+ 'status': 'draft',
814
+ 'metadata': {
815
+ 'customer_company': company_info.get('name', 'Unknown'),
816
+ 'contact_name': contact_info.get('name', 'Unknown'),
817
+ 'recipient_email': recipient_identity.get('email'),
818
+ 'recipient_name': recipient_identity.get('full_name'),
819
+ 'email_format': 'html',
820
+ 'recommended_product': recommended_product.get('product_name', 'Unknown'),
821
+ 'pain_points_addressed': len([p for p in pain_points if p.get('severity') in ['high', 'medium']]),
822
+ 'generation_method': 'llm_powered'
823
+ }
824
+ }
748
825
 
749
826
  generated_drafts.append(draft)
750
827
 
@@ -1193,18 +1270,22 @@ class InitialOutreachStage(BaseStage):
1193
1270
  self.logger.debug('Skipping prompt entry because it is not a dict: %s', entry)
1194
1271
  return None
1195
1272
 
1196
- email_body = entry.get('body') or entry.get('content') or ''
1197
- if isinstance(email_body, dict):
1198
- email_body = email_body.get('html') or email_body.get('text') or email_body.get('content') or ''
1199
- email_body = str(email_body).strip()
1200
- if not email_body:
1201
- self.logger.debug('Skipping prompt entry because email body is empty: %s', entry)
1202
- return None
1203
-
1204
- subject = entry.get('subject')
1205
- if isinstance(subject, list):
1206
- subject = subject[0] if subject else ''
1207
- subject = str(subject).strip() if subject else ''
1273
+ email_body = entry.get('body') or entry.get('content') or ''
1274
+ if isinstance(email_body, dict):
1275
+ email_body = email_body.get('html') or email_body.get('text') or email_body.get('content') or ''
1276
+ email_body = str(email_body).strip()
1277
+ if not email_body:
1278
+ self.logger.debug('Skipping prompt entry because email body is empty: %s', entry)
1279
+ return None
1280
+
1281
+ recipient_identity = self._resolve_recipient_identity(customer_data, context)
1282
+ if recipient_identity.get('first_name') and not context.get('customer_first_name'):
1283
+ context['customer_first_name'] = recipient_identity['first_name']
1284
+
1285
+ subject = entry.get('subject')
1286
+ if isinstance(subject, list):
1287
+ subject = subject[0] if subject else ''
1288
+ subject = str(subject).strip() if subject else ''
1208
1289
 
1209
1290
  subject_alternatives: List[str] = []
1210
1291
  for key in ('subject_alternatives', 'subject_variations', 'subject_variants', 'alternative_subjects', 'subjects'):
@@ -1250,17 +1331,22 @@ class InitialOutreachStage(BaseStage):
1250
1331
  message_type = entry.get('message_type') or 'Email'
1251
1332
  rep_profile = getattr(self, '_active_rep_profile', {}) or {}
1252
1333
  staff_name = context.get('input_data', {}).get('staff_name') or self.config.get('staff_name', 'Sales Team')
1253
- first_name = context.get('customer_first_name') or context.get('input_data', {}).get('customer_name') or ''
1334
+ first_name = recipient_identity.get('first_name') or context.get('customer_first_name') or context.get('input_data', {}).get('customer_name') or ''
1254
1335
  email_body = self._sanitize_email_body(email_body, staff_name, rep_profile, first_name)
1255
-
1256
- metadata = {
1257
- 'customer_company': customer_data.get('companyInfo', {}).get('name', 'Unknown'),
1258
- 'contact_name': customer_data.get('primaryContact', {}).get('name', 'Unknown'),
1259
- 'recommended_product': product_name or 'Unknown',
1260
- 'generation_method': 'prompt_template',
1261
- 'tags': tags,
1262
- 'message_type': message_type
1263
- }
1336
+ if '<html' not in email_body.lower():
1337
+ email_body = f"<html><body>{email_body}</body></html>"
1338
+
1339
+ metadata = {
1340
+ 'customer_company': customer_data.get('companyInfo', {}).get('name', 'Unknown'),
1341
+ 'contact_name': customer_data.get('primaryContact', {}).get('name', 'Unknown'),
1342
+ 'recipient_email': recipient_identity.get('email'),
1343
+ 'recipient_name': recipient_identity.get('full_name'),
1344
+ 'email_format': 'html',
1345
+ 'recommended_product': product_name or 'Unknown',
1346
+ 'generation_method': 'prompt_template',
1347
+ 'tags': tags,
1348
+ 'message_type': message_type
1349
+ }
1264
1350
 
1265
1351
  draft_id = entry.get('draft_id') or f"uuid:{str(uuid.uuid4())}"
1266
1352
  draft_approach = "prompt"
@@ -1270,15 +1356,19 @@ class InitialOutreachStage(BaseStage):
1270
1356
  'draft_id': draft_id,
1271
1357
  'approach': approach,
1272
1358
  'tone': mail_tone,
1273
- 'focus': focus,
1274
- 'subject': subject,
1275
- 'subject_alternatives': subject_alternatives,
1276
- 'email_body': email_body,
1277
- 'call_to_action': call_to_action,
1278
- 'product_mention': product_mention,
1279
- 'product_name': product_name,
1280
- 'priority_order': priority_order if priority_order is not None else 0,
1281
- 'personalization_score': personalization_score,
1359
+ 'focus': focus,
1360
+ 'subject': subject,
1361
+ 'subject_alternatives': subject_alternatives,
1362
+ 'email_body': email_body,
1363
+ 'email_format': 'html',
1364
+ 'recipient_email': recipient_identity.get('email'),
1365
+ 'recipient_name': recipient_identity.get('full_name'),
1366
+ 'customer_first_name': recipient_identity.get('first_name'),
1367
+ 'call_to_action': call_to_action,
1368
+ 'product_mention': product_mention,
1369
+ 'product_name': product_name,
1370
+ 'priority_order': priority_order if priority_order is not None else 0,
1371
+ 'personalization_score': personalization_score,
1282
1372
  'generated_at': datetime.now().isoformat(),
1283
1373
  'status': 'draft',
1284
1374
  'metadata': metadata
@@ -1332,7 +1422,7 @@ class InitialOutreachStage(BaseStage):
1332
1422
  )
1333
1423
 
1334
1424
  # Clean and validate the generated content
1335
- cleaned_content = self._clean_email_content(email_content)
1425
+ cleaned_content = self._clean_email_content(email_content, context)
1336
1426
 
1337
1427
  return cleaned_content
1338
1428
 
@@ -1443,35 +1533,74 @@ Generate 4 subject lines, one per line, no numbering or bullets:"""
1443
1533
  f"5-minute chat about {company_name}?"
1444
1534
  ]
1445
1535
 
1446
- def _clean_email_content(self, raw_content: str) -> str:
1447
- """Clean and validate generated email content."""
1448
- # Remove any unwanted prefixes or suffixes
1449
- content = raw_content.strip()
1450
-
1451
- # Remove common LLM artifacts
1452
- artifacts_to_remove = [
1453
- "Here's the email:",
1454
- "Here is the email:",
1455
- "Email content:",
1456
- "Generated email:",
1457
- "Subject:",
1458
- "Email:"
1459
- ]
1460
-
1461
- for artifact in artifacts_to_remove:
1462
- if content.startswith(artifact):
1463
- content = content[len(artifact):].strip()
1464
-
1465
- # Ensure proper email structure
1466
- if not content.startswith(('Dear', 'Hi', 'Hello', 'Greetings')):
1467
- # Add a greeting if missing
1468
- content = f"Dear Valued Customer,\n\n{content}"
1469
-
1470
- # Ensure proper closing
1471
- if not any(closing in content.lower() for closing in ['best regards', 'sincerely', 'best', 'thanks']):
1472
- content += "\n\nBest regards"
1473
-
1474
- return content
1536
+ def _clean_email_content(
1537
+ self,
1538
+ raw_content: str,
1539
+ context: Optional[Dict[str, Any]] = None
1540
+ ) -> str:
1541
+ """
1542
+ Clean and normalize generated email content, returning HTML.
1543
+ """
1544
+ content = (raw_content or "").replace("\r\n", "\n").strip()
1545
+
1546
+ artifacts_to_remove = (
1547
+ "Here's the email:",
1548
+ "Here is the email:",
1549
+ "Email content:",
1550
+ "Generated email:",
1551
+ "Subject:",
1552
+ "Email:"
1553
+ )
1554
+
1555
+ for artifact in artifacts_to_remove:
1556
+ if content.startswith(artifact):
1557
+ content = content[len(artifact):].strip()
1558
+
1559
+ lines = [line.strip() for line in content.split('\n') if line.strip()]
1560
+ content = '\n\n'.join(lines)
1561
+
1562
+ if not content:
1563
+ return "<html><body></body></html>"
1564
+
1565
+ if not content.lower().startswith(('dear ', 'hi ', 'hello ', 'greetings')):
1566
+ content = f"Dear Valued Customer,\n\n{content}"
1567
+
1568
+ closings = ('best regards', 'sincerely', 'thanks', 'thank you', 'kind regards')
1569
+ if not any(closing in content.lower() for closing in closings):
1570
+ content += "\n\nBest regards,\n[Your Name]"
1571
+
1572
+ ctx = context or {}
1573
+ input_data = ctx.get('input_data', {}) or {}
1574
+ rep_profile = getattr(self, '_active_rep_profile', {}) or {}
1575
+ staff_name = input_data.get('staff_name') or self.config.get('staff_name') or 'Sales Team'
1576
+ identity = ctx.get('_recipient_identity') or {}
1577
+ customer_first_name = (
1578
+ identity.get('first_name')
1579
+ or ctx.get('customer_first_name')
1580
+ or input_data.get('customer_first_name')
1581
+ or input_data.get('recipient_name')
1582
+ or input_data.get('customer_name')
1583
+ or ''
1584
+ )
1585
+
1586
+ sanitized = self._sanitize_email_body(content, staff_name, rep_profile, customer_first_name)
1587
+ if '<html' not in sanitized.lower():
1588
+ sanitized = f"<html><body>{sanitized}</body></html>"
1589
+
1590
+ return sanitized
1591
+
1592
+ def _ensure_html_email(self, raw_content: Any, context: Dict[str, Any]) -> str:
1593
+ """
1594
+ Normalize potentially plain-text content into HTML output.
1595
+ """
1596
+ if raw_content is None:
1597
+ return "<html><body></body></html>"
1598
+
1599
+ text = str(raw_content)
1600
+ if '<html' in text.lower():
1601
+ return text
1602
+
1603
+ return self._clean_email_content(text, context)
1475
1604
 
1476
1605
  def _extract_call_to_action(self, email_content: str) -> str:
1477
1606
  """Extract the main call-to-action from email content."""
@@ -1542,76 +1671,156 @@ Generate 4 subject lines, one per line, no numbering or bullets:"""
1542
1671
 
1543
1672
  return min(score, 100)
1544
1673
 
1545
- def _generate_template_email(self, customer_data: Dict[str, Any], recommended_product: Dict[str, Any],
1546
- approach: Dict[str, Any], context: Dict[str, Any]) -> str:
1547
- """Generate email using template as fallback."""
1548
- input_data = context.get('input_data', {})
1549
- company_info = customer_data.get('companyInfo', {})
1550
- contact_info = customer_data.get('primaryContact', {})
1551
-
1552
- return f"""Dear {contact_info.get('name', 'there')},
1553
-
1554
- 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')}.
1555
-
1556
- 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')}.
1557
-
1558
- 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?
1559
-
1560
- Best regards,
1561
- {input_data.get('staff_name', 'Sales Team')}
1562
- {input_data.get('org_name', 'Our Company')}"""
1674
+ def _generate_template_email(self, customer_data: Dict[str, Any], recommended_product: Dict[str, Any],
1675
+ approach: Dict[str, Any], context: Dict[str, Any]) -> str:
1676
+ """Generate a deterministic HTML email when LLM generation is unavailable."""
1677
+ input_data = context.get('input_data', {}) or {}
1678
+ company_info = customer_data.get('companyInfo', {}) or {}
1679
+ contact_info = customer_data.get('primaryContact', {}) or {}
1680
+ identity = context.get('_recipient_identity') or self._resolve_recipient_identity(customer_data, context)
1681
+
1682
+ first_name = identity.get('first_name') or contact_info.get('name') or input_data.get('customer_name') or input_data.get('recipient_name') or 'there'
1683
+ first_name = self._extract_first_name(first_name) if isinstance(first_name, str) else 'there'
1684
+
1685
+ staff_name = input_data.get('staff_name') or self.config.get('staff_name', 'Sales Team')
1686
+ org_name = input_data.get('org_name') or self.config.get('org_name', 'FuseSell')
1687
+ company_name = company_info.get('name', 'your company')
1688
+ industry = company_info.get('industry', 'your industry')
1689
+ approach_name = approach.get('name', 'professional_direct')
1690
+ approach_focus = approach.get('focus', 'business value')
1691
+ approach_tone = approach.get('tone', 'professional')
1692
+
1693
+ benefits: List[str] = []
1694
+ if recommended_product:
1695
+ product_name = recommended_product.get('product_name')
1696
+ benefits = [b for b in (recommended_product.get('key_benefits') or []) if b]
1697
+ if not benefits and product_name:
1698
+ benefits = [
1699
+ f"{product_name} accelerates {company_name}'s {approach_focus} goals",
1700
+ f"Designed specifically for {industry} operators",
1701
+ "Rapid onboarding with dedicated local support"
1702
+ ]
1703
+ if not benefits:
1704
+ benefits = [
1705
+ f"Measurable improvements in {approach_focus}",
1706
+ f"Playbooks tailored for {industry} teams",
1707
+ "Guided adoption with FuseSell specialists"
1708
+ ]
1709
+
1710
+ bullet_html = ''.join(f"<li>{benefit}</li>" for benefit in benefits)
1711
+
1712
+ cta_map = {
1713
+ 'professional_direct': f"Would you have 20 minutes this week to explore how FuseSell can lighten {company_name}'s {approach_focus} workload?",
1714
+ 'consultative': f"Could we schedule a short working session to dig into your current {approach_focus} priorities?",
1715
+ 'industry_expert': f"Shall we review the latest {industry} benchmarks together and map them to your roadmap?",
1716
+ 'relationship_building': f"I'd love to hear how your team is approaching {approach_focus}; is a quick virtual coffee an option?"
1717
+ }
1718
+ cta_text = cta_map.get(approach_name, f"Would you be open to a brief call to discuss {approach_focus} priorities at {company_name}?")
1719
+
1720
+ product_sentence = ""
1721
+ if recommended_product and recommended_product.get('product_name'):
1722
+ product_sentence = f"<p>We engineered <strong>{recommended_product['product_name']}</strong> specifically for teams tackling {approach_focus} in {industry}. It's a natural fit for {company_name}'s next phase.</p>"
1723
+
1724
+ news = company_info.get('recentNews')
1725
+ intro_sentence = f"<p>I'm reaching out because leaders at {company_name} are raising the same questions we hear from other {industry} innovators: how to keep {approach_focus} moving without burning out the team.</p>"
1726
+ if news:
1727
+ intro_sentence = f"<p>I noticed the recent update about {news}. Many {industry} peers use FuseSell to capitalise on moments exactly like this.</p>"
1728
+
1729
+ html = (
1730
+ "<html><body>"
1731
+ f"<p>Hi {first_name},</p>"
1732
+ f"{intro_sentence}"
1733
+ f"<p>From our {approach_tone.lower()} conversations with {industry} operators, three ideas could help {company_name} right away:</p>"
1734
+ f"<ul>{bullet_html}</ul>"
1735
+ f"{product_sentence}"
1736
+ f"<p>{cta_text}</p>"
1737
+ f"<p>Best regards,<br>{staff_name}<br>{org_name}</p>"
1738
+ "</body></html>"
1739
+ )
1740
+
1741
+ return html
1563
1742
 
1564
1743
  def _generate_fallback_draft(self, customer_data: Dict[str, Any], recommended_product: Dict[str, Any], context: Dict[str, Any]) -> List[Dict[str, Any]]:
1565
1744
  """Generate fallback draft when LLM generation fails."""
1566
- draft_id = f"uuid:{str(uuid.uuid4())}"
1567
- draft_approach = "fallback"
1568
- draft_type = "initial"
1569
-
1570
- return [{
1571
- 'draft_id': draft_id,
1572
- 'approach': 'fallback_template',
1573
- 'tone': 'professional',
1574
- 'focus': 'general outreach',
1575
- 'subject': self._generate_fallback_subject_lines(customer_data, recommended_product)[0],
1576
- 'subject_alternatives': self._generate_fallback_subject_lines(customer_data, recommended_product)[1:],
1577
- 'email_body': self._generate_template_email(customer_data, recommended_product, {'tone': 'professional'}, context),
1578
- 'call_to_action': 'Would you be interested in a brief call?',
1579
- 'personalization_score': 50,
1580
- 'generated_at': datetime.now().isoformat(),
1581
- 'status': 'draft',
1582
- 'metadata': {
1583
- 'generation_method': 'template_fallback',
1584
- 'note': 'Generated using template due to LLM failure'
1585
- }
1586
- }]
1745
+ draft_id = f"uuid:{str(uuid.uuid4())}"
1746
+ recipient_identity = self._resolve_recipient_identity(customer_data, context)
1747
+ context.setdefault('_recipient_identity', recipient_identity)
1748
+ if recipient_identity.get('first_name') and not context.get('customer_first_name'):
1749
+ context['customer_first_name'] = recipient_identity['first_name']
1750
+
1751
+ template_body = self._generate_template_email(
1752
+ customer_data,
1753
+ recommended_product,
1754
+ {'tone': 'professional'},
1755
+ context
1756
+ )
1757
+ email_body = self._clean_email_content(template_body, context)
1758
+
1759
+ fallback_subjects = self._generate_fallback_subject_lines(customer_data, recommended_product)
1760
+
1761
+ return [{
1762
+ 'draft_id': draft_id,
1763
+ 'approach': 'fallback_template',
1764
+ 'tone': 'professional',
1765
+ 'focus': 'general outreach',
1766
+ 'subject': fallback_subjects[0],
1767
+ 'subject_alternatives': fallback_subjects[1:],
1768
+ 'email_body': email_body,
1769
+ 'email_format': 'html',
1770
+ 'recipient_email': recipient_identity.get('email'),
1771
+ 'recipient_name': recipient_identity.get('full_name'),
1772
+ 'customer_first_name': recipient_identity.get('first_name'),
1773
+ 'call_to_action': 'Would you be interested in a brief call?',
1774
+ 'personalization_score': 50,
1775
+ 'generated_at': datetime.now().isoformat(),
1776
+ 'status': 'draft',
1777
+ 'metadata': {
1778
+ 'generation_method': 'template_fallback',
1779
+ 'note': 'Generated using template due to LLM failure',
1780
+ 'recipient_email': recipient_identity.get('email'),
1781
+ 'recipient_name': recipient_identity.get('full_name'),
1782
+ 'email_format': 'html'
1783
+ }
1784
+ }]
1587
1785
 
1588
1786
  def _get_mock_email_drafts(self, customer_data: Dict[str, Any], recommended_product: Dict[str, Any], context: Dict[str, Any]) -> List[Dict[str, Any]]:
1589
1787
  """Get mock email drafts for dry run."""
1590
1788
  input_data = context.get('input_data', {})
1591
1789
  company_info = customer_data.get('companyInfo', {})
1592
1790
 
1593
- return [{
1594
- 'draft_id': 'mock_draft_001',
1595
- 'approach': 'professional_direct',
1596
- 'tone': 'professional and direct',
1597
- 'focus': 'business value and ROI',
1598
- 'subject': f"Partnership Opportunity for {company_info.get('name', 'Test Company')}",
1599
- 'subject_alternatives': [
1600
- f"Quick Question About {company_info.get('name', 'Test Company')}",
1601
- f"Helping Companies Like {company_info.get('name', 'Test Company')}"
1602
- ],
1603
- 'email_body': f"""[DRY RUN] Mock email content for {company_info.get('name', 'Test Company')}
1604
-
1605
- 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.""",
1606
- 'call_to_action': 'Mock call to action',
1607
- 'personalization_score': 85,
1608
- 'generated_at': datetime.now().isoformat(),
1609
- 'status': 'mock',
1610
- 'metadata': {
1611
- 'generation_method': 'mock_data',
1612
- 'note': 'This is mock data for dry run testing'
1613
- }
1614
- }]
1791
+ recipient_identity = self._resolve_recipient_identity(customer_data, context)
1792
+ context.setdefault('_recipient_identity', recipient_identity)
1793
+ mock_body = f"""[DRY RUN] Mock email content for {company_info.get('name', 'Test Company')}
1794
+
1795
+ 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."""
1796
+
1797
+ return [{
1798
+ 'draft_id': 'mock_draft_001',
1799
+ 'approach': 'professional_direct',
1800
+ 'tone': 'professional and direct',
1801
+ 'focus': 'business value and ROI',
1802
+ 'subject': f"Partnership Opportunity for {company_info.get('name', 'Test Company')}",
1803
+ 'subject_alternatives': [
1804
+ f"Quick Question About {company_info.get('name', 'Test Company')}",
1805
+ f"Helping Companies Like {company_info.get('name', 'Test Company')}"
1806
+ ],
1807
+ 'email_body': self._clean_email_content(mock_body, context),
1808
+ 'email_format': 'html',
1809
+ 'recipient_email': recipient_identity.get('email'),
1810
+ 'recipient_name': recipient_identity.get('full_name'),
1811
+ 'customer_first_name': recipient_identity.get('first_name'),
1812
+ 'call_to_action': 'Mock call to action',
1813
+ 'personalization_score': 85,
1814
+ 'generated_at': datetime.now().isoformat(),
1815
+ 'status': 'mock',
1816
+ 'metadata': {
1817
+ 'generation_method': 'mock_data',
1818
+ 'note': 'This is mock data for dry run testing',
1819
+ 'recipient_email': recipient_identity.get('email'),
1820
+ 'recipient_name': recipient_identity.get('full_name'),
1821
+ 'email_format': 'html'
1822
+ }
1823
+ }]
1615
1824
 
1616
1825
 
1617
1826
  def _convert_draft_to_server_format(self, draft: Dict[str, Any], context: Dict[str, Any]) -> Dict[str, Any]:
@@ -1950,16 +2159,17 @@ This is a mock email that would be generated for testing purposes. In a real exe
1950
2159
 
1951
2160
  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]:
1952
2161
  """Rewrite existing draft based on reason using LLM."""
1953
- try:
1954
- if self.is_dry_run():
1955
- rewritten = existing_draft.copy()
1956
- rewritten['draft_id'] = f"uuid:{str(uuid.uuid4())}"
1957
- rewritten['draft_approach'] = "rewrite"
1958
- rewritten['draft_type'] = "rewrite"
1959
- rewritten['email_body'] = f"[DRY RUN - REWRITTEN: {reason}] " + existing_draft.get('email_body', '')
1960
- rewritten['rewrite_reason'] = reason
1961
- rewritten['rewritten_at'] = datetime.now().isoformat()
1962
- return rewritten
2162
+ try:
2163
+ if self.is_dry_run():
2164
+ rewritten = existing_draft.copy()
2165
+ rewritten['draft_id'] = f"uuid:{str(uuid.uuid4())}"
2166
+ rewritten['draft_approach'] = "rewrite"
2167
+ rewritten['draft_type'] = "rewrite"
2168
+ rewritten['email_body'] = f"[DRY RUN - REWRITTEN: {reason}] " + existing_draft.get('email_body', '')
2169
+ rewritten['rewrite_reason'] = reason
2170
+ rewritten['rewritten_at'] = datetime.now().isoformat()
2171
+ rewritten.setdefault('email_format', 'html')
2172
+ return rewritten
1963
2173
 
1964
2174
  input_data = context.get('input_data', {})
1965
2175
  company_info = customer_data.get('companyInfo', {})
@@ -1997,46 +2207,66 @@ Generate only the rewritten email content:"""
1997
2207
  )
1998
2208
 
1999
2209
  # Clean the rewritten content
2000
- cleaned_content = self._clean_email_content(rewritten_content)
2001
-
2002
- # Create rewritten draft object
2003
- rewritten = existing_draft.copy()
2004
- rewritten['draft_id'] = f"uuid:{str(uuid.uuid4())}"
2005
- rewritten['draft_approach'] = "rewrite"
2006
- rewritten['draft_type'] = "rewrite"
2007
- rewritten['email_body'] = cleaned_content
2008
- rewritten['rewrite_reason'] = reason
2009
- rewritten['rewritten_at'] = datetime.now().isoformat()
2010
- rewritten['version'] = existing_draft.get('version', 1) + 1
2011
- rewritten['call_to_action'] = self._extract_call_to_action(cleaned_content)
2012
- rewritten['personalization_score'] = self._calculate_personalization_score(cleaned_content, customer_data)
2210
+ cleaned_content = self._clean_email_content(rewritten_content, context)
2211
+
2212
+ # Create rewritten draft object
2213
+ rewritten = existing_draft.copy()
2214
+ rewritten['draft_id'] = f"uuid:{str(uuid.uuid4())}"
2215
+ rewritten['draft_approach'] = "rewrite"
2216
+ rewritten['draft_type'] = "rewrite"
2217
+ rewritten['email_body'] = cleaned_content
2218
+ rewritten['rewrite_reason'] = reason
2219
+ rewritten['rewritten_at'] = datetime.now().isoformat()
2220
+ rewritten['version'] = existing_draft.get('version', 1) + 1
2221
+ rewritten['call_to_action'] = self._extract_call_to_action(cleaned_content)
2222
+ rewritten['personalization_score'] = self._calculate_personalization_score(cleaned_content, customer_data)
2223
+ recipient_identity = context.get('_recipient_identity') or self._resolve_recipient_identity(customer_data, context)
2224
+ if recipient_identity:
2225
+ rewritten['recipient_email'] = recipient_identity.get('email')
2226
+ rewritten['recipient_name'] = recipient_identity.get('full_name')
2227
+ rewritten['customer_first_name'] = recipient_identity.get('first_name')
2228
+ rewritten['email_format'] = 'html'
2013
2229
 
2014
2230
  # Update metadata
2015
2231
  if 'metadata' not in rewritten:
2016
2232
  rewritten['metadata'] = {}
2017
2233
  rewritten['metadata']['rewrite_history'] = rewritten['metadata'].get('rewrite_history', [])
2018
- rewritten['metadata']['rewrite_history'].append({
2019
- 'reason': reason,
2020
- 'rewritten_at': datetime.now().isoformat(),
2021
- 'original_draft_id': existing_draft.get('draft_id')
2022
- })
2023
- rewritten['metadata']['generation_method'] = 'llm_rewrite'
2234
+ rewritten['metadata']['rewrite_history'].append({
2235
+ 'reason': reason,
2236
+ 'rewritten_at': datetime.now().isoformat(),
2237
+ 'original_draft_id': existing_draft.get('draft_id')
2238
+ })
2239
+ rewritten['metadata']['generation_method'] = 'llm_rewrite'
2240
+ if recipient_identity:
2241
+ rewritten['metadata']['recipient_email'] = recipient_identity.get('email')
2242
+ rewritten['metadata']['recipient_name'] = recipient_identity.get('full_name')
2243
+ rewritten['metadata']['email_format'] = 'html'
2024
2244
 
2025
2245
  self.logger.info(f"Successfully rewrote draft based on reason: {reason}")
2026
2246
  return rewritten
2027
2247
 
2028
- except Exception as e:
2029
- self.logger.error(f"Failed to rewrite draft: {str(e)}")
2030
- # Fallback to simple modification
2031
- rewritten = existing_draft.copy()
2032
- rewritten['draft_id'] = f"uuid:{str(uuid.uuid4())}"
2033
- rewritten['draft_approach'] = "rewrite"
2034
- rewritten['draft_type'] = "rewrite"
2035
- rewritten['email_body'] = f"[REWRITTEN: {reason}]\n\n" + existing_draft.get('email_body', '')
2036
- rewritten['rewrite_reason'] = reason
2037
- rewritten['rewritten_at'] = datetime.now().isoformat()
2038
- rewritten['metadata'] = {'generation_method': 'template_rewrite', 'error': str(e)}
2039
- return rewritten
2248
+ except Exception as e:
2249
+ self.logger.error(f"Failed to rewrite draft: {str(e)}")
2250
+ # Fallback to simple modification
2251
+ rewritten = existing_draft.copy()
2252
+ rewritten['draft_id'] = f"uuid:{str(uuid.uuid4())}"
2253
+ rewritten['draft_approach'] = "rewrite"
2254
+ rewritten['draft_type'] = "rewrite"
2255
+ rewritten['email_body'] = f"[REWRITTEN: {reason}]\n\n" + existing_draft.get('email_body', '')
2256
+ rewritten['rewrite_reason'] = reason
2257
+ rewritten['rewritten_at'] = datetime.now().isoformat()
2258
+ rewritten['metadata'] = {'generation_method': 'template_rewrite', 'error': str(e)}
2259
+ rewritten['email_body'] = self._clean_email_content(rewritten['email_body'], context)
2260
+ fallback_identity = context.get('_recipient_identity') or self._resolve_recipient_identity(customer_data, context)
2261
+ if fallback_identity:
2262
+ rewritten['recipient_email'] = fallback_identity.get('email')
2263
+ rewritten['recipient_name'] = fallback_identity.get('full_name')
2264
+ rewritten['customer_first_name'] = fallback_identity.get('first_name')
2265
+ rewritten['metadata']['recipient_email'] = fallback_identity.get('email')
2266
+ rewritten['metadata']['recipient_name'] = fallback_identity.get('full_name')
2267
+ rewritten['metadata']['email_format'] = 'html'
2268
+ rewritten['email_format'] = 'html'
2269
+ return rewritten
2040
2270
 
2041
2271
  def _save_rewritten_draft(self, context: Dict[str, Any], rewritten_draft: Dict[str, Any], original_draft_id: str) -> Dict[str, Any]:
2042
2272
  """Save rewritten draft to database and file system."""
@@ -552,6 +552,7 @@ class LocalDataManager:
552
552
  status TEXT NOT NULL,
553
553
  task TEXT NOT NULL,
554
554
  cron TEXT NOT NULL,
555
+ cron_ts INTEGER,
555
556
  room_id TEXT,
556
557
  tags TEXT,
557
558
  customextra TEXT,
@@ -567,6 +568,11 @@ class LocalDataManager:
567
568
  )
568
569
  """)
569
570
 
571
+ cursor.execute("PRAGMA table_info(reminder_task)")
572
+ reminder_columns = {row[1] for row in cursor.fetchall()}
573
+ if 'cron_ts' not in reminder_columns:
574
+ cursor.execute("ALTER TABLE reminder_task ADD COLUMN cron_ts INTEGER")
575
+
570
576
  # Create extracted_files table (equivalent to gs_plan_setting_extracted_file)
571
577
  cursor.execute("""
572
578
  CREATE TABLE IF NOT EXISTS extracted_files (
@@ -684,6 +690,8 @@ class LocalDataManager:
684
690
  "CREATE INDEX IF NOT EXISTS idx_reminder_task_task_id ON reminder_task(task_id)")
685
691
  cursor.execute(
686
692
  "CREATE INDEX IF NOT EXISTS idx_reminder_task_cron ON reminder_task(cron)")
693
+ cursor.execute(
694
+ "CREATE INDEX IF NOT EXISTS idx_reminder_task_cron_ts ON reminder_task(cron_ts)")
687
695
  cursor.execute(
688
696
  "CREATE INDEX IF NOT EXISTS idx_extracted_files_org_id ON extracted_files(org_id)")
689
697
  cursor.execute(
@@ -4,7 +4,7 @@ Creates scheduled events in database for external app to handle
4
4
  """
5
5
 
6
6
  import logging
7
- from datetime import datetime, timedelta
7
+ from datetime import datetime, timedelta, timezone
8
8
  from typing import Dict, Any, Optional, List, Union
9
9
  import pytz
10
10
  import json