fusesell 1.2.7__py3-none-any.whl → 1.2.9__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.7
3
+ Version: 1.2.9
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.7.dist-info/licenses/LICENSE,sha256=GDz1ZoC4lB0kwjERpzqc_OdA_awYVso2aBnUH-ErW_w,1070
3
- fusesell_local/__init__.py,sha256=8U1jX2uHDz_7rJeLg7NGKJGJum7I8VK-RwZCtXxRVrs,966
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
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=U6KHZIpr7d-W3x65z9XVm0gKL88SaVR47-_cQq3f-dc,117170
15
+ fusesell_local/stages/initial_outreach.py,sha256=yoXAVaPgQXZc3bMq4U363Z4ARTsnSzOqbodKB3tke3A,123593
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
@@ -23,13 +23,13 @@ fusesell_local/tests/test_data_manager_teams.py,sha256=kjk4V4r9ja4EVREIiQMxkuZd4
23
23
  fusesell_local/utils/__init__.py,sha256=TVemlo0wpckhNUxP3a1Tky3ekswy8JdIHaXBlkKXKBQ,330
24
24
  fusesell_local/utils/birthday_email_manager.py,sha256=NKLoUyzPedyhewZPma21SOoU8p9wPquehloer7TRA9U,20478
25
25
  fusesell_local/utils/data_manager.py,sha256=60CLOVkVB76AQx1wQyja0PFmA1t-YJITiGNni14IPOs,188449
26
- fusesell_local/utils/event_scheduler.py,sha256=YwWIdkvRdWFdDLX-sepI5AXJOhEIullIclpk9njvZAA,38577
26
+ fusesell_local/utils/event_scheduler.py,sha256=tP-rnx9Hixfcm6ZTqloLy_EgSPII89v5dSycRHrCTLE,39824
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.7.dist-info/METADATA,sha256=uHVe8upgxtCHgruhMvflhXpBfUpi5DD2eeDR4g9JPU8,35074
32
- fusesell-1.2.7.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
33
- fusesell-1.2.7.dist-info/entry_points.txt,sha256=Vqek7tbiX7iF4rQkCRBZvT5WrB0HUduqKTsI2ZjhsXo,53
34
- fusesell-1.2.7.dist-info/top_level.txt,sha256=VP9y1K6DEq6gNq2UgLd7ChujxViF6OzeAVCK7IUBXPA,24
35
- fusesell-1.2.7.dist-info/RECORD,,
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,,
@@ -32,6 +32,6 @@ __all__ = [
32
32
  "validate_config",
33
33
  ]
34
34
 
35
- __version__ = "1.2.7"
35
+ __version__ = "1.2.9"
36
36
  __author__ = "FuseSell Team"
37
37
  __description__ = "Local implementation of FuseSell AI sales automation pipeline"
@@ -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
 
@@ -624,16 +640,31 @@ class InitialOutreachStage(BaseStage):
624
640
  self.logger.error(f"Failed to get auto interaction config for team {team_id}: {str(e)}")
625
641
  return default_config
626
642
 
627
- 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]]:
628
- """Generate multiple personalized email drafts using LLM."""
629
- if self.is_dry_run():
630
- return self._get_mock_email_drafts(customer_data, recommended_product, context)
631
-
632
- try:
633
- input_data = context.get('input_data', {})
634
- company_info = customer_data.get('companyInfo', {})
635
- contact_info = customer_data.get('primaryContact', {})
636
- 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', [])
637
668
 
