fusesell 1.3.42__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.
Files changed (35) hide show
  1. fusesell-1.3.42.dist-info/METADATA +873 -0
  2. fusesell-1.3.42.dist-info/RECORD +35 -0
  3. fusesell-1.3.42.dist-info/WHEEL +5 -0
  4. fusesell-1.3.42.dist-info/entry_points.txt +2 -0
  5. fusesell-1.3.42.dist-info/licenses/LICENSE +21 -0
  6. fusesell-1.3.42.dist-info/top_level.txt +2 -0
  7. fusesell.py +20 -0
  8. fusesell_local/__init__.py +37 -0
  9. fusesell_local/api.py +343 -0
  10. fusesell_local/cli.py +1480 -0
  11. fusesell_local/config/__init__.py +11 -0
  12. fusesell_local/config/default_email_templates.json +34 -0
  13. fusesell_local/config/default_prompts.json +19 -0
  14. fusesell_local/config/default_scoring_criteria.json +154 -0
  15. fusesell_local/config/prompts.py +245 -0
  16. fusesell_local/config/settings.py +277 -0
  17. fusesell_local/pipeline.py +978 -0
  18. fusesell_local/stages/__init__.py +19 -0
  19. fusesell_local/stages/base_stage.py +603 -0
  20. fusesell_local/stages/data_acquisition.py +1820 -0
  21. fusesell_local/stages/data_preparation.py +1238 -0
  22. fusesell_local/stages/follow_up.py +1728 -0
  23. fusesell_local/stages/initial_outreach.py +2972 -0
  24. fusesell_local/stages/lead_scoring.py +1452 -0
  25. fusesell_local/utils/__init__.py +36 -0
  26. fusesell_local/utils/agent_context.py +552 -0
  27. fusesell_local/utils/auto_setup.py +361 -0
  28. fusesell_local/utils/birthday_email_manager.py +467 -0
  29. fusesell_local/utils/data_manager.py +4857 -0
  30. fusesell_local/utils/event_scheduler.py +959 -0
  31. fusesell_local/utils/llm_client.py +342 -0
  32. fusesell_local/utils/logger.py +203 -0
  33. fusesell_local/utils/output_helpers.py +2443 -0
  34. fusesell_local/utils/timezone_detector.py +914 -0
  35. fusesell_local/utils/validators.py +436 -0
