fusesell 1.2.8__py3-none-any.whl → 1.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of fusesell might be problematic. Click here for more details.
- {fusesell-1.2.8.dist-info → fusesell-1.3.0.dist-info}/METADATA +1 -1
- {fusesell-1.2.8.dist-info → fusesell-1.3.0.dist-info}/RECORD +10 -10
- fusesell_local/__init__.py +1 -1
- fusesell_local/stages/initial_outreach.py +427 -187
- fusesell_local/utils/data_manager.py +8 -0
- fusesell_local/utils/event_scheduler.py +43 -15
- {fusesell-1.2.8.dist-info → fusesell-1.3.0.dist-info}/WHEEL +0 -0
- {fusesell-1.2.8.dist-info → fusesell-1.3.0.dist-info}/entry_points.txt +0 -0
- {fusesell-1.2.8.dist-info → fusesell-1.3.0.dist-info}/licenses/LICENSE +0 -0
- {fusesell-1.2.8.dist-info → fusesell-1.3.0.dist-info}/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
fusesell.py,sha256=t5PjkhWEJGINp4k517u0EX0ge7lzuHOUHHro-BE1mGk,596
|
|
2
|
-
fusesell-1.
|
|
3
|
-
fusesell_local/__init__.py,sha256=
|
|
2
|
+
fusesell-1.3.0.dist-info/licenses/LICENSE,sha256=GDz1ZoC4lB0kwjERpzqc_OdA_awYVso2aBnUH-ErW_w,1070
|
|
3
|
+
fusesell_local/__init__.py,sha256=7shpYv9KVMmtCmwRUUrJOIid2cvvUuLpZwnbNNk4TS8,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=
|
|
15
|
+
fusesell_local/stages/initial_outreach.py,sha256=hvt0tpJQGDX5-k3pb2SR54IOnhQv2XyYXYtiVVECmts,131850
|
|
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=
|
|
26
|
-
fusesell_local/utils/event_scheduler.py,sha256=
|
|
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.
|
|
32
|
-
fusesell-1.
|
|
33
|
-
fusesell-1.
|
|
34
|
-
fusesell-1.
|
|
35
|
-
fusesell-1.
|
|
31
|
+
fusesell-1.3.0.dist-info/METADATA,sha256=cBJe2l9s_vsCApaTmAtH_tOjdq9berqTED9CGOtSoEM,35074
|
|
32
|
+
fusesell-1.3.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
33
|
+
fusesell-1.3.0.dist-info/entry_points.txt,sha256=Vqek7tbiX7iF4rQkCRBZvT5WrB0HUduqKTsI2ZjhsXo,53
|
|
34
|
+
fusesell-1.3.0.dist-info/top_level.txt,sha256=VP9y1K6DEq6gNq2UgLd7ChujxViF6OzeAVCK7IUBXPA,24
|
|
35
|
+
fusesell-1.3.0.dist-info/RECORD,,
|
fusesell_local/__init__.py
CHANGED
|
@@ -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
|
-
|
|
176
|
-
|
|
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
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
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:
|
|
@@ -725,26 +794,33 @@ class InitialOutreachStage(BaseStage):
|
|
|
725
794
|
# Select the best subject line (first one, or most relevant)
|
|
726
795
|
selected_subject = subject_lines[0] if subject_lines else f"Partnership opportunity for {company_info.get('name', 'your company')}"
|
|
727
796
|
|
|
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
|
-
'
|
|
737
|
-
'
|
|
738
|
-
'
|
|
739
|
-
'
|
|
740
|
-
'
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
'
|
|
746
|
-
|
|
747
|
-
|
|
797
|
+
draft = {
|
|
798
|
+
'draft_id': draft_id,
|
|
799
|
+
'approach': approach['name'],
|
|
800
|
+
'tone': approach['tone'],
|
|
801
|
+
'focus': approach['focus'],
|
|
802
|
+
'subject': selected_subject, # Single subject instead of array
|
|
803
|
+
'subject_alternatives': subject_lines[1:4] if len(subject_lines) > 1 else [], # Store alternatives separately
|
|
804
|
+
'email_body': email_content,
|
|
805
|
+
'email_format': 'html',
|
|
806
|
+
'recipient_email': recipient_identity.get('email'),
|
|
807
|
+
'recipient_name': recipient_identity.get('full_name'),
|
|
808
|
+
'customer_first_name': recipient_identity.get('first_name'),
|
|
809
|
+
'call_to_action': self._extract_call_to_action(email_content),
|
|
810
|
+
'personalization_score': self._calculate_personalization_score(email_content, customer_data),
|
|
811
|
+
'generated_at': datetime.now().isoformat(),
|
|
812
|
+
'status': 'draft',
|
|
813
|
+
'metadata': {
|
|
814
|
+
'customer_company': company_info.get('name', 'Unknown'),
|
|
815
|
+
'contact_name': contact_info.get('name', 'Unknown'),
|
|
816
|
+
'recipient_email': recipient_identity.get('email'),
|
|
817
|
+
'recipient_name': recipient_identity.get('full_name'),
|
|
818
|
+
'email_format': 'html',
|
|
819
|
+
'recommended_product': recommended_product.get('product_name', 'Unknown'),
|
|
820
|
+
'pain_points_addressed': len([p for p in pain_points if p.get('severity') in ['high', 'medium']]),
|
|
821
|
+
'generation_method': 'llm_powered'
|
|
822
|
+
}
|
|
823
|
+
}
|
|
748
824
|
|
|
749
825
|
generated_drafts.append(draft)
|
|
750
826
|
|
|
@@ -904,6 +980,7 @@ class InitialOutreachStage(BaseStage):
|
|
|
904
980
|
first_name = name_parts[0]
|
|
905
981
|
else:
|
|
906
982
|
first_name = contact_name or ''
|
|
983
|
+
context.setdefault('customer_first_name', first_name or contact_name or '')
|
|
907
984
|
|
|
908
985
|
action = input_data.get('action', 'draft_write')
|
|
909
986
|
action_labels = {
|
|
@@ -1044,7 +1121,7 @@ class InitialOutreachStage(BaseStage):
|
|
|
1044
1121
|
return rep
|
|
1045
1122
|
return reps[0] if reps else {}
|
|
1046
1123
|
|
|
1047
|
-
def _sanitize_email_body(self, html: str, staff_name: str, rep_profile: Dict[str, Any]) -> str:
|
|
1124
|
+
def _sanitize_email_body(self, html: str, staff_name: str, rep_profile: Dict[str, Any], customer_first_name: str) -> str:
|
|
1048
1125
|
if not html:
|
|
1049
1126
|
return ''
|
|
1050
1127
|
|
|
@@ -1065,12 +1142,84 @@ class InitialOutreachStage(BaseStage):
|
|
|
1065
1142
|
else:
|
|
1066
1143
|
html = html.replace(placeholder, '')
|
|
1067
1144
|
|
|
1068
|
-
# Remove any lingering placeholder fragments such as "[Your LinkedIn Profile"
|
|
1069
1145
|
html = re.sub(r'\[Your[^<\]]+\]?', '', html, flags=re.IGNORECASE)
|
|
1070
|
-
|
|
1146
|
+
|
|
1147
|
+
if '<p' not in html.lower():
|
|
1148
|
+
lines = [line.strip() for line in html.splitlines() if line.strip()]
|
|
1149
|
+
if lines:
|
|
1150
|
+
html = ''.join(f'<p>{line}</p>' for line in lines)
|
|
1151
|
+
|
|
1152
|
+
html = self._deduplicate_greeting(html, customer_first_name or '')
|
|
1071
1153
|
html = re.sub(r'(<p>\s*</p>)+', '', html, flags=re.IGNORECASE)
|
|
1072
1154
|
return html
|
|
1073
1155
|
|
|
1156
|
+
def _deduplicate_greeting(self, html: str, customer_first_name: str) -> str:
|
|
1157
|
+
paragraphs = re.findall(r'(<p.*?>.*?</p>)', html, flags=re.IGNORECASE | re.DOTALL)
|
|
1158
|
+
if not paragraphs:
|
|
1159
|
+
return html
|
|
1160
|
+
|
|
1161
|
+
greeting_seen = False
|
|
1162
|
+
cleaned: List[str] = []
|
|
1163
|
+
for para in paragraphs:
|
|
1164
|
+
text = self._strip_html_tags(para).strip()
|
|
1165
|
+
normalized_para = para
|
|
1166
|
+
if self._looks_like_greeting(text):
|
|
1167
|
+
normalized_para = self._standardize_greeting_paragraph(para, customer_first_name)
|
|
1168
|
+
if greeting_seen:
|
|
1169
|
+
continue
|
|
1170
|
+
greeting_seen = True
|
|
1171
|
+
cleaned.append(normalized_para)
|
|
1172
|
+
|
|
1173
|
+
remainder = re.sub(r'(<p.*?>.*?</p>)', '__PARA__', html, flags=re.IGNORECASE | re.DOTALL)
|
|
1174
|
+
rebuilt = ''
|
|
1175
|
+
idx = 0
|
|
1176
|
+
for segment in remainder.split('__PARA__'):
|
|
1177
|
+
rebuilt += segment
|
|
1178
|
+
if idx < len(cleaned):
|
|
1179
|
+
rebuilt += cleaned[idx]
|
|
1180
|
+
idx += 1
|
|
1181
|
+
if idx < len(cleaned):
|
|
1182
|
+
rebuilt += ''.join(cleaned[idx:])
|
|
1183
|
+
return rebuilt
|
|
1184
|
+
|
|
1185
|
+
def _looks_like_greeting(self, text: str) -> bool:
|
|
1186
|
+
lowered = text.lower().replace('\xa0', ' ').strip()
|
|
1187
|
+
return lowered.startswith(('hi ', 'hello ', 'dear '))
|
|
1188
|
+
|
|
1189
|
+
def _standardize_greeting_paragraph(self, paragraph_html: str, customer_first_name: str) -> str:
|
|
1190
|
+
text = self._strip_html_tags(paragraph_html).strip()
|
|
1191
|
+
lowered = text.lower()
|
|
1192
|
+
first_word = next((candidate.title() for candidate in ('dear', 'hello', 'hi') if lowered.startswith(candidate)), 'Hi')
|
|
1193
|
+
|
|
1194
|
+
if customer_first_name:
|
|
1195
|
+
greeting = f"{first_word} {customer_first_name},"
|
|
1196
|
+
else:
|
|
1197
|
+
greeting = f"{first_word} there,"
|
|
1198
|
+
|
|
1199
|
+
remainder = ''
|
|
1200
|
+
match = re.match(r' *(hi|hello|dear)\b[^,]*,(.*)', text, flags=re.IGNORECASE | re.DOTALL)
|
|
1201
|
+
if match:
|
|
1202
|
+
remainder = match.group(2).lstrip()
|
|
1203
|
+
elif lowered.startswith(('hi', 'hello', 'dear')):
|
|
1204
|
+
parts = text.split(',', 1)
|
|
1205
|
+
if len(parts) > 1:
|
|
1206
|
+
remainder = parts[1].lstrip()
|
|
1207
|
+
else:
|
|
1208
|
+
remainder = text[len(text.split(' ', 1)[0]):].lstrip()
|
|
1209
|
+
|
|
1210
|
+
if remainder:
|
|
1211
|
+
sanitized_text = f"{greeting} {remainder}".strip()
|
|
1212
|
+
else:
|
|
1213
|
+
sanitized_text = greeting
|
|
1214
|
+
|
|
1215
|
+
return re.sub(
|
|
1216
|
+
r'(<p.*?>).*?(</p>)',
|
|
1217
|
+
lambda m: f"{m.group(1)}{sanitized_text}{m.group(2)}",
|
|
1218
|
+
paragraph_html,
|
|
1219
|
+
count=1,
|
|
1220
|
+
flags=re.IGNORECASE | re.DOTALL
|
|
1221
|
+
)
|
|
1222
|
+
|
|
1074
1223
|
def _extract_first_name(self, full_name: str) -> str:
|
|
1075
1224
|
if not full_name:
|
|
1076
1225
|
return ''
|
|
@@ -1120,18 +1269,22 @@ class InitialOutreachStage(BaseStage):
|
|
|
1120
1269
|
self.logger.debug('Skipping prompt entry because it is not a dict: %s', entry)
|
|
1121
1270
|
return None
|
|
1122
1271
|
|
|
1123
|
-
email_body = entry.get('body') or entry.get('content') or ''
|
|
1124
|
-
if isinstance(email_body, dict):
|
|
1125
|
-
email_body = email_body.get('html') or email_body.get('text') or email_body.get('content') or ''
|
|
1126
|
-
email_body = str(email_body).strip()
|
|
1127
|
-
if not email_body:
|
|
1128
|
-
self.logger.debug('Skipping prompt entry because email body is empty: %s', entry)
|
|
1129
|
-
return None
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
if
|
|
1133
|
-
|
|
1134
|
-
|
|
1272
|
+
email_body = entry.get('body') or entry.get('content') or ''
|
|
1273
|
+
if isinstance(email_body, dict):
|
|
1274
|
+
email_body = email_body.get('html') or email_body.get('text') or email_body.get('content') or ''
|
|
1275
|
+
email_body = str(email_body).strip()
|
|
1276
|
+
if not email_body:
|
|
1277
|
+
self.logger.debug('Skipping prompt entry because email body is empty: %s', entry)
|
|
1278
|
+
return None
|
|
1279
|
+
|
|
1280
|
+
recipient_identity = self._resolve_recipient_identity(customer_data, context)
|
|
1281
|
+
if recipient_identity.get('first_name') and not context.get('customer_first_name'):
|
|
1282
|
+
context['customer_first_name'] = recipient_identity['first_name']
|
|
1283
|
+
|
|
1284
|
+
subject = entry.get('subject')
|
|
1285
|
+
if isinstance(subject, list):
|
|
1286
|
+
subject = subject[0] if subject else ''
|
|
1287
|
+
subject = str(subject).strip() if subject else ''
|
|
1135
1288
|
|
|
1136
1289
|
subject_alternatives: List[str] = []
|
|
1137
1290
|
for key in ('subject_alternatives', 'subject_variations', 'subject_variants', 'alternative_subjects', 'subjects'):
|
|
@@ -1177,16 +1330,22 @@ class InitialOutreachStage(BaseStage):
|
|
|
1177
1330
|
message_type = entry.get('message_type') or 'Email'
|
|
1178
1331
|
rep_profile = getattr(self, '_active_rep_profile', {}) or {}
|
|
1179
1332
|
staff_name = context.get('input_data', {}).get('staff_name') or self.config.get('staff_name', 'Sales Team')
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
'
|
|
1187
|
-
'
|
|
1188
|
-
'
|
|
1189
|
-
|
|
1333
|
+
first_name = recipient_identity.get('first_name') or context.get('customer_first_name') or context.get('input_data', {}).get('customer_name') or ''
|
|
1334
|
+
email_body = self._sanitize_email_body(email_body, staff_name, rep_profile, first_name)
|
|
1335
|
+
if '<html' not in email_body.lower():
|
|
1336
|
+
email_body = f"<html><body>{email_body}</body></html>"
|
|
1337
|
+
|
|
1338
|
+
metadata = {
|
|
1339
|
+
'customer_company': customer_data.get('companyInfo', {}).get('name', 'Unknown'),
|
|
1340
|
+
'contact_name': customer_data.get('primaryContact', {}).get('name', 'Unknown'),
|
|
1341
|
+
'recipient_email': recipient_identity.get('email'),
|
|
1342
|
+
'recipient_name': recipient_identity.get('full_name'),
|
|
1343
|
+
'email_format': 'html',
|
|
1344
|
+
'recommended_product': product_name or 'Unknown',
|
|
1345
|
+
'generation_method': 'prompt_template',
|
|
1346
|
+
'tags': tags,
|
|
1347
|
+
'message_type': message_type
|
|
1348
|
+
}
|
|
1190
1349
|
|
|
1191
1350
|
draft_id = entry.get('draft_id') or f"uuid:{str(uuid.uuid4())}"
|
|
1192
1351
|
draft_approach = "prompt"
|
|
@@ -1196,15 +1355,19 @@ class InitialOutreachStage(BaseStage):
|
|
|
1196
1355
|
'draft_id': draft_id,
|
|
1197
1356
|
'approach': approach,
|
|
1198
1357
|
'tone': mail_tone,
|
|
1199
|
-
'focus': focus,
|
|
1200
|
-
'subject': subject,
|
|
1201
|
-
'subject_alternatives': subject_alternatives,
|
|
1202
|
-
'email_body': email_body,
|
|
1203
|
-
'
|
|
1204
|
-
'
|
|
1205
|
-
'
|
|
1206
|
-
'
|
|
1207
|
-
'
|
|
1358
|
+
'focus': focus,
|
|
1359
|
+
'subject': subject,
|
|
1360
|
+
'subject_alternatives': subject_alternatives,
|
|
1361
|
+
'email_body': email_body,
|
|
1362
|
+
'email_format': 'html',
|
|
1363
|
+
'recipient_email': recipient_identity.get('email'),
|
|
1364
|
+
'recipient_name': recipient_identity.get('full_name'),
|
|
1365
|
+
'customer_first_name': recipient_identity.get('first_name'),
|
|
1366
|
+
'call_to_action': call_to_action,
|
|
1367
|
+
'product_mention': product_mention,
|
|
1368
|
+
'product_name': product_name,
|
|
1369
|
+
'priority_order': priority_order if priority_order is not None else 0,
|
|
1370
|
+
'personalization_score': personalization_score,
|
|
1208
1371
|
'generated_at': datetime.now().isoformat(),
|
|
1209
1372
|
'status': 'draft',
|
|
1210
1373
|
'metadata': metadata
|
|
@@ -1258,7 +1421,7 @@ class InitialOutreachStage(BaseStage):
|
|
|
1258
1421
|
)
|
|
1259
1422
|
|
|
1260
1423
|
# Clean and validate the generated content
|
|
1261
|
-
cleaned_content = self._clean_email_content(email_content)
|
|
1424
|
+
cleaned_content = self._clean_email_content(email_content, context)
|
|
1262
1425
|
|
|
1263
1426
|
return cleaned_content
|
|
1264
1427
|
|
|
@@ -1369,35 +1532,61 @@ Generate 4 subject lines, one per line, no numbering or bullets:"""
|
|
|
1369
1532
|
f"5-minute chat about {company_name}?"
|
|
1370
1533
|
]
|
|
1371
1534
|
|
|
1372
|
-
def _clean_email_content(
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
"
|
|
1384
|
-
"
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1535
|
+
def _clean_email_content(
|
|
1536
|
+
self,
|
|
1537
|
+
raw_content: str,
|
|
1538
|
+
context: Optional[Dict[str, Any]] = None
|
|
1539
|
+
) -> str:
|
|
1540
|
+
"""
|
|
1541
|
+
Clean and normalize generated email content, returning HTML.
|
|
1542
|
+
"""
|
|
1543
|
+
content = (raw_content or "").replace("\r\n", "\n").strip()
|
|
1544
|
+
|
|
1545
|
+
artifacts_to_remove = (
|
|
1546
|
+
"Here's the email:",
|
|
1547
|
+
"Here is the email:",
|
|
1548
|
+
"Email content:",
|
|
1549
|
+
"Generated email:",
|
|
1550
|
+
"Subject:",
|
|
1551
|
+
"Email:"
|
|
1552
|
+
)
|
|
1553
|
+
|
|
1554
|
+
for artifact in artifacts_to_remove:
|
|
1555
|
+
if content.startswith(artifact):
|
|
1556
|
+
content = content[len(artifact):].strip()
|
|
1557
|
+
|
|
1558
|
+
lines = [line.strip() for line in content.split('\n') if line.strip()]
|
|
1559
|
+
content = '\n\n'.join(lines)
|
|
1560
|
+
|
|
1561
|
+
if not content:
|
|
1562
|
+
return "<html><body></body></html>"
|
|
1563
|
+
|
|
1564
|
+
if not content.lower().startswith(('dear ', 'hi ', 'hello ', 'greetings')):
|
|
1565
|
+
content = f"Dear Valued Customer,\n\n{content}"
|
|
1566
|
+
|
|
1567
|
+
closings = ('best regards', 'sincerely', 'thanks', 'thank you', 'kind regards')
|
|
1568
|
+
if not any(closing in content.lower() for closing in closings):
|
|
1569
|
+
content += "\n\nBest regards,\n[Your Name]"
|
|
1570
|
+
|
|
1571
|
+
ctx = context or {}
|
|
1572
|
+
input_data = ctx.get('input_data', {}) or {}
|
|
1573
|
+
rep_profile = getattr(self, '_active_rep_profile', {}) or {}
|
|
1574
|
+
staff_name = input_data.get('staff_name') or self.config.get('staff_name') or 'Sales Team'
|
|
1575
|
+
identity = ctx.get('_recipient_identity') or {}
|
|
1576
|
+
customer_first_name = (
|
|
1577
|
+
identity.get('first_name')
|
|
1578
|
+
or ctx.get('customer_first_name')
|
|
1579
|
+
or input_data.get('customer_first_name')
|
|
1580
|
+
or input_data.get('recipient_name')
|
|
1581
|
+
or input_data.get('customer_name')
|
|
1582
|
+
or ''
|
|
1583
|
+
)
|
|
1584
|
+
|
|
1585
|
+
sanitized = self._sanitize_email_body(content, staff_name, rep_profile, customer_first_name)
|
|
1586
|
+
if '<html' not in sanitized.lower():
|
|
1587
|
+
sanitized = f"<html><body>{sanitized}</body></html>"
|
|
1588
|
+
|
|
1589
|
+
return sanitized
|
|
1401
1590
|
|
|
1402
1591
|
def _extract_call_to_action(self, email_content: str) -> str:
|
|
1403
1592
|
"""Extract the main call-to-action from email content."""
|
|
@@ -1489,55 +1678,85 @@ Best regards,
|
|
|
1489
1678
|
|
|
1490
1679
|
def _generate_fallback_draft(self, customer_data: Dict[str, Any], recommended_product: Dict[str, Any], context: Dict[str, Any]) -> List[Dict[str, Any]]:
|
|
1491
1680
|
"""Generate fallback draft when LLM generation fails."""
|
|
1492
|
-
draft_id = f"uuid:{str(uuid.uuid4())}"
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
'
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1681
|
+
draft_id = f"uuid:{str(uuid.uuid4())}"
|
|
1682
|
+
recipient_identity = self._resolve_recipient_identity(customer_data, context)
|
|
1683
|
+
context.setdefault('_recipient_identity', recipient_identity)
|
|
1684
|
+
if recipient_identity.get('first_name') and not context.get('customer_first_name'):
|
|
1685
|
+
context['customer_first_name'] = recipient_identity['first_name']
|
|
1686
|
+
|
|
1687
|
+
template_body = self._generate_template_email(
|
|
1688
|
+
customer_data,
|
|
1689
|
+
recommended_product,
|
|
1690
|
+
{'tone': 'professional'},
|
|
1691
|
+
context
|
|
1692
|
+
)
|
|
1693
|
+
email_body = self._clean_email_content(template_body, context)
|
|
1694
|
+
|
|
1695
|
+
fallback_subjects = self._generate_fallback_subject_lines(customer_data, recommended_product)
|
|
1696
|
+
|
|
1697
|
+
return [{
|
|
1698
|
+
'draft_id': draft_id,
|
|
1699
|
+
'approach': 'fallback_template',
|
|
1700
|
+
'tone': 'professional',
|
|
1701
|
+
'focus': 'general outreach',
|
|
1702
|
+
'subject': fallback_subjects[0],
|
|
1703
|
+
'subject_alternatives': fallback_subjects[1:],
|
|
1704
|
+
'email_body': email_body,
|
|
1705
|
+
'email_format': 'html',
|
|
1706
|
+
'recipient_email': recipient_identity.get('email'),
|
|
1707
|
+
'recipient_name': recipient_identity.get('full_name'),
|
|
1708
|
+
'customer_first_name': recipient_identity.get('first_name'),
|
|
1709
|
+
'call_to_action': 'Would you be interested in a brief call?',
|
|
1710
|
+
'personalization_score': 50,
|
|
1711
|
+
'generated_at': datetime.now().isoformat(),
|
|
1712
|
+
'status': 'draft',
|
|
1713
|
+
'metadata': {
|
|
1714
|
+
'generation_method': 'template_fallback',
|
|
1715
|
+
'note': 'Generated using template due to LLM failure',
|
|
1716
|
+
'recipient_email': recipient_identity.get('email'),
|
|
1717
|
+
'recipient_name': recipient_identity.get('full_name'),
|
|
1718
|
+
'email_format': 'html'
|
|
1719
|
+
}
|
|
1720
|
+
}]
|
|
1513
1721
|
|
|
1514
1722
|
def _get_mock_email_drafts(self, customer_data: Dict[str, Any], recommended_product: Dict[str, Any], context: Dict[str, Any]) -> List[Dict[str, Any]]:
|
|
1515
1723
|
"""Get mock email drafts for dry run."""
|
|
1516
1724
|
input_data = context.get('input_data', {})
|
|
1517
1725
|
company_info = customer_data.get('companyInfo', {})
|
|
1518
1726
|
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
'
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
'
|
|
1536
|
-
'
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1727
|
+
recipient_identity = self._resolve_recipient_identity(customer_data, context)
|
|
1728
|
+
context.setdefault('_recipient_identity', recipient_identity)
|
|
1729
|
+
mock_body = f"""[DRY RUN] Mock email content for {company_info.get('name', 'Test Company')}
|
|
1730
|
+
|
|
1731
|
+
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."""
|
|
1732
|
+
|
|
1733
|
+
return [{
|
|
1734
|
+
'draft_id': 'mock_draft_001',
|
|
1735
|
+
'approach': 'professional_direct',
|
|
1736
|
+
'tone': 'professional and direct',
|
|
1737
|
+
'focus': 'business value and ROI',
|
|
1738
|
+
'subject': f"Partnership Opportunity for {company_info.get('name', 'Test Company')}",
|
|
1739
|
+
'subject_alternatives': [
|
|
1740
|
+
f"Quick Question About {company_info.get('name', 'Test Company')}",
|
|
1741
|
+
f"Helping Companies Like {company_info.get('name', 'Test Company')}"
|
|
1742
|
+
],
|
|
1743
|
+
'email_body': self._clean_email_content(mock_body, context),
|
|
1744
|
+
'email_format': 'html',
|
|
1745
|
+
'recipient_email': recipient_identity.get('email'),
|
|
1746
|
+
'recipient_name': recipient_identity.get('full_name'),
|
|
1747
|
+
'customer_first_name': recipient_identity.get('first_name'),
|
|
1748
|
+
'call_to_action': 'Mock call to action',
|
|
1749
|
+
'personalization_score': 85,
|
|
1750
|
+
'generated_at': datetime.now().isoformat(),
|
|
1751
|
+
'status': 'mock',
|
|
1752
|
+
'metadata': {
|
|
1753
|
+
'generation_method': 'mock_data',
|
|
1754
|
+
'note': 'This is mock data for dry run testing',
|
|
1755
|
+
'recipient_email': recipient_identity.get('email'),
|
|
1756
|
+
'recipient_name': recipient_identity.get('full_name'),
|
|
1757
|
+
'email_format': 'html'
|
|
1758
|
+
}
|
|
1759
|
+
}]
|
|
1541
1760
|
|
|
1542
1761
|
|
|
1543
1762
|
def _convert_draft_to_server_format(self, draft: Dict[str, Any], context: Dict[str, Any]) -> Dict[str, Any]:
|
|
@@ -1876,16 +2095,17 @@ This is a mock email that would be generated for testing purposes. In a real exe
|
|
|
1876
2095
|
|
|
1877
2096
|
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]:
|
|
1878
2097
|
"""Rewrite existing draft based on reason using LLM."""
|
|
1879
|
-
try:
|
|
1880
|
-
if self.is_dry_run():
|
|
1881
|
-
rewritten = existing_draft.copy()
|
|
1882
|
-
rewritten['draft_id'] = f"uuid:{str(uuid.uuid4())}"
|
|
1883
|
-
rewritten['draft_approach'] = "rewrite"
|
|
1884
|
-
rewritten['draft_type'] = "rewrite"
|
|
1885
|
-
rewritten['email_body'] = f"[DRY RUN - REWRITTEN: {reason}] " + existing_draft.get('email_body', '')
|
|
1886
|
-
rewritten['rewrite_reason'] = reason
|
|
1887
|
-
rewritten['rewritten_at'] = datetime.now().isoformat()
|
|
1888
|
-
|
|
2098
|
+
try:
|
|
2099
|
+
if self.is_dry_run():
|
|
2100
|
+
rewritten = existing_draft.copy()
|
|
2101
|
+
rewritten['draft_id'] = f"uuid:{str(uuid.uuid4())}"
|
|
2102
|
+
rewritten['draft_approach'] = "rewrite"
|
|
2103
|
+
rewritten['draft_type'] = "rewrite"
|
|
2104
|
+
rewritten['email_body'] = f"[DRY RUN - REWRITTEN: {reason}] " + existing_draft.get('email_body', '')
|
|
2105
|
+
rewritten['rewrite_reason'] = reason
|
|
2106
|
+
rewritten['rewritten_at'] = datetime.now().isoformat()
|
|
2107
|
+
rewritten.setdefault('email_format', 'html')
|
|
2108
|
+
return rewritten
|
|
1889
2109
|
|
|
1890
2110
|
input_data = context.get('input_data', {})
|
|
1891
2111
|
company_info = customer_data.get('companyInfo', {})
|
|
@@ -1923,46 +2143,66 @@ Generate only the rewritten email content:"""
|
|
|
1923
2143
|
)
|
|
1924
2144
|
|
|
1925
2145
|
# Clean the rewritten content
|
|
1926
|
-
cleaned_content = self._clean_email_content(rewritten_content)
|
|
1927
|
-
|
|
1928
|
-
# Create rewritten draft object
|
|
1929
|
-
rewritten = existing_draft.copy()
|
|
1930
|
-
rewritten['draft_id'] = f"uuid:{str(uuid.uuid4())}"
|
|
1931
|
-
rewritten['draft_approach'] = "rewrite"
|
|
1932
|
-
rewritten['draft_type'] = "rewrite"
|
|
1933
|
-
rewritten['email_body'] = cleaned_content
|
|
1934
|
-
rewritten['rewrite_reason'] = reason
|
|
1935
|
-
rewritten['rewritten_at'] = datetime.now().isoformat()
|
|
1936
|
-
rewritten['version'] = existing_draft.get('version', 1) + 1
|
|
1937
|
-
rewritten['call_to_action'] = self._extract_call_to_action(cleaned_content)
|
|
1938
|
-
rewritten['personalization_score'] = self._calculate_personalization_score(cleaned_content, customer_data)
|
|
2146
|
+
cleaned_content = self._clean_email_content(rewritten_content, context)
|
|
2147
|
+
|
|
2148
|
+
# Create rewritten draft object
|
|
2149
|
+
rewritten = existing_draft.copy()
|
|
2150
|
+
rewritten['draft_id'] = f"uuid:{str(uuid.uuid4())}"
|
|
2151
|
+
rewritten['draft_approach'] = "rewrite"
|
|
2152
|
+
rewritten['draft_type'] = "rewrite"
|
|
2153
|
+
rewritten['email_body'] = cleaned_content
|
|
2154
|
+
rewritten['rewrite_reason'] = reason
|
|
2155
|
+
rewritten['rewritten_at'] = datetime.now().isoformat()
|
|
2156
|
+
rewritten['version'] = existing_draft.get('version', 1) + 1
|
|
2157
|
+
rewritten['call_to_action'] = self._extract_call_to_action(cleaned_content)
|
|
2158
|
+
rewritten['personalization_score'] = self._calculate_personalization_score(cleaned_content, customer_data)
|
|
2159
|
+
recipient_identity = context.get('_recipient_identity') or self._resolve_recipient_identity(customer_data, context)
|
|
2160
|
+
if recipient_identity:
|
|
2161
|
+
rewritten['recipient_email'] = recipient_identity.get('email')
|
|
2162
|
+
rewritten['recipient_name'] = recipient_identity.get('full_name')
|
|
2163
|
+
rewritten['customer_first_name'] = recipient_identity.get('first_name')
|
|
2164
|
+
rewritten['email_format'] = 'html'
|
|
1939
2165
|
|
|
1940
2166
|
# Update metadata
|
|
1941
2167
|
if 'metadata' not in rewritten:
|
|
1942
2168
|
rewritten['metadata'] = {}
|
|
1943
2169
|
rewritten['metadata']['rewrite_history'] = rewritten['metadata'].get('rewrite_history', [])
|
|
1944
|
-
rewritten['metadata']['rewrite_history'].append({
|
|
1945
|
-
'reason': reason,
|
|
1946
|
-
'rewritten_at': datetime.now().isoformat(),
|
|
1947
|
-
'original_draft_id': existing_draft.get('draft_id')
|
|
1948
|
-
})
|
|
1949
|
-
rewritten['metadata']['generation_method'] = 'llm_rewrite'
|
|
2170
|
+
rewritten['metadata']['rewrite_history'].append({
|
|
2171
|
+
'reason': reason,
|
|
2172
|
+
'rewritten_at': datetime.now().isoformat(),
|
|
2173
|
+
'original_draft_id': existing_draft.get('draft_id')
|
|
2174
|
+
})
|
|
2175
|
+
rewritten['metadata']['generation_method'] = 'llm_rewrite'
|
|
2176
|
+
if recipient_identity:
|
|
2177
|
+
rewritten['metadata']['recipient_email'] = recipient_identity.get('email')
|
|
2178
|
+
rewritten['metadata']['recipient_name'] = recipient_identity.get('full_name')
|
|
2179
|
+
rewritten['metadata']['email_format'] = 'html'
|
|
1950
2180
|
|
|
1951
2181
|
self.logger.info(f"Successfully rewrote draft based on reason: {reason}")
|
|
1952
2182
|
return rewritten
|
|
1953
2183
|
|
|
1954
|
-
except Exception as e:
|
|
1955
|
-
self.logger.error(f"Failed to rewrite draft: {str(e)}")
|
|
1956
|
-
# Fallback to simple modification
|
|
1957
|
-
rewritten = existing_draft.copy()
|
|
1958
|
-
rewritten['draft_id'] = f"uuid:{str(uuid.uuid4())}"
|
|
1959
|
-
rewritten['draft_approach'] = "rewrite"
|
|
1960
|
-
rewritten['draft_type'] = "rewrite"
|
|
1961
|
-
rewritten['email_body'] = f"[REWRITTEN: {reason}]\n\n" + existing_draft.get('email_body', '')
|
|
1962
|
-
rewritten['rewrite_reason'] = reason
|
|
1963
|
-
rewritten['rewritten_at'] = datetime.now().isoformat()
|
|
1964
|
-
rewritten['metadata'] = {'generation_method': 'template_rewrite', 'error': str(e)}
|
|
1965
|
-
|
|
2184
|
+
except Exception as e:
|
|
2185
|
+
self.logger.error(f"Failed to rewrite draft: {str(e)}")
|
|
2186
|
+
# Fallback to simple modification
|
|
2187
|
+
rewritten = existing_draft.copy()
|
|
2188
|
+
rewritten['draft_id'] = f"uuid:{str(uuid.uuid4())}"
|
|
2189
|
+
rewritten['draft_approach'] = "rewrite"
|
|
2190
|
+
rewritten['draft_type'] = "rewrite"
|
|
2191
|
+
rewritten['email_body'] = f"[REWRITTEN: {reason}]\n\n" + existing_draft.get('email_body', '')
|
|
2192
|
+
rewritten['rewrite_reason'] = reason
|
|
2193
|
+
rewritten['rewritten_at'] = datetime.now().isoformat()
|
|
2194
|
+
rewritten['metadata'] = {'generation_method': 'template_rewrite', 'error': str(e)}
|
|
2195
|
+
rewritten['email_body'] = self._clean_email_content(rewritten['email_body'], context)
|
|
2196
|
+
fallback_identity = context.get('_recipient_identity') or self._resolve_recipient_identity(customer_data, context)
|
|
2197
|
+
if fallback_identity:
|
|
2198
|
+
rewritten['recipient_email'] = fallback_identity.get('email')
|
|
2199
|
+
rewritten['recipient_name'] = fallback_identity.get('full_name')
|
|
2200
|
+
rewritten['customer_first_name'] = fallback_identity.get('first_name')
|
|
2201
|
+
rewritten['metadata']['recipient_email'] = fallback_identity.get('email')
|
|
2202
|
+
rewritten['metadata']['recipient_name'] = fallback_identity.get('full_name')
|
|
2203
|
+
rewritten['metadata']['email_format'] = 'html'
|
|
2204
|
+
rewritten['email_format'] = 'html'
|
|
2205
|
+
return rewritten
|
|
1966
2206
|
|
|
1967
2207
|
def _save_rewritten_draft(self, context: Dict[str, Any], rewritten_draft: Dict[str, Any], original_draft_id: str) -> Dict[str, Any]:
|
|
1968
2208
|
"""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
|
|
@@ -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 []
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|