fusesell 1.2.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.

@@ -0,0 +1,932 @@
1
+ """
2
+ FuseSell Pipeline Orchestrator
3
+ Manages the execution of all pipeline stages in sequence
4
+ """
5
+
6
+ from typing import Dict, Any, List, Optional
7
+ import time
8
+ from datetime import datetime
9
+ import uuid
10
+
11
+ from .stages import (
12
+ DataAcquisitionStage,
13
+ DataPreparationStage,
14
+ LeadScoringStage,
15
+ InitialOutreachStage,
16
+ FollowUpStage
17
+ )
18
+ from .utils.data_manager import LocalDataManager
19
+ from .utils.logger import get_logger, log_execution_start, log_execution_complete
20
+ from .utils.validators import InputValidator
21
+
22
+
23
+ class FuseSellPipeline:
24
+ """
25
+ Main pipeline orchestrator for FuseSell local execution.
26
+ Manages stage execution, data flow, and error handling.
27
+ """
28
+
29
+ def __init__(self, config: Dict[str, Any]):
30
+ """
31
+ Initialize pipeline with configuration.
32
+
33
+ Args:
34
+ config: Pipeline configuration dictionary
35
+ """
36
+ self.config = config
37
+ self.execution_id = config.get('execution_id') or self._generate_execution_id()
38
+ self.logger = get_logger("pipeline")
39
+
40
+ # Initialize components
41
+ self.data_manager = LocalDataManager(config.get('data_dir', './fusesell_data'))
42
+ self.validator = InputValidator()
43
+
44
+ # Initialize stages
45
+ self.stages = self._initialize_stages()
46
+
47
+ # Execution state
48
+ self.stage_results = {}
49
+ self.start_time = None
50
+ self.end_time = None
51
+
52
+ def _initialize_stages(self) -> List:
53
+ """
54
+ Initialize all pipeline stages.
55
+
56
+ Returns:
57
+ List of initialized stage instances
58
+ """
59
+ stages = []
60
+
61
+ # Only initialize stages that are not skipped
62
+ skip_stages = self.config.get('skip_stages', [])
63
+ stop_after = self.config.get('stop_after')
64
+
65
+ stage_classes = [
66
+ ('data_acquisition', DataAcquisitionStage),
67
+ ('data_preparation', DataPreparationStage),
68
+ ('lead_scoring', LeadScoringStage),
69
+ ('initial_outreach', InitialOutreachStage),
70
+ ('follow_up', FollowUpStage)
71
+ ]
72
+
73
+ for stage_name, stage_class in stage_classes:
74
+ if stage_name not in skip_stages:
75
+ try:
76
+ # Pass shared data_manager instance to avoid multiple database initializations
77
+ stage = stage_class(self.config, self.data_manager)
78
+ stages.append(stage)
79
+ self.logger.debug(f"Initialized {stage_name} stage with shared data manager")
80
+ except Exception as e:
81
+ self.logger.error(f"Failed to initialize {stage_name} stage: {str(e)}")
82
+ raise
83
+
84
+ # Stop adding stages if we've reached the stop point
85
+ if stop_after == stage_name:
86
+ break
87
+
88
+ return stages
89
+
90
+ def execute(self) -> Dict[str, Any]:
91
+ """
92
+ Execute the complete pipeline or continue existing execution.
93
+
94
+ Returns:
95
+ Dictionary containing execution results
96
+ """
97
+ self.start_time = time.time()
98
+
99
+ try:
100
+ # Check if this is a continuation
101
+ if self.config.get('continue_execution'):
102
+ return self._continue_execution()
103
+
104
+ # New execution flow
105
+ # Validate configuration
106
+ self._validate_configuration()
107
+
108
+ # Log execution start
109
+ log_execution_start(self.execution_id, self.config)
110
+
111
+ # Save execution record
112
+ self._save_execution_record()
113
+
114
+ # Create execution context
115
+ context = self._create_execution_context()
116
+
117
+ # Execute stages sequentially
118
+ runtime_index = 0
119
+ for stage in self.stages:
120
+ # Add runtime_index to context for operation tracking
121
+ context['runtime_index'] = runtime_index
122
+
123
+ stage_result = self._execute_stage(stage, context)
124
+
125
+ # Update context with stage results
126
+ context['stage_results'][stage.stage_name] = stage_result
127
+
128
+ # Update task runtime index
129
+ try:
130
+ self.data_manager.update_task_status(
131
+ task_id=self.execution_id,
132
+ status="running",
133
+ runtime_index=runtime_index
134
+ )
135
+ except Exception as e:
136
+ self.logger.warning(f"Failed to update task runtime index: {str(e)}")
137
+
138
+ # Check if pipeline should stop
139
+ if stage.should_stop_pipeline(stage_result):
140
+ self.logger.warning(f"Pipeline stopped after {stage.stage_name} stage")
141
+ break
142
+
143
+ runtime_index += 1
144
+
145
+ # Compile final results
146
+ results = self._compile_results(context)
147
+
148
+ # Note: executions is now a view - status updated via llm_worker_task
149
+
150
+ # Update task status (correct schema)
151
+ try:
152
+ self.data_manager.update_task_status(
153
+ task_id=self.execution_id,
154
+ status="completed",
155
+ runtime_index=runtime_index
156
+ )
157
+ except Exception as e:
158
+ self.logger.warning(f"Failed to update final task status: {str(e)}")
159
+
160
+ return results
161
+
162
+ except Exception as e:
163
+ self.logger.error(f"Pipeline execution failed: {str(e)}")
164
+
165
+ # Update task status to failed
166
+ try:
167
+ self.data_manager.update_task_status(
168
+ task_id=self.execution_id,
169
+ status="failed",
170
+ runtime_index=0
171
+ )
172
+ except Exception as update_error:
173
+ self.logger.warning(f"Failed to update task status to failed: {str(update_error)}")
174
+
175
+ error_result = {
176
+ 'error': str(e),
177
+ 'error_type': type(e).__name__,
178
+ 'stage_results': self.stage_results
179
+ }
180
+
181
+ return error_result
182
+
183
+ finally:
184
+ self.end_time = time.time()
185
+ duration = self.end_time - self.start_time if self.start_time else 0
186
+
187
+ status = 'completed' if not hasattr(self, '_failed') else 'failed'
188
+ log_execution_complete(self.execution_id, status, duration)
189
+
190
+ # Generate performance analytics
191
+ self._log_performance_analytics(duration)
192
+
193
+ def _log_performance_analytics(self, total_duration: float) -> None:
194
+ """
195
+ Log detailed performance analytics for the pipeline execution.
196
+
197
+ Args:
198
+ total_duration: Total pipeline execution time in seconds
199
+ """
200
+ try:
201
+ # Collect timing data from stage results
202
+ stage_timings = []
203
+ total_stage_time = 0.0
204
+
205
+ for stage_name, result in self.stage_results.items():
206
+ if isinstance(result, dict) and 'timing' in result:
207
+ timing = result['timing']
208
+ duration = timing.get('duration_seconds', 0.0)
209
+ stage_timings.append({
210
+ 'stage': stage_name,
211
+ 'duration': duration,
212
+ 'percentage': (duration / total_duration * 100) if total_duration > 0 else 0
213
+ })
214
+ total_stage_time += duration
215
+
216
+ # Log performance summary
217
+ self.logger.info("=" * 60)
218
+ self.logger.info(f"PERFORMANCE ANALYTICS - Execution {self.execution_id}")
219
+ self.logger.info("=" * 60)
220
+ self.logger.info(f"Total Pipeline Duration: {total_duration:.2f} seconds")
221
+ self.logger.info(f"Total Stage Duration: {total_stage_time:.2f} seconds")
222
+
223
+ if total_duration > 0:
224
+ overhead = total_duration - total_stage_time
225
+ overhead_pct = (overhead / total_duration * 100)
226
+ self.logger.info(f"Pipeline Overhead: {overhead:.2f} seconds ({overhead_pct:.1f}%)")
227
+
228
+ self.logger.info("-" * 40)
229
+ self.logger.info("Stage Performance Breakdown:")
230
+
231
+ for timing in sorted(stage_timings, key=lambda x: x['duration'], reverse=True):
232
+ self.logger.info(f" {timing['stage']:<20}: {timing['duration']:>6.2f}s ({timing['percentage']:>5.1f}%)")
233
+
234
+ # Performance insights
235
+ if stage_timings:
236
+ slowest_stage = max(stage_timings, key=lambda x: x['duration'])
237
+ fastest_stage = min(stage_timings, key=lambda x: x['duration'])
238
+
239
+ self.logger.info("-" * 40)
240
+ self.logger.info(f"Slowest Stage: {slowest_stage['stage']} ({slowest_stage['duration']:.2f}s)")
241
+ self.logger.info(f"Fastest Stage: {fastest_stage['stage']} ({fastest_stage['duration']:.2f}s)")
242
+
243
+ if slowest_stage['duration'] > 0 and fastest_stage['duration'] > 0:
244
+ ratio = slowest_stage['duration'] / fastest_stage['duration']
245
+ self.logger.info(f"Performance Ratio: {ratio:.1f}x difference")
246
+
247
+ # Validation: Total time should roughly equal sum of stage durations
248
+ if total_duration > 0:
249
+ time_discrepancy = abs(total_duration - total_stage_time)
250
+ discrepancy_percentage = (time_discrepancy / total_duration * 100)
251
+
252
+ self.logger.info("-" * 40)
253
+ self.logger.info("TIMING VALIDATION:")
254
+ if discrepancy_percentage < 5.0:
255
+ self.logger.info(f"✅ Timing validation PASSED (discrepancy: {discrepancy_percentage:.1f}%)")
256
+ else:
257
+ self.logger.warning(f"⚠️ Timing validation WARNING (discrepancy: {discrepancy_percentage:.1f}%)")
258
+ self.logger.warning(f" Expected ~{total_stage_time:.2f}s, got {total_duration:.2f}s")
259
+
260
+ self.logger.info("=" * 60)
261
+
262
+ except Exception as e:
263
+ self.logger.warning(f"Failed to generate performance analytics: {str(e)}")
264
+
265
+ def _generate_performance_analytics(self, total_duration: float) -> Dict[str, Any]:
266
+ """
267
+ Generate performance analytics data for inclusion in results.
268
+
269
+ Args:
270
+ total_duration: Total pipeline execution time in seconds
271
+
272
+ Returns:
273
+ Performance analytics dictionary
274
+ """
275
+ try:
276
+ # Collect timing data from stage results
277
+ stage_timings = []
278
+ total_stage_time = 0.0
279
+
280
+ for stage_name, result in self.stage_results.items():
281
+ if isinstance(result, dict) and 'timing' in result:
282
+ timing = result['timing']
283
+ duration = timing.get('duration_seconds', 0.0)
284
+ stage_timings.append({
285
+ 'stage': stage_name,
286
+ 'duration_seconds': duration,
287
+ 'percentage_of_total': (duration / total_duration * 100) if total_duration > 0 else 0,
288
+ 'start_time': timing.get('start_time'),
289
+ 'end_time': timing.get('end_time')
290
+ })
291
+ total_stage_time += duration
292
+
293
+ # Calculate overhead
294
+ overhead = total_duration - total_stage_time
295
+ overhead_percentage = (overhead / total_duration * 100) if total_duration > 0 else 0
296
+
297
+ # Find performance insights
298
+ insights = {}
299
+ if stage_timings:
300
+ slowest_stage = max(stage_timings, key=lambda x: x['duration_seconds'])
301
+ fastest_stage = min(stage_timings, key=lambda x: x['duration_seconds'])
302
+
303
+ insights = {
304
+ 'slowest_stage': {
305
+ 'name': slowest_stage['stage'],
306
+ 'duration_seconds': slowest_stage['duration_seconds']
307
+ },
308
+ 'fastest_stage': {
309
+ 'name': fastest_stage['stage'],
310
+ 'duration_seconds': fastest_stage['duration_seconds']
311
+ },
312
+ 'performance_ratio': (slowest_stage['duration_seconds'] / fastest_stage['duration_seconds'])
313
+ if fastest_stage['duration_seconds'] > 0 else 0
314
+ }
315
+
316
+ return {
317
+ 'total_duration_seconds': total_duration,
318
+ 'total_stage_duration_seconds': total_stage_time,
319
+ 'pipeline_overhead_seconds': overhead,
320
+ 'pipeline_overhead_percentage': overhead_percentage,
321
+ 'stage_count': len(stage_timings),
322
+ 'average_stage_duration': total_stage_time / len(stage_timings) if stage_timings else 0,
323
+ 'stage_timings': stage_timings,
324
+ 'performance_insights': insights,
325
+ 'generated_at': datetime.now().isoformat()
326
+ }
327
+
328
+ except Exception as e:
329
+ self.logger.warning(f"Failed to generate performance analytics data: {str(e)}")
330
+ return {
331
+ 'error': str(e),
332
+ 'total_duration_seconds': total_duration,
333
+ 'generated_at': datetime.now().isoformat()
334
+ }
335
+
336
+ def _validate_configuration(self) -> None:
337
+ """
338
+ Validate pipeline configuration.
339
+
340
+ Raises:
341
+ ValueError: If configuration is invalid
342
+ """
343
+ errors = self.validator.validate_config(self.config)
344
+ if errors:
345
+ error_msg = "Configuration validation failed:\n" + "\n".join(f"- {error}" for error in errors)
346
+ raise ValueError(error_msg)
347
+
348
+ def _create_execution_context(self) -> Dict[str, Any]:
349
+ """
350
+ Create execution context for pipeline stages.
351
+
352
+ Returns:
353
+ Execution context dictionary
354
+ """
355
+ return {
356
+ 'execution_id': self.execution_id,
357
+ 'config': self.config,
358
+ 'input_data': self._extract_input_data(),
359
+ 'stage_results': {},
360
+ 'data_manager': self.data_manager,
361
+ 'start_time': self.start_time
362
+ }
363
+
364
+ def _extract_input_data(self) -> Dict[str, Any]:
365
+ """
366
+ Extract input data from configuration.
367
+
368
+ Returns:
369
+ Input data dictionary
370
+ """
371
+ return {
372
+ 'org_id': self.config['org_id'],
373
+ 'org_name': self.config['org_name'],
374
+ 'team_id': self.config.get('team_id'),
375
+ 'team_name': self.config.get('team_name'),
376
+ 'project_code': self.config.get('project_code'),
377
+ 'staff_name': self.config.get('staff_name', 'Sales Team'),
378
+ 'language': self.config.get('language', 'english'),
379
+ # Data sources (matching executor schema)
380
+ 'input_website': self.config.get('input_website', ''),
381
+ 'input_description': self.config.get('input_description', ''),
382
+ 'input_business_card': self.config.get('input_business_card', ''),
383
+ 'input_linkedin_url': self.config.get('input_linkedin_url', ''),
384
+ 'input_facebook_url': self.config.get('input_facebook_url', ''),
385
+ 'input_freetext': self.config.get('input_freetext', ''),
386
+
387
+ # Context fields
388
+ 'customer_id': self.config.get('customer_id', 'null'),
389
+ 'full_input': self.config.get('full_input'),
390
+
391
+ # Action and continuation fields (for server executor compatibility)
392
+ 'action': self.config.get('action', 'draft_write'),
393
+ 'selected_draft_id': self.config.get('selected_draft_id', ''),
394
+ 'reason': self.config.get('reason', ''),
395
+ 'recipient_address': self.config.get('recipient_address', ''),
396
+ 'recipient_name': self.config.get('recipient_name', ''),
397
+ 'interaction_type': self.config.get('interaction_type', 'email'),
398
+ 'human_action_id': self.config.get('human_action_id', ''),
399
+
400
+ # Scheduling preferences
401
+ 'send_immediately': self.config.get('send_immediately', False),
402
+ 'customer_timezone': self.config.get('customer_timezone', ''),
403
+ 'business_hours_start': self.config.get('business_hours_start', '08:00'),
404
+ 'business_hours_end': self.config.get('business_hours_end', '20:00'),
405
+ 'delay_hours': self.config.get('delay_hours', 2)
406
+ }
407
+
408
+ def _execute_stage(self, stage, context: Dict[str, Any]) -> Dict[str, Any]:
409
+ """
410
+ Execute a single pipeline stage with business logic validation.
411
+
412
+ Args:
413
+ stage: Stage instance to execute
414
+ context: Execution context
415
+
416
+ Returns:
417
+ Stage execution result
418
+ """
419
+ stage_start_time = time.time()
420
+
421
+ operation_id = None
422
+ try:
423
+ self.logger.info(f"Executing {stage.stage_name} stage")
424
+
425
+ # Apply business logic pre-checks
426
+ if not self._should_execute_stage(stage.stage_name, context):
427
+ skip_result = {
428
+ 'status': 'skipped',
429
+ 'reason': 'Business logic condition not met',
430
+ 'stage': stage.stage_name,
431
+ 'timestamp': datetime.now().isoformat()
432
+ }
433
+ self.stage_results[stage.stage_name] = skip_result
434
+ return skip_result
435
+
436
+ # Prepare stage input
437
+ stage_input = self._prepare_stage_input(stage.stage_name, context)
438
+ validation_errors = self.validator.validate_stage_input(stage.stage_name, stage_input)
439
+
440
+ if validation_errors:
441
+ error_msg = f"Stage input validation failed: {'; '.join(validation_errors)}"
442
+ raise ValueError(error_msg)
443
+
444
+ # Create operation record (server-compatible tracking)
445
+ try:
446
+ runtime_index = context.get('runtime_index', 0)
447
+ chain_index = len([s for s in self.stage_results.keys()]) # Current position in chain
448
+
449
+ operation_id = self.data_manager.create_operation(
450
+ task_id=self.execution_id,
451
+ executor_name=stage.stage_name,
452
+ runtime_index=runtime_index,
453
+ chain_index=chain_index,
454
+ input_data=stage_input
455
+ )
456
+
457
+ # Add operation_id to context for stage use
458
+ context['operation_id'] = operation_id
459
+
460
+ except Exception as e:
461
+ self.logger.warning(f"Failed to create operation record: {str(e)}")
462
+ operation_id = None
463
+
464
+ # Execute stage with timing
465
+ result = stage.execute_with_timing(context)
466
+
467
+ # Update operation with success result
468
+ if operation_id:
469
+ try:
470
+ self.data_manager.update_operation_status(
471
+ operation_id=operation_id,
472
+ execution_status='done',
473
+ output_data=result
474
+ )
475
+ except Exception as e:
476
+ self.logger.warning(f"Failed to update operation status: {str(e)}")
477
+
478
+ # Apply business logic post-checks
479
+ if self._should_stop_after_stage(stage.stage_name, result, context):
480
+ result['pipeline_stop'] = True
481
+ result['stop_reason'] = self._get_stop_reason(stage.stage_name, result)
482
+
483
+ # Save stage result if configured (backward compatibility)
484
+ if self.config.get('save_intermediate', True):
485
+ stage.save_stage_result(context, result)
486
+
487
+ # Store result
488
+ self.stage_results[stage.stage_name] = result
489
+
490
+ return result
491
+
492
+ except Exception as e:
493
+ stage.log_stage_error(context, e)
494
+
495
+ error_result = stage.create_error_result(e, context)
496
+ self.stage_results[stage.stage_name] = error_result
497
+
498
+ # Update operation with failure result
499
+ if operation_id:
500
+ try:
501
+ error_output = {
502
+ 'error': str(e),
503
+ 'error_type': type(e).__name__,
504
+ 'stage': stage.stage_name,
505
+ 'timestamp': datetime.now().isoformat()
506
+ }
507
+ self.data_manager.update_operation_status(
508
+ operation_id=operation_id,
509
+ execution_status='failed',
510
+ output_data=error_output
511
+ )
512
+ except Exception as update_error:
513
+ self.logger.warning(f"Failed to update operation failure status: {str(update_error)}")
514
+
515
+ # Save error result (backward compatibility)
516
+ if self.config.get('save_intermediate', True):
517
+ stage.save_stage_result(context, error_result)
518
+
519
+ raise
520
+
521
+ finally:
522
+ stage_duration = time.time() - stage_start_time
523
+ self.logger.debug(f"Stage {stage.stage_name} completed in {stage_duration:.2f} seconds")
524
+
525
+ def _prepare_stage_input(self, stage_name: str, context: Dict[str, Any]) -> Dict[str, Any]:
526
+ """
527
+ Prepare input data for a specific stage.
528
+
529
+ Args:
530
+ stage_name: Name of the stage
531
+ context: Execution context
532
+
533
+ Returns:
534
+ Stage-specific input data
535
+ """
536
+ base_input = context['input_data'].copy()
537
+ stage_results = context['stage_results']
538
+
539
+ # Add stage-specific data based on previous results
540
+ if stage_name == 'data_preparation' and 'data_acquisition' in stage_results:
541
+ acquisition_result = stage_results['data_acquisition']
542
+ if acquisition_result.get('status') == 'success':
543
+ base_input['raw_customer_data'] = acquisition_result.get('data', {})
544
+
545
+ elif stage_name == 'lead_scoring' and 'data_preparation' in stage_results:
546
+ prep_result = stage_results['data_preparation']
547
+ if prep_result.get('status') == 'success':
548
+ prep_data = prep_result.get('data', {})
549
+ base_input.update(prep_data)
550
+
551
+ elif stage_name == 'initial_outreach':
552
+ # Combine data from previous stages
553
+ if 'data_preparation' in stage_results:
554
+ prep_data = stage_results['data_preparation'].get('data', {})
555
+ base_input['customer_data'] = prep_data
556
+
557
+ if 'lead_scoring' in stage_results:
558
+ scoring_data = stage_results['lead_scoring'].get('data', {})
559
+ base_input['lead_scores'] = scoring_data.get('scores', [])
560
+
561
+ elif stage_name == 'follow_up':
562
+ # Add interaction history from previous outreach
563
+ if 'initial_outreach' in stage_results:
564
+ outreach_data = stage_results['initial_outreach'].get('data', {})
565
+ base_input['previous_interactions'] = [outreach_data]
566
+
567
+ return base_input
568
+
569
+ def _compile_results(self, context: Dict[str, Any]) -> Dict[str, Any]:
570
+ """
571
+ Compile final execution results.
572
+
573
+ Args:
574
+ context: Execution context
575
+
576
+ Returns:
577
+ Compiled results dictionary
578
+ """
579
+ duration = time.time() - self.start_time if self.start_time else 0
580
+
581
+ # Determine overall status
582
+ status = 'completed'
583
+ for stage_result in self.stage_results.values():
584
+ if stage_result.get('status') in ['error', 'fail']:
585
+ status = 'failed'
586
+ break
587
+
588
+ # Extract key data from stage results
589
+ customer_data = {}
590
+ lead_scores = []
591
+ email_drafts = []
592
+
593
+ for stage_name, result in self.stage_results.items():
594
+ if result.get('status') == 'success':
595
+ data = result.get('data', {})
596
+
597
+ if stage_name == 'data_preparation':
598
+ customer_data = data
599
+ elif stage_name == 'lead_scoring':
600
+ lead_scores = data.get('scores', [])
601
+ elif stage_name == 'initial_outreach':
602
+ email_drafts.extend(data.get('drafts', []))
603
+ elif stage_name == 'follow_up':
604
+ email_drafts.extend(data.get('drafts', []))
605
+
606
+ # Generate performance analytics
607
+ performance_analytics = self._generate_performance_analytics(duration)
608
+
609
+ return {
610
+ 'execution_id': self.execution_id,
611
+ 'status': status,
612
+ 'started_at': datetime.fromtimestamp(self.start_time).isoformat() if self.start_time else None,
613
+ 'completed_at': datetime.fromtimestamp(self.end_time).isoformat() if self.end_time else None,
614
+ 'duration_seconds': duration,
615
+ 'performance_analytics': performance_analytics,
616
+ 'config': {
617
+ 'org_id': self.config['org_id'],
618
+ 'org_name': self.config['org_name'],
619
+ 'input_website': self.config.get('input_website'),
620
+ 'language': self.config.get('language', 'english')
621
+ },
622
+ 'stage_results': self.stage_results,
623
+ 'customer_data': customer_data,
624
+ 'lead_scores': lead_scores,
625
+ 'email_drafts': email_drafts,
626
+ 'stages_executed': list(self.stage_results.keys()),
627
+ 'stages_successful': [
628
+ name for name, result in self.stage_results.items()
629
+ if result.get('status') == 'success'
630
+ ]
631
+ }
632
+
633
+ def _save_execution_record(self) -> None:
634
+ """Save initial task record to database (server-compatible schema)."""
635
+ try:
636
+ # Create server-compatible request body
637
+ request_body = {
638
+ 'org_id': self.config['org_id'],
639
+ 'org_name': self.config.get('org_name'),
640
+ 'team_id': self.config.get('team_id'),
641
+ 'team_name': self.config.get('team_name'),
642
+ 'project_code': self.config.get('project_code'),
643
+ 'staff_name': self.config.get('staff_name', 'Sales Team'),
644
+ 'language': self.config.get('language', 'english'),
645
+ 'customer_info': self.config.get('input_description', ''),
646
+ 'input_website': self.config.get('input_website', ''),
647
+ 'input_description': self.config.get('input_description', ''),
648
+ 'input_business_card': self.config.get('input_business_card', ''),
649
+ 'input_linkedin_url': self.config.get('input_linkedin_url', ''),
650
+ 'input_facebook_url': self.config.get('input_facebook_url', ''),
651
+ 'input_freetext': self.config.get('input_freetext', ''),
652
+ 'full_input': self.config.get('full_input', ''),
653
+ 'action': self.config.get('action', 'draft_write'),
654
+ 'execution_id': self.execution_id
655
+ }
656
+
657
+ # Save as task using server-compatible schema
658
+ self.data_manager.create_task(
659
+ task_id=self.execution_id,
660
+ plan_id=self.config.get('plan_id', '569cdcbd-cf6d-4e33-b0b2-d2f6f15a0832'),
661
+ org_id=self.config['org_id'],
662
+ request_body=request_body,
663
+ status="running"
664
+ )
665
+
666
+ # Note: executions is now a view that maps to llm_worker_task
667
+ # No need for separate executions table save
668
+
669
+ except Exception as e:
670
+ self.logger.error(f"Failed to save task record: {str(e)}")
671
+ raise
672
+
673
+ def _update_execution_status(self, status: str, results: Dict[str, Any]) -> None:
674
+ """
675
+ Update execution status in database.
676
+
677
+ Args:
678
+ status: Execution status
679
+ results: Execution results
680
+ """
681
+ try:
682
+ self.data_manager.update_execution_status(
683
+ execution_id=self.execution_id,
684
+ status=status,
685
+ results=results
686
+ )
687
+ except Exception as e:
688
+ self.logger.warning(f"Failed to update execution status: {str(e)}")
689
+
690
+ def _should_execute_stage(self, stage_name: str, context: Dict[str, Any]) -> bool:
691
+ """
692
+ Apply business logic to determine if a stage should execute.
693
+
694
+ Args:
695
+ stage_name: Name of the stage
696
+ context: Execution context
697
+
698
+ Returns:
699
+ True if stage should execute, False otherwise
700
+ """
701
+ stage_results = context.get('stage_results', {})
702
+
703
+ # Data Acquisition: Always execute if it's the first stage
704
+ if stage_name == 'dataacquisition':
705
+ return True
706
+
707
+ # Data Preparation: Requires successful Data Acquisition
708
+ if stage_name == 'datapreparation':
709
+ acquisition_result = stage_results.get('dataacquisition', {})
710
+ return acquisition_result.get('status') == 'success'
711
+
712
+ # Lead Scoring: Requires successful Data Preparation
713
+ if stage_name == 'leadscoring':
714
+ prep_result = stage_results.get('datapreparation', {})
715
+ return prep_result.get('status') == 'success'
716
+
717
+ # Initial Outreach: Requires successful Lead Scoring
718
+ if stage_name == 'initialoutreach':
719
+ scoring_result = stage_results.get('leadscoring', {})
720
+ return scoring_result.get('status') == 'success'
721
+
722
+ # Follow Up: Requires successful Initial Outreach
723
+ if stage_name == 'followup':
724
+ outreach_result = stage_results.get('initialoutreach', {})
725
+ return outreach_result.get('status') == 'success'
726
+
727
+ return True
728
+
729
+ def _should_stop_after_stage(self, stage_name: str, result: Dict[str, Any], context: Dict[str, Any]) -> bool:
730
+ """
731
+ Apply business logic to determine if pipeline should stop after a stage.
732
+
733
+ Args:
734
+ stage_name: Name of the completed stage
735
+ result: Stage execution result
736
+ context: Execution context
737
+
738
+ Returns:
739
+ True if pipeline should stop, False otherwise
740
+ """
741
+ # Critical stop condition: Data Acquisition website failure
742
+ if stage_name == 'dataacquisition':
743
+ if result.get('data', {}).get('status_info_website') == 'fail':
744
+ self.logger.warning("Data Acquisition failed: website extraction failed")
745
+ return True
746
+
747
+ # Business rule: Stop after Initial Outreach draft generation (human-in-the-loop)
748
+ if stage_name == 'initial_outreach':
749
+ if result.get('status') == 'success':
750
+ action = result.get('data', {}).get('action', 'draft_write')
751
+ if action == 'draft_write':
752
+ self.logger.info("Stopping after Initial Outreach draft generation for human review")
753
+ return True
754
+
755
+ # Stop on any error
756
+ if result.get('status') in ['error', 'fail']:
757
+ return True
758
+
759
+ return False
760
+
761
+ def _get_stop_reason(self, stage_name: str, result: Dict[str, Any]) -> str:
762
+ """
763
+ Get the reason for stopping the pipeline.
764
+
765
+ Args:
766
+ stage_name: Name of the stage that triggered the stop
767
+ result: Stage execution result
768
+
769
+ Returns:
770
+ Human-readable stop reason
771
+ """
772
+ if stage_name == 'dataacquisition':
773
+ if result.get('data', {}).get('status_info_website') == 'fail':
774
+ return "Website extraction failed in Data Acquisition stage"
775
+
776
+ if stage_name == 'initial_outreach':
777
+ action = result.get('data', {}).get('action', 'draft_write')
778
+ if action == 'draft_write':
779
+ return "Draft generated in Initial Outreach - waiting for human review"
780
+
781
+ if result.get('status') in ['error', 'fail']:
782
+ return f"Stage {stage_name} failed with error: {result.get('error_message', 'Unknown error')}"
783
+
784
+ return "Pipeline stopped due to business logic condition"
785
+
786
+ def _continue_execution(self) -> Dict[str, Any]:
787
+ """
788
+ Continue an existing execution with a specific action.
789
+
790
+ Returns:
791
+ Dictionary containing execution results
792
+ """
793
+ continue_execution_id = self.config['continue_execution']
794
+ action = self.config['action']
795
+
796
+ self.logger.info(f"Continuing execution {continue_execution_id} with action: {action}")
797
+
798
+ try:
799
+ # Load existing execution data
800
+ existing_execution = self.data_manager.get_execution(continue_execution_id)
801
+ if not existing_execution:
802
+ raise ValueError(f"Execution {continue_execution_id} not found")
803
+
804
+ # Load previous stage results
805
+ stage_results = self.data_manager.get_stage_results(continue_execution_id)
806
+
807
+ # Create continuation context
808
+ context = self._create_continuation_context(existing_execution, stage_results)
809
+
810
+ # Determine which stage to execute based on action
811
+ target_stage = self._get_target_stage_for_action(action)
812
+
813
+ if not target_stage:
814
+ raise ValueError(f"No suitable stage found for action: {action}")
815
+
816
+ # Execute the specific action
817
+ stage_result = self._execute_continuation_action(target_stage, context, action)
818
+
819
+ # Update execution record
820
+ self._update_execution_status('continued', {
821
+ 'action': action,
822
+ 'stage': target_stage.stage_name,
823
+ 'result': stage_result,
824
+ 'continued_from': continue_execution_id
825
+ })
826
+
827
+ return {
828
+ 'execution_id': self.execution_id,
829
+ 'continued_from': continue_execution_id,
830
+ 'action': action,
831
+ 'status': 'completed',
832
+ 'result': stage_result,
833
+ 'timestamp': datetime.now().isoformat()
834
+ }
835
+
836
+ except Exception as e:
837
+ self.logger.error(f"Failed to continue execution: {str(e)}")
838
+ error_result = {
839
+ 'execution_id': self.execution_id,
840
+ 'continued_from': continue_execution_id,
841
+ 'action': action,
842
+ 'status': 'failed',
843
+ 'error': str(e),
844
+ 'timestamp': datetime.now().isoformat()
845
+ }
846
+ self._update_execution_status('failed', error_result)
847
+ return error_result
848
+
849
+ def _create_continuation_context(self, existing_execution: Dict[str, Any], stage_results: List[Dict[str, Any]]) -> Dict[str, Any]:
850
+ """
851
+ Create execution context for continuation.
852
+
853
+ Args:
854
+ existing_execution: Previous execution record
855
+ stage_results: Previous stage results
856
+
857
+ Returns:
858
+ Continuation context
859
+ """
860
+ # Reconstruct stage results dictionary
861
+ results_dict = {}
862
+ for result in stage_results:
863
+ stage_name = result['stage_name']
864
+ results_dict[stage_name] = result['output_data']
865
+
866
+ return {
867
+ 'execution_id': self.execution_id,
868
+ 'original_execution_id': existing_execution['execution_id'],
869
+ 'config': self.config,
870
+ 'original_config': existing_execution.get('config', {}),
871
+ 'stage_results': results_dict,
872
+ 'continuation_action': self.config['action'],
873
+ 'draft_id': self.config.get('draft_id'),
874
+ 'reason': self.config.get('reason', ''),
875
+ 'start_time': self.start_time
876
+ }
877
+
878
+ def _get_target_stage_for_action(self, action: str):
879
+ """
880
+ Get the appropriate stage for the given action.
881
+
882
+ Args:
883
+ action: Action to perform
884
+
885
+ Returns:
886
+ Stage instance or None
887
+ """
888
+ # Map actions to stages
889
+ action_stage_map = {
890
+ 'draft_write': 'initialoutreach',
891
+ 'draft_rewrite': 'initialoutreach',
892
+ 'send': 'initialoutreach',
893
+ 'close': 'initialoutreach'
894
+ }
895
+
896
+ target_stage_name = action_stage_map.get(action)
897
+ if not target_stage_name:
898
+ return None
899
+
900
+ # Find the stage instance
901
+ for stage in self.stages:
902
+ if stage.stage_name == target_stage_name:
903
+ return stage
904
+
905
+ return None
906
+
907
+ def _execute_continuation_action(self, stage, context: Dict[str, Any], action: str) -> Dict[str, Any]:
908
+ """
909
+ Execute a specific continuation action.
910
+
911
+ Args:
912
+ stage: Stage instance to execute
913
+ context: Continuation context
914
+ action: Specific action to perform
915
+
916
+ Returns:
917
+ Action execution result
918
+ """
919
+ self.logger.info(f"Executing continuation action: {action}")
920
+
921
+ # Add action-specific context
922
+ context['action'] = action
923
+ context['is_continuation'] = True
924
+
925
+ # Execute the stage with continuation context and timing
926
+ return stage.execute_with_timing(context)
927
+
928
+ def _generate_execution_id(self) -> str:
929
+ """Generate unique execution ID."""
930
+ timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
931
+ unique_id = str(uuid.uuid4())[:8]
932
+ return f"fusesell_{timestamp}_{unique_id}"