638
669
  prompt_drafts = self._generate_email_drafts_from_prompt(
639
670
  customer_data,
@@ -873,6 +904,7 @@ class InitialOutreachStage(BaseStage):
873
904
  first_name = name_parts[0]
874
905
  else:
875
906
  first_name = contact_name or ''
907
+ context.setdefault('customer_first_name', first_name or contact_name or '')
876
908
 
877
909
  action = input_data.get('action', 'draft_write')
878
910
  action_labels = {
@@ -1000,12 +1032,123 @@ class InitialOutreachStage(BaseStage):
1000
1032
  f'Start with "Hi {en_name}," or "Hello {en_name}," and do not use the surname or placeholders.'
1001
1033
  )
1002
1034
  return 'If the recipient name is unknown, use a neutral greeting like "Hi there," without placeholders.'
1003
-
1004
- def _extract_first_name(self, full_name: str) -> str:
1005
- if not full_name:
1006
- return ''
1007
- parts = full_name.strip().split()
1008
- return parts[-1] if parts else full_name
1035
+
1036
+ def _resolve_primary_sales_rep(self, context: Dict[str, Any]) -> Dict[str, Any]:
1037
+ team_id = context.get('input_data', {}).get('team_id') or self.config.get('team_id')
1038
+ if not team_id:
1039
+ return {}
1040
+ reps = self.get_team_setting('gs_team_rep', team_id, [])
1041
+ if not isinstance(reps, list):
1042
+ return {}
1043
+ for rep in reps:
1044
+ if rep and rep.get('is_primary'):
1045
+ return rep
1046
+ return reps[0] if reps else {}
1047
+
1048
+ def _sanitize_email_body(self, html: str, staff_name: str, rep_profile: Dict[str, Any], customer_first_name: str) -> str:
1049
+ if not html:
1050
+ return ''
1051
+
1052
+ replacements = {
1053
+ '[Your Name]': rep_profile.get('name') or staff_name,
1054
+ '[Your Email]': rep_profile.get('email'),
1055
+ '[Your Phone Number]': rep_profile.get('phone') or rep_profile.get('primary_phone'),
1056
+ '[Your Phone]': rep_profile.get('phone') or rep_profile.get('primary_phone'),
1057
+ '[Your Title]': rep_profile.get('position'),
1058
+ '[Your LinkedIn Profile]': rep_profile.get('linkedin') or rep_profile.get('linkedin_profile'),
1059
+ '[Your LinkedIn Profile URL]': rep_profile.get('linkedin') or rep_profile.get('linkedin_profile'),
1060
+ '[Your Website]': rep_profile.get('website'),
1061
+ }
1062
+
1063
+ for placeholder, value in replacements.items():
1064
+ if value:
1065
+ html = html.replace(placeholder, str(value))
1066
+ else:
1067
+ html = html.replace(placeholder, '')
1068
+
1069
+ html = re.sub(r'\[Your[^<\]]+\]?', '', html, flags=re.IGNORECASE)
1070
+
1071
+ if '<p' not in html.lower():
1072
+ lines = [line.strip() for line in html.splitlines() if line.strip()]
1073
+ if lines:
1074
+ html = ''.join(f'<p>{line}</p>' for line in lines)
1075
+
1076
+ html = self._deduplicate_greeting(html, customer_first_name or '')
1077
+ html = re.sub(r'(<p>\s*</p>)+', '', html, flags=re.IGNORECASE)
1078
+ return html
1079
+
1080
+ def _deduplicate_greeting(self, html: str, customer_first_name: str) -> str:
1081
+ paragraphs = re.findall(r'(<p.*?>.*?</p>)', html, flags=re.IGNORECASE | re.DOTALL)
1082
+ if not paragraphs:
1083
+ return html
1084
+
1085
+ greeting_seen = False
1086
+ cleaned: List[str] = []
1087
+ for para in paragraphs:
1088
+ text = self._strip_html_tags(para).strip()
1089
+ normalized_para = para
1090
+ if self._looks_like_greeting(text):
1091
+ normalized_para = self._standardize_greeting_paragraph(para, customer_first_name)
1092
+ if greeting_seen:
1093
+ continue
1094
+ greeting_seen = True
1095
+ cleaned.append(normalized_para)
1096
+
1097
+ remainder = re.sub(r'(<p.*?>.*?</p>)', '__PARA__', html, flags=re.IGNORECASE | re.DOTALL)
1098
+ rebuilt = ''
1099
+ idx = 0
1100
+ for segment in remainder.split('__PARA__'):
1101
+ rebuilt += segment
1102
+ if idx < len(cleaned):
1103
+ rebuilt += cleaned[idx]
1104
+ idx += 1
1105
+ if idx < len(cleaned):
1106
+ rebuilt += ''.join(cleaned[idx:])
1107
+ return rebuilt
1108
+
1109
+ def _looks_like_greeting(self, text: str) -> bool:
1110
+ lowered = text.lower().replace('\xa0', ' ').strip()
1111
+ return lowered.startswith(('hi ', 'hello ', 'dear '))
1112
+
1113
+ def _standardize_greeting_paragraph(self, paragraph_html: str, customer_first_name: str) -> str:
1114
+ text = self._strip_html_tags(paragraph_html).strip()
1115
+ lowered = text.lower()
1116
+ first_word = next((candidate.title() for candidate in ('dear', 'hello', 'hi') if lowered.startswith(candidate)), 'Hi')
1117
+
1118
+ if customer_first_name:
1119
+ greeting = f"{first_word} {customer_first_name},"
1120
+ else:
1121
+ greeting = f"{first_word} there,"
1122
+
1123
+ remainder = ''
1124
+ match = re.match(r' *(hi|hello|dear)\b[^,]*,(.*)', text, flags=re.IGNORECASE | re.DOTALL)
1125
+ if match:
1126
+ remainder = match.group(2).lstrip()
1127
+ elif lowered.startswith(('hi', 'hello', 'dear')):
1128
+ parts = text.split(',', 1)
1129
+ if len(parts) > 1:
1130
+ remainder = parts[1].lstrip()
1131
+ else:
1132
+ remainder = text[len(text.split(' ', 1)[0]):].lstrip()
1133
+
1134
+ if remainder:
1135
+ sanitized_text = f"{greeting} {remainder}".strip()
1136
+ else:
1137
+ sanitized_text = greeting
1138
+
1139
+ return re.sub(
1140
+ r'(<p.*?>).*?(</p>)',
1141
+ lambda m: f"{m.group(1)}{sanitized_text}{m.group(2)}",
1142
+ paragraph_html,
1143
+ count=1,
1144
+ flags=re.IGNORECASE | re.DOTALL
1145
+ )
1146
+
1147
+ def _extract_first_name(self, full_name: str) -> str:
1148
+ if not full_name:
1149
+ return ''
1150
+ parts = full_name.strip().split()
1151
+ return parts[-1] if parts else full_name
1009
1152
 
1010
1153
  def _strip_code_fences(self, text: str) -> str:
1011
1154
  if not text:
@@ -1102,9 +1245,13 @@ class InitialOutreachStage(BaseStage):
1102
1245
  tags = [tags]
1103
1246
  tags = [str(tag).strip() for tag in tags if str(tag).strip()]
1104
1247
 
1105
- call_to_action = self._extract_call_to_action(email_body)
1106
- personalization_score = self._calculate_personalization_score(email_body, customer_data)
1107
- message_type = entry.get('message_type') or 'Email'
1248
+ call_to_action = self._extract_call_to_action(email_body)
1249
+ personalization_score = self._calculate_personalization_score(email_body, customer_data)
1250
+ message_type = entry.get('message_type') or 'Email'
1251
+ rep_profile = getattr(self, '_active_rep_profile', {}) or {}
1252
+ 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 ''
1254
+ email_body = self._sanitize_email_body(email_body, staff_name, rep_profile, first_name)
1108
1255
 
1109
1256
  metadata = {
1110
1257
  'customer_company': customer_data.get('companyInfo', {}).get('name', 'Unknown'),
@@ -84,6 +84,7 @@ class EventScheduler:
84
84
  status TEXT NOT NULL,
85
85
  task TEXT NOT NULL,
86
86
  cron TEXT NOT NULL,
87
+ cron_ts INTEGER,
87
88
  room_id TEXT,
88
89
  tags TEXT,
89
90
  customextra TEXT,
@@ -115,6 +116,11 @@ class EventScheduler:
115
116
  CREATE INDEX IF NOT EXISTS idx_reminder_task_cron
116
117
  ON reminder_task(cron)
117
118
  """)
119
+
120
+ cursor.execute("PRAGMA table_info(reminder_task)")
121
+ columns = {row[1] for row in cursor.fetchall()}
122
+ if 'cron_ts' not in columns:
123
+ cursor.execute("ALTER TABLE reminder_task ADD COLUMN cron_ts INTEGER")
118
124
 
119
125
  conn.commit()
120
126
  conn.close()
@@ -191,6 +197,19 @@ class EventScheduler:
191
197
  except ValueError:
192
198
  return value_str
193
199
 
200
+ def _to_unix_timestamp(self, value: Union[str, datetime, None]) -> Optional[int]:
201
+ """
202
+ Convert a datetime-like value to a Unix timestamp (seconds).
203
+ """
204
+ iso_value = self._format_datetime(value)
205
+ try:
206
+ parsed = datetime.fromisoformat(iso_value)
207
+ except ValueError:
208
+ return None
209
+ if parsed.tzinfo is None:
210
+ parsed = parsed.replace(tzinfo=timezone.utc)
211
+ return int(parsed.timestamp())
212
+
194
213
  def _build_reminder_payload(
195
214
  self,
196
215
  base_context: Dict[str, Any],
@@ -296,11 +315,13 @@ class EventScheduler:
296
315
 
297
316
  cron_value = self._format_datetime(cron_value or send_time)
298
317
  scheduled_time_str = self._format_datetime(scheduled_time_value or send_time)
318
+ cron_ts = self._to_unix_timestamp(cron_value)
299
319
 
300
320
  return {
301
321
  'status': status,
302
322
  'task': task_label,
303
323
  'cron': cron_value,
324
+ 'cron_ts': cron_ts,
304
325
  'room_id': room_id,
305
326
  'tags': tags,
306
327
  'customextra': customextra,
@@ -346,15 +367,20 @@ class EventScheduler:
346
367
  conn = sqlite3.connect(self.main_db_path)
347
368
  cursor = conn.cursor()
348
369
 
370
+ cron_ts = payload.get('cron_ts')
371
+ if cron_ts is None:
372
+ cron_ts = self._to_unix_timestamp(payload.get('cron'))
373
+
349
374
  cursor.execute("""
350
375
  INSERT INTO reminder_task
351
- (id, status, task, cron, room_id, tags, customextra, org_id, customer_id, task_id, import_uuid, scheduled_time)
352
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
376
+ (id, status, task, cron, cron_ts, room_id, tags, customextra, org_id, customer_id, task_id, import_uuid, scheduled_time)
377
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
353
378
  """, (
354
379
  reminder_id,
355
380
  payload.get('status', 'published'),
356
381
  payload.get('task') or 'FuseSell Reminder',
357
382
  self._format_datetime(payload.get('cron')),
383
+ cron_ts,
358
384
  payload.get('room_id'),
359
385
  tags_str,
360
386
  customextra_str,
@@ -455,6 +481,7 @@ class EventScheduler:
455
481
  draft_id=draft_id,
456
482
  customer_timezone=customer_timezone
457
483
  )
484
+ reminder_payload.setdefault('cron_ts', self._to_unix_timestamp(reminder_payload.get('cron')))
458
485
  reminder_task_id = self._insert_reminder_task(reminder_payload)
459
486
 
460
487
  # Log the scheduling
@@ -585,6 +612,7 @@ class EventScheduler:
585
612
  draft_id=original_draft_id,
586
613
  customer_timezone=event_data['customer_timezone']
587
614
  )
615
+ reminder_payload.setdefault('cron_ts', self._to_unix_timestamp(reminder_payload.get('cron')))
588
616
  reminder_task_id = self._insert_reminder_task(reminder_payload)
589
617
 
590
618
  self.logger.info(f"Scheduled follow-up event {followup_event_id} for {follow_up_time}")
@@ -834,18 +862,18 @@ class EventScheduler:
834
862
 
835
863
  # Convert to list of dictionaries
836
864
  events = []
837
- for row in rows:
838
- event = dict(zip(columns, row))
839
- # Parse event_data JSON
840
- if event['event_data']:
841
- try:
842
- event['event_data'] = json.loads(event['event_data'])
843
- except json.JSONDecodeError:
844
- pass
845
- events.append(event)
846
-
847
- return events
848
-
865
+ for row in rows:
866
+ event = dict(zip(columns, row))
867
+ # Parse event_data JSON
868
+ if event['event_data']:
869
+ try:
870
+ event['event_data'] = json.loads(event['event_data'])
871
+ except json.JSONDecodeError:
872
+ pass
873
+ events.append(event)
874
+
875
+ return events
876
+
849
877
  except Exception as e:
850
878
  self.logger.error(f"Failed to get scheduled events: {str(e)}")
851
879
  return []