@@ -0,0 +1,19 @@
1
+ """
2
+ FuseSell Stages - Individual pipeline stage implementations
3
+ """
4
+
5
+ from .base_stage import BaseStage
6
+ from .data_acquisition import DataAcquisitionStage
7
+ from .data_preparation import DataPreparationStage
8
+ from .lead_scoring import LeadScoringStage
9
+ from .initial_outreach import InitialOutreachStage
10
+ from .follow_up import FollowUpStage
11
+
12
+ __all__ = [
13
+ 'BaseStage',
14
+ 'DataAcquisitionStage',
15
+ 'DataPreparationStage',
16
+ 'LeadScoringStage',
17
+ 'InitialOutreachStage',
18
+ 'FollowUpStage'
19
+ ]
@@ -0,0 +1,603 @@
1
+ """
2
+ Base Stage Interface for FuseSell Pipeline Stages
3
+ """
4
+
5
+ from abc import ABC, abstractmethod
6
+ from typing import Dict, Any, Optional
7
+ import logging
8
+ import json
9
+ import time
10
+ from datetime import datetime
11
+ import uuid
12
+
13
+ from ..utils.llm_client import LLMClient
14
+ from ..utils.data_manager import LocalDataManager
15
+
16
+
17
+ class BaseStage(ABC):
18
+ """
19
+ Abstract base class for all FuseSell pipeline stages.
20
+ Provides common functionality and interface for stage implementations.
21
+ """
22
+
23
+ def __init__(self, config: Dict[str, Any], data_manager: Optional[LocalDataManager] = None):
24
+ """
25
+ Initialize the stage with configuration.
26
+
27
+ Args:
28
+ config: Configuration dictionary containing API keys, settings, etc.
29
+ data_manager: Optional shared data manager instance. If not provided, creates a new one.
30
+ """
31
+ self.config = config
32
+ # Convert class name to snake_case stage name
33
+ class_name = self.__class__.__name__.replace('Stage', '')
34
+ # Convert CamelCase to snake_case
35
+ import re
36
+ self.stage_name = re.sub('([a-z0-9])([A-Z])', r'\1_\2', class_name).lower()
37
+ self.logger = logging.getLogger(f"fusesell.{self.stage_name}")
38
+
39
+ # Initialize LLM client if API key provided
40
+ if config.get('openai_api_key'):
41
+ try:
42
+ # Initialize with base URL if provided
43
+ llm_kwargs = {
44
+ 'api_key': config['openai_api_key'],
45
+ 'model': config.get('llm_model', 'gpt-4.1-mini')
46
+ }
47
+ if config.get('llm_base_url'):
48
+ llm_kwargs['base_url'] = config['llm_base_url']
49
+
50
+ self.llm_client = LLMClient(**llm_kwargs)
51
+ except ImportError as e:
52
+ self.logger.warning(f"LLM client not available: {str(e)}")
53
+ self.llm_client = None
54
+ else:
55
+ self.llm_client = None
56
+
57
+ # Use provided data manager or create new one (for backward compatibility)
58
+ if data_manager is not None:
59
+ self.data_manager = data_manager
60
+ self.logger.debug("Using shared data manager instance")
61
+ else:
62
+ self.data_manager = LocalDataManager(config.get('data_dir', './fusesell_data'))
63
+ self.logger.warning("Created new data manager instance - this may cause performance overhead. Consider using shared data manager.")
64
+
65
+ @abstractmethod
66
+ def execute(self, context: Dict[str, Any]) -> Dict[str, Any]:
67
+ """
68
+ Execute the stage logic and return results.
69
+
70
+ Args:
71
+ context: Execution context containing input data and previous results
72
+
73
+ Returns:
74
+ Dictionary containing stage results and metadata
75
+ """
76
+ pass
77
+
78
+ @abstractmethod
79
+ def validate_input(self, context: Dict[str, Any]) -> bool:
80
+ """
81
+ Validate input data for this stage.
82
+
83
+ Args:
84
+ context: Execution context to validate
85
+
86
+ Returns:
87
+ True if input is valid, False otherwise
88
+ """
89
+ pass
90
+
91
+ def call_llm(self, prompt: str, **kwargs) -> str:
92
+ """
93
+ Standardized LLM calling interface.
94
+
95
+ Args:
96
+ prompt: The prompt to send to the LLM
97
+ **kwargs: Additional parameters for the LLM call
98
+
99
+ Returns:
100
+ LLM response text
101
+
102
+ Raises:
103
+ ValueError: If LLM client is not initialized
104
+ """
105
+ if not self.llm_client:
106
+ raise ValueError("LLM client not initialized. Provide openai_api_key in config.")
107
+
108
+ self.logger.debug(f"Calling LLM with prompt length: {len(prompt)}")
109
+
110
+ try:
111
+ response = self.llm_client.chat_completion(
112
+ messages=[{"role": "user", "content": prompt}],
113
+ **kwargs
114
+ )
115
+
116
+ self.logger.debug(f"LLM response length: {len(response)}")
117
+ return response
118
+
119
+ except Exception as e:
120
+ self.logger.error(f"LLM call failed: {str(e)}")
121
+ raise
122
+
123
+ def call_llm_with_system(self, system_prompt: str, user_prompt: str, **kwargs) -> str:
124
+ """
125
+ Call LLM with system and user prompts.
126
+
127
+ Args:
128
+ system_prompt: System prompt to set context
129
+ user_prompt: User prompt with the actual request
130
+ **kwargs: Additional parameters for the LLM call
131
+
132
+ Returns:
133
+ LLM response text
134
+ """
135
+ if not self.llm_client:
136
+ raise ValueError("LLM client not initialized. Provide openai_api_key in config.")
137
+
138
+ self.logger.debug(f"Calling LLM with system prompt length: {len(system_prompt)}, user prompt length: {len(user_prompt)}")
139
+
140
+ try:
141
+ response = self.llm_client.chat_completion(
142
+ messages=[
143
+ {"role": "system", "content": system_prompt},
144
+ {"role": "user", "content": user_prompt}
145
+ ],
146
+ **kwargs
147
+ )
148
+
149
+ self.logger.debug(f"LLM response length: {len(response)}")
150
+ return response
151
+
152
+ except Exception as e:
153
+ self.logger.error(f"LLM call with system prompt failed: {str(e)}")
154
+ raise
155
+
156
+ def call_llm_structured(self, prompt: str, response_format: str = "json", **kwargs) -> Dict[str, Any]:
157
+ """
158
+ Call LLM and parse structured response.
159
+
160
+ Args:
161
+ prompt: The prompt to send to the LLM
162
+ response_format: Expected response format ('json' or 'yaml')
163
+ **kwargs: Additional parameters for the LLM call
164
+
165
+ Returns:
166
+ Parsed structured response
167
+
168
+ Raises:
169
+ ValueError: If response cannot be parsed
170
+ """
171
+ # Add format instruction to prompt
172
+ if response_format.lower() == "json":
173
+ formatted_prompt = f"{prompt}\n\nPlease respond with valid JSON format."
174
+ else:
175
+ formatted_prompt = prompt
176
+
177
+ response = self.call_llm(formatted_prompt, **kwargs)
178
+
179
+ if response_format.lower() == "json":
180
+ return self.parse_json_response(response)
181
+ else:
182
+ return {"raw_response": response}
183
+
184
+ def parse_json_response(self, response: str) -> Dict[str, Any]:
185
+ """
186
+ Parse JSON response from LLM, handling common formatting issues.
187
+
188
+ Args:
189
+ response: Raw LLM response text
190
+
191
+ Returns:
192
+ Parsed JSON dictionary
193
+
194
+ Raises:
195
+ ValueError: If response cannot be parsed as JSON
196
+ """
197
+ try:
198
+ # Try direct parsing first
199
+ return json.loads(response)
200
+ except json.JSONDecodeError:
201
+ # Try to extract JSON from markdown code blocks
202
+ if "```json" in response:
203
+ start = response.find("```json") + 7
204
+ end = response.find("```", start)
205
+ if end != -1:
206
+ json_str = response[start:end].strip()
207
+ return json.loads(json_str)
208
+
209
+ # Try to extract JSON from the response
210
+ start = response.find("{")
211
+ end = response.rfind("}") + 1
212
+ if start != -1 and end > start:
213
+ json_str = response[start:end]
214
+ return json.loads(json_str)
215
+
216
+ raise ValueError(f"Could not parse JSON from LLM response: {response[:200]}...")
217
+
218
+ def log_stage_start(self, context: Dict[str, Any]) -> None:
219
+ """Log the start of stage execution."""
220
+ execution_id = context.get('execution_id', 'unknown')
221
+ self.logger.info(f"Starting {self.stage_name} stage for execution {execution_id}")
222
+
223
+ def log_stage_complete(self, context: Dict[str, Any], result: Dict[str, Any]) -> None:
224
+ """Log the completion of stage execution."""
225
+ execution_id = context.get('execution_id', 'unknown')
226
+ status = result.get('status', 'unknown')
227
+ self.logger.info(f"Completed {self.stage_name} stage for execution {execution_id} with status: {status}")
228
+
229
+ def log_stage_error(self, context: Dict[str, Any], error: Exception) -> None:
230
+ """Log stage execution errors."""
231
+ execution_id = context.get('execution_id', 'unknown')
232
+ self.logger.error(f"Error in {self.stage_name} stage for execution {execution_id}: {str(error)}")
233
+
234
+ def execute_with_timing(self, context: Dict[str, Any]) -> Dict[str, Any]:
235
+ """
236
+ Execute the stage with performance timing and consolidated logging.
237
+
238
+ Args:
239
+ context: Execution context containing input data and previous results
240
+
241
+ Returns:
242
+ Dictionary containing stage results and metadata with timing information
243
+ """
244
+ execution_id = context.get('execution_id', 'unknown')
245
+ start_time = time.time()
246
+
247
+ # Single start log message
248
+ self.logger.info(f"Starting {self.stage_name} stage for execution {execution_id}")
249
+
250
+ try:
251
+ # Execute the actual stage logic (stages should NOT log completion themselves)
252
+ result = self.execute(context)
253
+
254
+ # Calculate timing
255
+ end_time = time.time()
256
+ duration = end_time - start_time
257
+
258
+ # Add timing information to result
259
+ if isinstance(result, dict):
260
+ result['timing'] = {
261
+ 'start_time': start_time,
262
+ 'end_time': end_time,
263
+ 'duration_seconds': duration
264
+ }
265
+
266
+ # Single completion log message with timing
267
+ status = result.get('status', 'unknown') if isinstance(result, dict) else 'unknown'
268
+ self.logger.info(f"Completed {self.stage_name} stage for execution {execution_id} with status: {status} in {duration:.2f} seconds")
269
+
270
+ return result
271
+
272
+ except Exception as e:
273
+ end_time = time.time()
274
+ duration = end_time - start_time
275
+
276
+ # Single error log message with timing
277
+ self.logger.error(f"Error in {self.stage_name} stage for execution {execution_id} after {duration:.2f} seconds: {str(e)}")
278
+ raise
279
+
280
+ def save_stage_result(self, context: Dict[str, Any], result: Dict[str, Any]) -> None:
281
+ """
282
+ Save stage result to local database (backward compatibility only).
283
+
284
+ Note: Operation tracking is now handled by the pipeline using server-compatible schema.
285
+ This method only maintains backward compatibility with the old stage_results table.
286
+
287
+ Args:
288
+ context: Execution context
289
+ result: Stage execution result
290
+ """
291
+ try:
292
+ # Save to stage_results table (backward compatibility only)
293
+ # The pipeline now handles operation creation with server-compatible schema
294
+ self.data_manager.save_stage_result(
295
+ execution_id=context.get('execution_id'),
296
+ stage_name=self.stage_name,
297
+ input_data=context.get('input_data', {}),
298
+ output_data=result,
299
+ status=result.get('status', 'unknown')
300
+ )
301
+
302
+ except Exception as e:
303
+ self.logger.debug(f"Backward compatibility save failed (expected): {str(e)}")
304
+
305
+ def get_prompt_template(self, prompt_key: str) -> str:
306
+ """
307
+ Get prompt template from configuration.
308
+ Prioritizes custom user prompts over default system prompts.
309
+
310
+ Args:
311
+ prompt_key: Key for the prompt template
312
+
313
+ Returns:
314
+ Prompt template string (custom if available, otherwise default)
315
+ """
316
+ try:
317
+ prompts = self.data_manager.load_prompts()
318
+ stage_prompts = prompts.get(self.stage_name, {})
319
+ return stage_prompts.get(prompt_key, "")
320
+ except Exception as e:
321
+ self.logger.warning(f"Failed to load prompt template {prompt_key}: {str(e)}")
322
+ return ""
323
+
324
+ def get_required_fields(self) -> list:
325
+ """
326
+ Get list of required input fields for this stage.
327
+
328
+ Returns:
329
+ List of required field names
330
+ """
331
+ # Default implementation - stages should override this
332
+ return []
333
+
334
+ def validate_required_fields(self, context: Dict[str, Any]) -> list:
335
+ """
336
+ Validate that all required fields are present in the context.
337
+
338
+ Args:
339
+ context: Execution context to validate
340
+
341
+ Returns:
342
+ List of missing required fields
343
+ """
344
+ input_data = context.get('input_data', {})
345
+ required_fields = self.get_required_fields()
346
+ missing_fields = []
347
+
348
+ for field in required_fields:
349
+ if field not in input_data or input_data[field] is None:
350
+ missing_fields.append(field)
351
+
352
+ return missing_fields
353
+
354
+ def validate_context(self, context: Dict[str, Any]) -> tuple[bool, list]:
355
+ """
356
+ Comprehensive context validation.
357
+
358
+ Args:
359
+ context: Execution context to validate
360
+
361
+ Returns:
362
+ Tuple of (is_valid, list_of_errors)
363
+ """
364
+ errors = []
365
+
366
+ # Check for execution ID
367
+ if not context.get('execution_id'):
368
+ errors.append("Missing execution_id in context")
369
+
370
+ # Check for input data
371
+ if 'input_data' not in context:
372
+ errors.append("Missing input_data in context")
373
+
374
+ # Check required fields
375
+ missing_fields = self.validate_required_fields(context)
376
+ if missing_fields:
377
+ errors.append(f"Missing required fields: {', '.join(missing_fields)}")
378
+
379
+ # Stage-specific validation
380
+ if not self.validate_input(context):
381
+ errors.append("Stage-specific input validation failed")
382
+
383
+ return len(errors) == 0, errors
384
+
385
+ def format_prompt(self, template: str, **kwargs) -> str:
386
+ """
387
+ Format prompt template with provided variables.
388
+
389
+ Args:
390
+ template: Prompt template string
391
+ **kwargs: Variables to substitute in template
392
+
393
+ Returns:
394
+ Formatted prompt string
395
+ """
396
+ try:
397
+ return template.format(**kwargs)
398
+ except KeyError as e:
399
+ self.logger.warning(f"Missing variable in prompt template: {str(e)}")
400
+ return template
401
+
402
+ def generate_execution_id(self) -> str:
403
+ """Generate unique execution ID."""
404
+ return f"{self.stage_name}_{datetime.now().strftime('%Y%m%d_%H%M%S')}_{str(uuid.uuid4())[:8]}"
405
+
406
+ def should_stop_pipeline(self, result: Dict[str, Any]) -> bool:
407
+ """
408
+ Determine if pipeline should stop based on stage result.
409
+
410
+ Args:
411
+ result: Stage execution result
412
+
413
+ Returns:
414
+ True if pipeline should stop, False otherwise
415
+ """
416
+ # Stop if stage failed
417
+ if result.get('status') in ['fail', 'error']:
418
+ return True
419
+
420
+ # Stop if explicit stop condition from business logic
421
+ if result.get('pipeline_stop', False):
422
+ return True
423
+
424
+ # Stop if explicit stop condition (legacy)
425
+ if result.get('stop_pipeline', False):
426
+ return True
427
+
428
+ return False
429
+
430
+ def create_error_result(self, error: Exception, context: Dict[str, Any]) -> Dict[str, Any]:
431
+ """
432
+ Create standardized error result.
433
+
434
+ Args:
435
+ error: Exception that occurred
436
+ context: Execution context
437
+
438
+ Returns:
439
+ Error result dictionary
440
+ """
441
+ return {
442
+ 'status': 'error',
443
+ 'error_type': type(error).__name__,
444
+ 'error_message': str(error),
445
+ 'stage': self.stage_name,
446
+ 'execution_id': context.get('execution_id'),
447
+ 'timestamp': datetime.now().isoformat()
448
+ }
449
+
450
+ def create_success_result(self, data: Dict[str, Any], context: Dict[str, Any]) -> Dict[str, Any]:
451
+ """
452
+ Create standardized success result.
453
+
454
+ Args:
455
+ data: Stage output data
456
+ context: Execution context
457
+
458
+ Returns:
459
+ Success result dictionary
460
+ """
461
+ return {
462
+ 'status': 'success',
463
+ 'stage': self.stage_name,
464
+ 'execution_id': context.get('execution_id'),
465
+ 'timestamp': datetime.now().isoformat(),
466
+ 'data': data
467
+ }
468
+
469
+ def create_skip_result(self, reason: str, context: Dict[str, Any]) -> Dict[str, Any]:
470
+ """
471
+ Create standardized skip result.
472
+
473
+ Args:
474
+ reason: Reason for skipping the stage
475
+ context: Execution context
476
+
477
+ Returns:
478
+ Skip result dictionary
479
+ """
480
+ return {
481
+ 'status': 'skipped',
482
+ 'reason': reason,
483
+ 'stage': self.stage_name,
484
+ 'execution_id': context.get('execution_id'),
485
+ 'timestamp': datetime.now().isoformat()
486
+ }
487
+
488
+ def handle_stage_error(self, error: Exception, context: Dict[str, Any]) -> Dict[str, Any]:
489
+ """
490
+ Comprehensive error handling for stage execution.
491
+
492
+ Args:
493
+ error: Exception that occurred
494
+ context: Execution context
495
+
496
+ Returns:
497
+ Error result dictionary
498
+ """
499
+ # Log the error
500
+ self.log_stage_error(context, error)
501
+
502
+ # Save error to database if possible
503
+ try:
504
+ self.data_manager.save_stage_result(
505
+ execution_id=context.get('execution_id'),
506
+ stage_name=self.stage_name,
507
+ input_data=context.get('input_data', {}),
508
+ output_data={'error': str(error)},
509
+ status='error',
510
+ error_message=str(error)
511
+ )
512
+ except Exception as save_error:
513
+ self.logger.warning(f"Failed to save error result: {str(save_error)}")
514
+
515
+ # Return standardized error result
516
+ return self.create_error_result(error, context)
517
+
518
+ def get_stage_config(self, key: str, default: Any = None) -> Any:
519
+ """
520
+ Get stage-specific configuration value.
521
+
522
+ Args:
523
+ key: Configuration key
524
+ default: Default value if key not found
525
+
526
+ Returns:
527
+ Configuration value
528
+ """
529
+ stage_config = self.config.get('stages', {}).get(self.stage_name, {})
530
+ return stage_config.get(key, default)
531
+
532
+ def is_dry_run(self) -> bool:
533
+ """
534
+ Check if this is a dry run execution.
535
+
536
+ Returns:
537
+ True if dry run mode is enabled
538
+ """
539
+ return self.config.get('dry_run', False)
540
+
541
+ def get_team_settings(self, team_id: str = None) -> Optional[Dict[str, Any]]:
542
+ """
543
+ Get team settings for the current execution.
544
+
545
+ Args:
546
+ team_id: Team ID to get settings for. If None, uses team_id from config.
547
+
548
+ Returns:
549
+ Team settings dictionary or None if not found
550
+ """
551
+ if not team_id:
552
+ team_id = self.config.get('team_id')
553
+
554
+ if not team_id:
555
+ return None
556
+
557
+ try:
558
+ settings = self.data_manager.get_team_settings(team_id)
559
+ if settings:
560
+ self.logger.debug(f"Loaded team settings for team: {team_id}")
561
+ else:
562
+ self.logger.debug(f"No team settings found for team: {team_id}")
563
+ return settings
564
+ except Exception as e:
565
+ self.logger.warning(f"Failed to load team settings for team {team_id}: {str(e)}")
566
+ return None
567
+
568
+ def get_team_setting(self, setting_name: str, team_id: str = None, default: Any = None) -> Any:
569
+ """
570
+ Get a specific team setting value.
571
+
572
+ Args:
573
+ setting_name: Name of the setting to retrieve
574
+ team_id: Team ID to get settings for. If None, uses team_id from config.
575
+ default: Default value if setting not found
576
+
577
+ Returns:
578
+ Setting value or default
579
+ """
580
+ team_settings = self.get_team_settings(team_id)
581
+ if team_settings and setting_name in team_settings:
582
+ return team_settings[setting_name]
583
+ return default
584
+
585
+ def get_execution_metadata(self, context: Dict[str, Any]) -> Dict[str, Any]:
586
+ """
587
+ Get execution metadata for logging and tracking.
588
+
589
+ Args:
590
+ context: Execution context
591
+
592
+ Returns:
593
+ Metadata dictionary
594
+ """
595
+ return {
596
+ 'execution_id': context.get('execution_id'),
597
+ 'stage': self.stage_name,
598
+ 'org_id': self.config.get('org_id'),
599
+ 'org_name': self.config.get('org_name'),
600
+ 'customer_name': context.get('input_data', {}).get('customer_name'),
601
+ 'timestamp': datetime.now().isoformat(),
602
+ 'dry_run': self.is_dry_run()
603
+ }