fusesell 1.2.8__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.8
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.8.dist-info/licenses/LICENSE,sha256=GDz1ZoC4lB0kwjERpzqc_OdA_awYVso2aBnUH-ErW_w,1070
3
- fusesell_local/__init__.py,sha256=ORlqpW5kTRov-Xw9f3Bk0L7wy1Q5bxTdaZAT9fIhLcQ,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=0JJMRtfvy1F83Z5anLm8b0634q4JXoFi__IxginPURw,120643
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.8.dist-info/METADATA,sha256=9r91NKwHYUIJ-FiYGr7JoNsWe5dDLMd0JO6Nf8MlXW0,35074
32
- fusesell-1.2.8.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
33
- fusesell-1.2.8.dist-info/entry_points.txt,sha256=Vqek7tbiX7iF4rQkCRBZvT5WrB0HUduqKTsI2ZjhsXo,53
34
- fusesell-1.2.8.dist-info/top_level.txt,sha256=VP9y1K6DEq6gNq2UgLd7ChujxViF6OzeAVCK7IUBXPA,24
35
- fusesell-1.2.8.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.8"
35
+ __version__ = "1.2.9"
36
36
  __author__ = "FuseSell Team"
37
37
  __description__ = "Local implementation of FuseSell AI sales automation pipeline"
@@ -904,6 +904,7 @@ class InitialOutreachStage(BaseStage):
904
904
  first_name = name_parts[0]
905
905
  else:
906
906
  first_name = contact_name or ''
907
+ context.setdefault('customer_first_name', first_name or contact_name or '')
907
908
 
908
909
  action = input_data.get('action', 'draft_write')
909
910
  action_labels = {
@@ -1044,7 +1045,7 @@ class InitialOutreachStage(BaseStage):
1044
1045
  return rep
1045
1046
  return reps[0] if reps else {}
1046
1047
 
1047
- def _sanitize_email_body(self, html: str, staff_name: str, rep_profile: Dict[str, Any]) -> str:
1048
+ def _sanitize_email_body(self, html: str, staff_name: str, rep_profile: Dict[str, Any], customer_first_name: str) -> str:
1048
1049
  if not html:
1049
1050
  return ''
1050
1051
 
@@ -1065,12 +1066,84 @@ class InitialOutreachStage(BaseStage):
1065
1066
  else:
1066
1067
  html = html.replace(placeholder, '')
1067
1068
 
1068
- # Remove any lingering placeholder fragments such as "[Your LinkedIn Profile"
1069
1069
  html = re.sub(r'\[Your[^<\]]+\]?', '', html, flags=re.IGNORECASE)
1070
- # Collapse empty paragraphs created by placeholder removal
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 '')
1071
1077
  html = re.sub(r'(<p>\s*</p>)+', '', html, flags=re.IGNORECASE)
1072
1078
  return html
1073
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
+
1074
1147
  def _extract_first_name(self, full_name: str) -> str:
1075
1148
  if not full_name:
1076
1149
  return ''
@@ -1177,7 +1250,8 @@ class InitialOutreachStage(BaseStage):
1177
1250
  message_type = entry.get('message_type') or 'Email'
1178
1251
  rep_profile = getattr(self, '_active_rep_profile', {}) or {}
1179
1252
  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)
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)
1181
1255
 
1182
1256
  metadata = {
1183
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 []