Graham Paasch commited on
Commit
e085479
·
1 Parent(s): 8ab2fb2

Fix ray module import error and add interactive consultation UI

Browse files

- Make ray import optional in agent/ray_executor.py with graceful fallback
- Add try/except guards in agent/pipeline_engine.py for ray_executor usage
- Add interactive consultation tab to app.py with chat interface
- Users can now have back-and-forth conversation before running pipeline
- Consultation summary can be extracted and used in full pipeline

Files changed (3) hide show
  1. agent/pipeline_engine.py +23 -7
  2. agent/ray_executor.py +509 -446
  3. app.py +156 -0
agent/pipeline_engine.py CHANGED
@@ -194,10 +194,15 @@ class OvergrowthPipeline:
194
  self.rca_analyzer = RootCauseAnalyzer(self.incident_db)
195
  self.test_generator = RegressionTestGenerator()
196
 
197
- # Ray distributed execution
198
- from agent.ray_executor import RayExecutor
199
- self.ray_executor = RayExecutor()
200
- self.parallel_mode = False # Enable for fleet operations
 
 
 
 
 
201
 
202
  def stage0_preflight(self, model: NetworkModel) -> Dict[str, Any]:
203
  """
@@ -326,8 +331,8 @@ class OvergrowthPipeline:
326
 
327
  Uses parallel execution when parallel_mode=True and >10 devices
328
  """
329
- # Use parallel execution for large fleets
330
- if self.parallel_mode and len(model.devices) > 10:
331
  return self._parallel_config_generation(model)
332
 
333
  configs = {}
@@ -1150,6 +1155,10 @@ Be specific and practical. Use RFC1918 addressing. Consider scalability and secu
1150
  Args:
1151
  ray_address: Ray cluster address (None for local mode)
1152
  """
 
 
 
 
1153
  self.parallel_mode = True
1154
  if ray_address:
1155
  self.ray_executor.ray_address = ray_address
@@ -1163,7 +1172,8 @@ Be specific and practical. Use RFC1918 addressing. Consider scalability and secu
1163
  def disable_parallel_mode(self):
1164
  """Disable parallel execution mode"""
1165
  self.parallel_mode = False
1166
- self.ray_executor.shutdown()
 
1167
  logger.info("Parallel mode disabled")
1168
 
1169
  def parallel_deploy_fleet(self, model: NetworkModel,
@@ -1186,6 +1196,12 @@ Be specific and practical. Use RFC1918 addressing. Consider scalability and secu
1186
  logger.warning("Parallel mode not enabled - enabling automatically")
1187
  self.enable_parallel_mode()
1188
 
 
 
 
 
 
 
1189
  # Generate configs for all devices
1190
  configs = self._generate_configs_for_batfish(model)
1191
 
 
194
  self.rca_analyzer = RootCauseAnalyzer(self.incident_db)
195
  self.test_generator = RegressionTestGenerator()
196
 
197
+ # Ray distributed execution (optional)
198
+ try:
199
+ from agent.ray_executor import RayExecutor
200
+ self.ray_executor = RayExecutor()
201
+ self.parallel_mode = False # Enable for fleet operations
202
+ except (ImportError, NotImplementedError) as e:
203
+ logger.warning(f"Ray executor not available: {e}")
204
+ self.ray_executor = None
205
+ self.parallel_mode = False
206
 
207
  def stage0_preflight(self, model: NetworkModel) -> Dict[str, Any]:
208
  """
 
331
 
332
  Uses parallel execution when parallel_mode=True and >10 devices
333
  """
334
+ # Use parallel execution for large fleets (only if ray_executor available)
335
+ if self.parallel_mode and self.ray_executor and len(model.devices) > 10:
336
  return self._parallel_config_generation(model)
337
 
338
  configs = {}
 
1155
  Args:
1156
  ray_address: Ray cluster address (None for local mode)
1157
  """
1158
+ if not self.ray_executor:
1159
+ logger.error("Ray executor not available - cannot enable parallel mode")
1160
+ return
1161
+
1162
  self.parallel_mode = True
1163
  if ray_address:
1164
  self.ray_executor.ray_address = ray_address
 
1172
  def disable_parallel_mode(self):
1173
  """Disable parallel execution mode"""
1174
  self.parallel_mode = False
1175
+ if self.ray_executor:
1176
+ self.ray_executor.shutdown()
1177
  logger.info("Parallel mode disabled")
1178
 
1179
  def parallel_deploy_fleet(self, model: NetworkModel,
 
1196
  logger.warning("Parallel mode not enabled - enabling automatically")
1197
  self.enable_parallel_mode()
1198
 
1199
+ if not self.ray_executor:
1200
+ return {
1201
+ 'status': 'error',
1202
+ 'message': 'Ray executor not available - cannot perform parallel deployment'
1203
+ }
1204
+
1205
  # Generate configs for all devices
1206
  configs = self._generate_configs_for_batfish(model)
1207
 
agent/ray_executor.py CHANGED
@@ -10,14 +10,22 @@ Enables parallel execution of:
10
  Works locally (single machine) or on Ray clusters with zero code changes.
11
  """
12
 
13
- import ray
14
- from ray.util.queue import Queue as RayQueue
15
- import time
16
  import logging
17
  from typing import List, Dict, Any, Optional, Callable, Tuple
18
  from dataclasses import dataclass, field
19
  from enum import Enum
20
  import asyncio
 
 
 
 
 
 
 
 
 
 
 
21
  from datetime import datetime
22
 
23
  logger = logging.getLogger(__name__)
@@ -98,513 +106,568 @@ class ExecutionProgress:
98
  }
99
 
100
 
101
- @ray.remote
102
- class ProgressTracker:
103
- """Actor for tracking execution progress across distributed workers"""
104
-
105
- def __init__(self, total_devices: int):
106
- self.progress = ExecutionProgress(total_devices=total_devices)
107
- self.results: List[TaskResult] = []
108
-
109
- def update_status(self, device_id: str, status: TaskStatus):
110
- """Update device status"""
111
- if status == TaskStatus.RUNNING:
112
- self.progress.running += 1
113
- self.progress.pending -= 1
114
- elif status == TaskStatus.SUCCESS:
115
- self.progress.running -= 1
116
- self.progress.completed += 1
117
- elif status == TaskStatus.FAILED:
118
- self.progress.running -= 1
119
- self.progress.failed += 1
120
-
121
- def add_result(self, result: TaskResult):
122
- """Add task result"""
123
- self.results.append(result)
124
-
125
- def get_progress(self) -> Dict[str, Any]:
126
- """Get current progress"""
127
- return self.progress.to_dict()
128
-
129
- def get_results(self) -> List[TaskResult]:
130
- """Get all results"""
131
- return self.results
132
-
133
- def get_failed_devices(self) -> List[str]:
134
- """Get list of failed device IDs"""
135
- return [r.device_id for r in self.results if r.status == TaskStatus.FAILED]
 
 
136
 
137
 
138
- @ray.remote
139
- def generate_device_config(device_id: str, device_data: Dict[str, Any],
140
- template_fn: Callable, progress_tracker: Any) -> TaskResult:
141
- """
142
- Ray remote function for parallel config generation.
143
-
144
- Args:
145
- device_id: Unique device identifier
146
- device_data: Device parameters (hostname, ip, role, etc.)
147
- template_fn: Function to generate config from device data
148
- progress_tracker: Progress tracking actor
149
-
150
- Returns:
151
- TaskResult with generated config or error
152
- """
153
- start_time = time.time()
154
-
155
- try:
156
- # Update status to running
157
- ray.get(progress_tracker.update_status.remote(device_id, TaskStatus.RUNNING))
158
-
159
- # Generate config
160
- config = template_fn(device_data)
161
-
162
- duration = time.time() - start_time
163
- result = TaskResult(
164
- device_id=device_id,
165
- status=TaskStatus.SUCCESS,
166
- result=config,
167
- duration_seconds=duration
168
- )
169
-
170
- # Update status to success
171
- ray.get(progress_tracker.update_status.remote(device_id, TaskStatus.SUCCESS))
172
- ray.get(progress_tracker.add_result.remote(result))
173
-
174
- return result
175
-
176
- except Exception as e:
177
- duration = time.time() - start_time
178
- result = TaskResult(
179
- device_id=device_id,
180
- status=TaskStatus.FAILED,
181
- error=str(e),
182
- duration_seconds=duration
183
- )
184
-
185
- ray.get(progress_tracker.update_status.remote(device_id, TaskStatus.FAILED))
186
- ray.get(progress_tracker.add_result.remote(result))
187
-
188
- return result
189
-
190
-
191
- @ray.remote
192
- def analyze_device_config(device_id: str, config: str,
193
- batfish_client: Any, progress_tracker: Any) -> TaskResult:
194
- """
195
- Ray remote function for parallel Batfish analysis.
196
-
197
- Args:
198
- device_id: Unique device identifier
199
- config: Device configuration to analyze
200
- batfish_client: Batfish client instance
201
- progress_tracker: Progress tracking actor
202
-
203
- Returns:
204
- TaskResult with analysis results or error
205
- """
206
- start_time = time.time()
207
-
208
- try:
209
- ray.get(progress_tracker.update_status.remote(device_id, TaskStatus.RUNNING))
210
-
211
- # Run Batfish analysis
212
- analysis = batfish_client.analyze_configs({device_id: config})
213
-
214
- duration = time.time() - start_time
215
- result = TaskResult(
216
- device_id=device_id,
217
- status=TaskStatus.SUCCESS,
218
- result=analysis,
219
- duration_seconds=duration
220
- )
221
-
222
- ray.get(progress_tracker.update_status.remote(device_id, TaskStatus.SUCCESS))
223
- ray.get(progress_tracker.add_result.remote(result))
224
-
225
- return result
226
 
227
- except Exception as e:
228
- duration = time.time() - start_time
229
- result = TaskResult(
230
- device_id=device_id,
231
- status=TaskStatus.FAILED,
232
- error=str(e),
233
- duration_seconds=duration
234
- )
235
 
236
- ray.get(progress_tracker.update_status.remote(device_id, TaskStatus.FAILED))
237
- ray.get(progress_tracker.add_result.remote(result))
 
 
238
 
239
- return result
240
-
241
-
242
- @ray.remote
243
- def deploy_to_device(device_id: str, config: str,
244
- gns3_client: Any, progress_tracker: Any,
245
- max_retries: int = 3) -> TaskResult:
246
- """
247
- Ray remote function for parallel device deployment.
248
-
249
- Args:
250
- device_id: Unique device identifier
251
- config: Configuration to deploy
252
- gns3_client: GNS3 client instance
253
- progress_tracker: Progress tracking actor
254
- max_retries: Maximum retry attempts on failure
255
-
256
- Returns:
257
- TaskResult with deployment status or error
258
- """
259
- start_time = time.time()
260
- retry_count = 0
261
-
262
- while retry_count <= max_retries:
263
  try:
264
- if retry_count > 0:
265
- ray.get(progress_tracker.update_status.remote(device_id, TaskStatus.RETRYING))
266
- else:
267
- ray.get(progress_tracker.update_status.remote(device_id, TaskStatus.RUNNING))
268
 
269
- # Deploy config to device
270
- deployment_result = gns3_client.apply_config(device_id, config)
271
 
272
  duration = time.time() - start_time
273
  result = TaskResult(
274
  device_id=device_id,
275
  status=TaskStatus.SUCCESS,
276
- result=deployment_result,
277
- duration_seconds=duration,
278
- retry_count=retry_count
279
  )
280
 
 
281
  ray.get(progress_tracker.update_status.remote(device_id, TaskStatus.SUCCESS))
282
  ray.get(progress_tracker.add_result.remote(result))
283
 
284
  return result
285
 
286
  except Exception as e:
287
- retry_count += 1
288
- if retry_count > max_retries:
289
- duration = time.time() - start_time
290
- result = TaskResult(
291
- device_id=device_id,
292
- status=TaskStatus.FAILED,
293
- error=f"Failed after {retry_count} retries: {str(e)}",
294
- duration_seconds=duration,
295
- retry_count=retry_count - 1
296
- )
297
-
298
- ray.get(progress_tracker.update_status.remote(device_id, TaskStatus.FAILED))
299
- ray.get(progress_tracker.add_result.remote(result))
300
-
301
- return result
302
 
303
- # Exponential backoff
304
- time.sleep(2 ** retry_count)
305
 
306
 
307
- class RayExecutor:
308
- """
309
- Distributed execution engine for hyperscale network automation.
310
-
311
- Provides parallel execution of config generation, analysis, and deployment
312
- across thousands of devices using Ray's distributed computing framework.
313
- """
314
-
315
- def __init__(self, ray_address: Optional[str] = None, num_cpus: Optional[int] = None):
316
  """
317
- Initialize Ray executor.
318
 
319
  Args:
320
- ray_address: Ray cluster address (None for local mode)
321
- num_cpus: Number of CPUs to use (None for auto-detect)
 
 
 
 
 
322
  """
323
- self.ray_address = ray_address
324
- self.num_cpus = num_cpus
325
- self.initialized = False
326
- self._progress_tracker = None
327
-
328
- def initialize(self):
329
- """Initialize Ray runtime"""
330
- if self.initialized:
331
- return
332
 
333
  try:
334
- # Check if Ray is already initialized
335
- if ray.is_initialized():
336
- logger.info("Ray already initialized")
337
- else:
338
- # Initialize Ray
339
- if self.ray_address:
340
- # Connect to existing cluster
341
- ray.init(address=self.ray_address)
342
- logger.info(f"Connected to Ray cluster at {self.ray_address}")
343
- else:
344
- # Start local Ray instance
345
- init_kwargs = {}
346
- if self.num_cpus:
347
- init_kwargs['num_cpus'] = self.num_cpus
348
-
349
- ray.init(**init_kwargs)
350
- logger.info(f"Started local Ray instance with {ray.available_resources().get('CPU', 0)} CPUs")
351
 
352
- self.initialized = True
 
353
 
354
- except Exception as e:
355
- logger.error(f"Failed to initialize Ray: {e}")
356
- raise
357
-
358
- def shutdown(self):
359
- """Shutdown Ray runtime"""
360
- if self.initialized and ray.is_initialized():
361
- ray.shutdown()
362
- self.initialized = False
363
- logger.info("Ray shutdown complete")
364
-
365
- def parallel_config_generation(self, devices: List[Dict[str, Any]],
366
- template_fn: Callable,
367
- batch_size: int = 100) -> Tuple[List[TaskResult], ExecutionProgress]:
368
- """
369
- Generate configs for multiple devices in parallel.
370
-
371
- Args:
372
- devices: List of device data dicts
373
- template_fn: Function to generate config from device data
374
- batch_size: Number of devices to process in each batch
375
-
376
- Returns:
377
- Tuple of (results, final_progress)
378
- """
379
- self.initialize()
380
-
381
- # Create progress tracker
382
- progress_tracker = ProgressTracker.remote(total_devices=len(devices))
383
-
384
- # Initialize pending count
385
- ray.get(progress_tracker.update_status.remote("_init_", TaskStatus.PENDING))
386
- for _ in range(len(devices) - 1):
387
- ray.get(progress_tracker.update_status.remote("_init_", TaskStatus.PENDING))
388
-
389
- # Launch parallel tasks
390
- futures = []
391
- for device in devices:
392
- future = generate_device_config.remote(
393
- device_id=device['device_id'],
394
- device_data=device,
395
- template_fn=template_fn,
396
- progress_tracker=progress_tracker
397
  )
398
- futures.append(future)
399
 
400
- # Process in batches to avoid overwhelming the cluster
401
- if len(futures) >= batch_size:
402
- ray.get(futures)
403
- futures = []
404
-
405
- # Wait for remaining tasks
406
- if futures:
407
- ray.get(futures)
408
-
409
- # Get final results
410
- results = ray.get(progress_tracker.get_results.remote())
411
- final_progress = ray.get(progress_tracker.get_progress.remote())
412
-
413
- return results, final_progress
414
-
415
- def parallel_batfish_analysis(self, configs: Dict[str, str],
416
- batfish_client: Any,
417
- batch_size: int = 50) -> Tuple[List[TaskResult], ExecutionProgress]:
418
- """
419
- Analyze configs in parallel using Batfish.
420
-
421
- Args:
422
- configs: Dict mapping device_id to config string
423
- batfish_client: Batfish client instance
424
- batch_size: Number of configs to analyze in each batch
425
-
426
- Returns:
427
- Tuple of (results, final_progress)
428
- """
429
- self.initialize()
430
-
431
- progress_tracker = ProgressTracker.remote(total_devices=len(configs))
432
-
433
- # Initialize pending count
434
- for _ in range(len(configs)):
435
- ray.get(progress_tracker.update_status.remote("_init_", TaskStatus.PENDING))
436
-
437
- # Launch parallel analysis tasks
438
- futures = []
439
- for device_id, config in configs.items():
440
- future = analyze_device_config.remote(
441
  device_id=device_id,
442
- config=config,
443
- batfish_client=batfish_client,
444
- progress_tracker=progress_tracker
445
  )
446
- futures.append(future)
447
 
448
- if len(futures) >= batch_size:
449
- ray.get(futures)
450
- futures = []
451
-
452
- if futures:
453
- ray.get(futures)
454
-
455
- results = ray.get(progress_tracker.get_results.remote())
456
- final_progress = ray.get(progress_tracker.get_progress.remote())
457
-
458
- return results, final_progress
459
-
460
- def parallel_deployment(self, deployments: Dict[str, str],
461
- gns3_client: Any,
462
- batch_size: int = 20,
463
- max_retries: int = 3) -> Tuple[List[TaskResult], ExecutionProgress]:
464
  """
465
- Deploy configs to multiple devices in parallel.
466
 
467
  Args:
468
- deployments: Dict mapping device_id to config string
 
469
  gns3_client: GNS3 client instance
470
- batch_size: Number of devices to deploy to simultaneously
471
- max_retries: Maximum retry attempts per device
472
 
473
  Returns:
474
- Tuple of (results, final_progress)
475
  """
476
- self.initialize()
477
-
478
- progress_tracker = ProgressTracker.remote(total_devices=len(deployments))
479
-
480
- # Initialize pending count
481
- for _ in range(len(deployments)):
482
- ray.get(progress_tracker.update_status.remote("_init_", TaskStatus.PENDING))
483
 
484
- # Launch parallel deployment tasks
485
- futures = []
486
- for device_id, config in deployments.items():
487
- future = deploy_to_device.remote(
488
- device_id=device_id,
489
- config=config,
490
- gns3_client=gns3_client,
491
- progress_tracker=progress_tracker,
492
- max_retries=max_retries
493
- )
494
- futures.append(future)
495
-
496
- # Deploy in smaller batches to avoid overwhelming network
497
- if len(futures) >= batch_size:
498
- ray.get(futures)
499
- futures = []
500
-
501
- if futures:
502
- ray.get(futures)
503
-
504
- results = ray.get(progress_tracker.get_results.remote())
505
- final_progress = ray.get(progress_tracker.get_progress.remote())
506
-
507
- return results, final_progress
508
-
509
- def get_cluster_resources(self) -> Dict[str, Any]:
510
- """Get available cluster resources"""
511
- self.initialize()
512
- return {
513
- 'available': ray.available_resources(),
514
- 'total': ray.cluster_resources()
515
- }
516
-
517
- def staggered_rollout(self, deployments: Dict[str, str],
518
- gns3_client: Any,
519
- stages: List[float] = [0.01, 0.1, 0.5, 1.0],
520
- validation_fn: Optional[Callable] = None) -> Tuple[List[TaskResult], ExecutionProgress]:
 
 
 
 
 
 
 
 
 
521
  """
522
- Deploy to devices in stages with validation between stages.
523
-
524
- Implements canary deployment pattern:
525
- - Stage 1: 1% of fleet
526
- - Stage 2: 10% of fleet
527
- - Stage 3: 50% of fleet
528
- - Stage 4: 100% of fleet
529
 
530
- Args:
531
- deployments: Dict mapping device_id to config
532
- gns3_client: GNS3 client instance
533
- stages: List of percentages for each stage (0.0 to 1.0)
534
- validation_fn: Optional function to validate stage success
535
-
536
- Returns:
537
- Tuple of (results, final_progress)
538
  """
539
- self.initialize()
540
 
541
- device_ids = list(deployments.keys())
542
- total_devices = len(device_ids)
543
- all_results = []
 
 
 
 
 
 
 
 
 
544
 
545
- current_index = 0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
546
 
547
- for stage_pct in stages:
548
- stage_count = int(total_devices * stage_pct) - current_index
549
- if stage_count <= 0:
550
- continue
 
551
 
552
- stage_devices = device_ids[current_index:current_index + stage_count]
553
- stage_deployments = {did: deployments[did] for did in stage_devices}
 
 
554
 
555
- logger.info(f"Starting stage {stage_pct*100}%: deploying to {len(stage_devices)} devices")
 
 
 
556
 
557
- # Deploy this stage
558
- results, progress = self.parallel_deployment(
559
- deployments=stage_deployments,
560
- gns3_client=gns3_client,
561
- batch_size=min(20, len(stage_devices))
562
- )
563
 
564
- all_results.extend(results)
 
 
565
 
566
- # Check for failures
567
- failed_count = sum(1 for r in results if r.status == TaskStatus.FAILED)
568
- failure_rate = failed_count / len(results) if results else 0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
569
 
570
- if failure_rate > 0.1: # More than 10% failure rate
571
- logger.error(f"Stage failed with {failure_rate*100}% failure rate. Stopping rollout.")
572
- # Return partial results
573
- final_progress = ExecutionProgress(
574
- total_devices=total_devices,
575
- completed=sum(1 for r in all_results if r.status == TaskStatus.SUCCESS),
576
- failed=sum(1 for r in all_results if r.status == TaskStatus.FAILED)
 
 
 
 
 
 
 
 
 
 
 
 
 
577
  )
578
- return all_results, final_progress.to_dict()
 
 
 
 
 
579
 
580
- # Run validation if provided
581
- if validation_fn:
582
- try:
583
- if not validation_fn(stage_devices, results):
584
- logger.error("Stage validation failed. Stopping rollout.")
585
- final_progress = ExecutionProgress(
586
- total_devices=total_devices,
587
- completed=sum(1 for r in all_results if r.status == TaskStatus.SUCCESS),
588
- failed=sum(1 for r in all_results if r.status == TaskStatus.FAILED)
589
- )
590
- return all_results, final_progress.to_dict()
591
- except Exception as e:
592
- logger.error(f"Stage validation error: {e}. Stopping rollout.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
593
  final_progress = ExecutionProgress(
594
  total_devices=total_devices,
595
  completed=sum(1 for r in all_results if r.status == TaskStatus.SUCCESS),
596
  failed=sum(1 for r in all_results if r.status == TaskStatus.FAILED)
597
  )
598
  return all_results, final_progress.to_dict()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
599
 
600
- logger.info(f"Stage {stage_pct*100}% completed successfully")
601
- current_index += stage_count
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
602
 
603
- # Create final progress
604
- final_progress = ExecutionProgress(
605
- total_devices=total_devices,
606
- completed=sum(1 for r in all_results if r.status == TaskStatus.SUCCESS),
607
- failed=sum(1 for r in all_results if r.status == TaskStatus.FAILED)
608
- )
 
609
 
610
- return all_results, final_progress.to_dict()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
  Works locally (single machine) or on Ray clusters with zero code changes.
11
  """
12
 
 
 
 
13
  import logging
14
  from typing import List, Dict, Any, Optional, Callable, Tuple
15
  from dataclasses import dataclass, field
16
  from enum import Enum
17
  import asyncio
18
+
19
+ # Optional ray import - gracefully degrade if not available
20
+ try:
21
+ import ray
22
+ from ray.util.queue import Queue as RayQueue
23
+ RAY_AVAILABLE = True
24
+ except ImportError:
25
+ RAY_AVAILABLE = False
26
+ logging.warning("Ray not installed - distributed execution disabled. Install with: pip install ray")
27
+
28
+ import time
29
  from datetime import datetime
30
 
31
  logger = logging.getLogger(__name__)
 
106
  }
107
 
108
 
109
+ # Only define ray-dependent classes if ray is available
110
+ if RAY_AVAILABLE:
111
+ @ray.remote
112
+ class ProgressTracker:
113
+ """Actor for tracking execution progress across distributed workers"""
114
+
115
+ def __init__(self, total_devices: int):
116
+ self.progress = ExecutionProgress(total_devices=total_devices)
117
+ self.results: List[TaskResult] = []
118
+
119
+ def update_status(self, device_id: str, status: TaskStatus):
120
+ """Update device status"""
121
+ if status == TaskStatus.RUNNING:
122
+ self.progress.running += 1
123
+ self.progress.pending -= 1
124
+ elif status == TaskStatus.SUCCESS:
125
+ self.progress.running -= 1
126
+ self.progress.completed += 1
127
+ elif status == TaskStatus.FAILED:
128
+ self.progress.running -= 1
129
+ self.progress.failed += 1
130
+
131
+ def add_result(self, result: TaskResult):
132
+ """Add task result"""
133
+ self.results.append(result)
134
+
135
+ def get_progress(self) -> Dict[str, Any]:
136
+ """Get current progress"""
137
+ return self.progress.to_dict()
138
+
139
+ def get_results(self) -> List[TaskResult]:
140
+ """Get all results"""
141
+ return self.results
142
+
143
+ def get_failed_devices(self) -> List[str]:
144
+ """Get list of failed device IDs"""
145
+ return [r.device_id for r in self.results if r.status == TaskStatus.FAILED]
146
 
147
 
148
+ @ray.remote
149
+ def generate_device_config(device_id: str, device_data: Dict[str, Any],
150
+ template_fn: Callable, progress_tracker: Any) -> TaskResult:
151
+ """
152
+ Ray remote function for parallel config generation.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
153
 
154
+ Args:
155
+ device_id: Unique device identifier
156
+ device_data: Device parameters (hostname, ip, role, etc.)
157
+ template_fn: Function to generate config from device data
158
+ progress_tracker: Progress tracking actor
 
 
 
159
 
160
+ Returns:
161
+ TaskResult with generated config or error
162
+ """
163
+ start_time = time.time()
164
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
165
  try:
166
+ # Update status to running
167
+ ray.get(progress_tracker.update_status.remote(device_id, TaskStatus.RUNNING))
 
 
168
 
169
+ # Generate config
170
+ config = template_fn(device_data)
171
 
172
  duration = time.time() - start_time
173
  result = TaskResult(
174
  device_id=device_id,
175
  status=TaskStatus.SUCCESS,
176
+ result=config,
177
+ duration_seconds=duration
 
178
  )
179
 
180
+ # Update status to success
181
  ray.get(progress_tracker.update_status.remote(device_id, TaskStatus.SUCCESS))
182
  ray.get(progress_tracker.add_result.remote(result))
183
 
184
  return result
185
 
186
  except Exception as e:
187
+ duration = time.time() - start_time
188
+ result = TaskResult(
189
+ device_id=device_id,
190
+ status=TaskStatus.FAILED,
191
+ error=str(e),
192
+ duration_seconds=duration
193
+ )
194
+
195
+ ray.get(progress_tracker.update_status.remote(device_id, TaskStatus.FAILED))
196
+ ray.get(progress_tracker.add_result.remote(result))
 
 
 
 
 
197
 
198
+ return result
 
199
 
200
 
201
+ @ray.remote
202
+ def analyze_device_config(device_id: str, config: str,
203
+ batfish_client: Any, progress_tracker: Any) -> TaskResult:
 
 
 
 
 
 
204
  """
205
+ Ray remote function for parallel Batfish analysis.
206
 
207
  Args:
208
+ device_id: Unique device identifier
209
+ config: Device configuration to analyze
210
+ batfish_client: Batfish client instance
211
+ progress_tracker: Progress tracking actor
212
+
213
+ Returns:
214
+ TaskResult with analysis results or error
215
  """
216
+ start_time = time.time()
 
 
 
 
 
 
 
 
217
 
218
  try:
219
+ ray.get(progress_tracker.update_status.remote(device_id, TaskStatus.RUNNING))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
220
 
221
+ # Run Batfish analysis
222
+ analysis = batfish_client.analyze_configs({device_id: config})
223
 
224
+ duration = time.time() - start_time
225
+ result = TaskResult(
226
+ device_id=device_id,
227
+ status=TaskStatus.SUCCESS,
228
+ result=analysis,
229
+ duration_seconds=duration
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
230
  )
 
231
 
232
+ ray.get(progress_tracker.update_status.remote(device_id, TaskStatus.SUCCESS))
233
+ ray.get(progress_tracker.add_result.remote(result))
234
+
235
+ return result
236
+
237
+ except Exception as e:
238
+ duration = time.time() - start_time
239
+ result = TaskResult(
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
240
  device_id=device_id,
241
+ status=TaskStatus.FAILED,
242
+ error=str(e),
243
+ duration_seconds=duration
244
  )
 
245
 
246
+ ray.get(progress_tracker.update_status.remote(device_id, TaskStatus.FAILED))
247
+ ray.get(progress_tracker.add_result.remote(result))
248
+
249
+ return result
250
+
251
+
252
+ @ray.remote
253
+ def deploy_to_device(device_id: str, config: str,
254
+ gns3_client: Any, progress_tracker: Any,
255
+ max_retries: int = 3) -> TaskResult:
 
 
 
 
 
 
256
  """
257
+ Ray remote function for parallel device deployment.
258
 
259
  Args:
260
+ device_id: Unique device identifier
261
+ config: Configuration to deploy
262
  gns3_client: GNS3 client instance
263
+ progress_tracker: Progress tracking actor
264
+ max_retries: Maximum retry attempts on failure
265
 
266
  Returns:
267
+ TaskResult with deployment status or error
268
  """
269
+ start_time = time.time()
270
+ retry_count = 0
 
 
 
 
 
271
 
272
+ while retry_count <= max_retries:
273
+ try:
274
+ if retry_count > 0:
275
+ ray.get(progress_tracker.update_status.remote(device_id, TaskStatus.RETRYING))
276
+ else:
277
+ ray.get(progress_tracker.update_status.remote(device_id, TaskStatus.RUNNING))
278
+
279
+ # Deploy config to device
280
+ deployment_result = gns3_client.apply_config(device_id, config)
281
+
282
+ duration = time.time() - start_time
283
+ result = TaskResult(
284
+ device_id=device_id,
285
+ status=TaskStatus.SUCCESS,
286
+ result=deployment_result,
287
+ duration_seconds=duration,
288
+ retry_count=retry_count
289
+ )
290
+
291
+ ray.get(progress_tracker.update_status.remote(device_id, TaskStatus.SUCCESS))
292
+ ray.get(progress_tracker.add_result.remote(result))
293
+
294
+ return result
295
+
296
+ except Exception as e:
297
+ retry_count += 1
298
+ if retry_count > max_retries:
299
+ duration = time.time() - start_time
300
+ result = TaskResult(
301
+ device_id=device_id,
302
+ status=TaskStatus.FAILED,
303
+ error=f"Failed after {retry_count} retries: {str(e)}",
304
+ duration_seconds=duration,
305
+ retry_count=retry_count - 1
306
+ )
307
+
308
+ ray.get(progress_tracker.update_status.remote(device_id, TaskStatus.FAILED))
309
+ ray.get(progress_tracker.add_result.remote(result))
310
+
311
+ return result
312
+
313
+ # Exponential backoff
314
+ time.sleep(2 ** retry_count)
315
+
316
+
317
+ class RayExecutor:
318
  """
319
+ Distributed execution engine for hyperscale network automation.
 
 
 
 
 
 
320
 
321
+ Provides parallel execution of config generation, analysis, and deployment
322
+ across thousands of devices using Ray's distributed computing framework.
 
 
 
 
 
 
323
  """
 
324
 
325
+ def __init__(self, ray_address: Optional[str] = None, num_cpus: Optional[int] = None):
326
+ """
327
+ Initialize Ray executor.
328
+
329
+ Args:
330
+ ray_address: Ray cluster address (None for local mode)
331
+ num_cpus: Number of CPUs to use (None for auto-detect)
332
+ """
333
+ self.ray_address = ray_address
334
+ self.num_cpus = num_cpus
335
+ self.initialized = False
336
+ self._progress_tracker = NoneNone
337
 
338
+ def initialize(self):
339
+ """Initialize Ray runtime"""
340
+ if self.initialized:
341
+ return
342
+
343
+ try:
344
+ # Check if Ray is already initialized
345
+ if ray.is_initialized():
346
+ logger.info("Ray already initialized")
347
+ else:
348
+ # Initialize Ray
349
+ if self.ray_address:
350
+ # Connect to existing cluster
351
+ ray.init(address=self.ray_address)
352
+ logger.info(f"Connected to Ray cluster at {self.ray_address}")
353
+ else:
354
+ # Start local Ray instance
355
+ init_kwargs = {}
356
+ if self.num_cpus:
357
+ init_kwargs['num_cpus'] = self.num_cpus
358
+
359
+ ray.init(**init_kwargs)
360
+ logger.info(f"Started local Ray instance with {ray.available_resources().get('CPU', 0)} CPUs")
361
+
362
+ self.initialized = True
363
+
364
+ except Exception as e:
365
+ logger.error(f"Failed to initialize Ray: {e}")
366
+ raise
367
+
368
+ def shutdown(self):
369
+ """Shutdown Ray runtime"""
370
+ if self.initialized and ray.is_initialized():
371
+ ray.shutdown()
372
+ self.initialized = False
373
+ logger.info("Ray shutdown complete")
374
+
375
+ def parallel_config_generation(self, devices: List[Dict[str, Any]],
376
+ template_fn: Callable,
377
+ batch_size: int = 100) -> Tuple[List[TaskResult], ExecutionProgress]:
378
+ """
379
+ Generate configs for multiple devices in parallel.
380
+
381
+ Args:
382
+ devices: List of device data dicts
383
+ template_fn: Function to generate config from device data
384
+ batch_size: Number of devices to process in each batch
385
+
386
+ Returns:
387
+ Tuple of (results, final_progress)
388
+ """
389
+ self.initialize()
390
+
391
+ # Create progress tracker
392
+ progress_tracker = ProgressTracker.remote(total_devices=len(devices))
393
+
394
+ # Initialize pending count
395
+ ray.get(progress_tracker.update_status.remote("_init_", TaskStatus.PENDING))
396
+ for _ in range(len(devices) - 1):
397
+ ray.get(progress_tracker.update_status.remote("_init_", TaskStatus.PENDING))
398
+
399
+ # Launch parallel tasks
400
+ futures = []
401
+ for device in devices:
402
+ future = generate_device_config.remote(
403
+ device_id=device['device_id'],
404
+ device_data=device,
405
+ template_fn=template_fn,
406
+ progress_tracker=progress_tracker
407
+ )
408
+ futures.append(future)
409
+
410
+ # Process in batches to avoid overwhelming the cluster
411
+ if len(futures) >= batch_size:
412
+ ray.get(futures)
413
+ futures = []
414
+
415
+ # Wait for remaining tasks
416
+ if futures:
417
+ ray.get(futures)
418
+
419
+ # Get final results
420
+ results = ray.get(progress_tracker.get_results.remote())
421
+ final_progress = ray.get(progress_tracker.get_progress.remote())
422
+
423
+ return results, final_progress
424
 
425
+ def parallel_batfish_analysis(self, configs: Dict[str, str],
426
+ batfish_client: Any,
427
+ batch_size: int = 50) -> Tuple[List[TaskResult], ExecutionProgress]:
428
+ """
429
+ Analyze configs in parallel using Batfish.
430
 
431
+ Args:
432
+ configs: Dict mapping device_id to config string
433
+ batfish_client: Batfish client instance
434
+ batch_size: Number of configs to analyze in each batch
435
 
436
+ Returns:
437
+ Tuple of (results, final_progress)
438
+ """
439
+ self.initialize()
440
 
441
+ progress_tracker = ProgressTracker.remote(total_devices=len(configs))
 
 
 
 
 
442
 
443
+ # Initialize pending count
444
+ for _ in range(len(configs)):
445
+ ray.get(progress_tracker.update_status.remote("_init_", TaskStatus.PENDING))
446
 
447
+ # Launch parallel analysis tasks
448
+ futures = []
449
+ for device_id, config in configs.items():
450
+ future = analyze_device_config.remote(
451
+ device_id=device_id,
452
+ config=config,
453
+ batfish_client=batfish_client,
454
+ progress_tracker=progress_tracker
455
+ )
456
+ futures.append(future)
457
+
458
+ if len(futures) >= batch_size:
459
+ ray.get(futures)
460
+ futures = []
461
+
462
+ if futures:
463
+ ray.get(futures)
464
+
465
+ results = ray.get(progress_tracker.get_results.remote())
466
+ final_progress = ray.get(progress_tracker.get_progress.remote())
467
+
468
+ return results, final_progressress
469
+
470
+ def parallel_deployment(self, deployments: Dict[str, str],
471
+ gns3_client: Any,
472
+ batch_size: int = 20,
473
+ max_retries: int = 3) -> Tuple[List[TaskResult], ExecutionProgress]:
474
+ """
475
+ Deploy configs to multiple devices in parallel.
476
+
477
+ Args:
478
+ deployments: Dict mapping device_id to config string
479
+ gns3_client: GNS3 client instance
480
+ batch_size: Number of devices to deploy to simultaneously
481
+ max_retries: Maximum retry attempts per device
482
 
483
+ Returns:
484
+ Tuple of (results, final_progress)
485
+ """
486
+ self.initialize()
487
+
488
+ progress_tracker = ProgressTracker.remote(total_devices=len(deployments))
489
+
490
+ # Initialize pending count
491
+ for _ in range(len(deployments)):
492
+ ray.get(progress_tracker.update_status.remote("_init_", TaskStatus.PENDING))
493
+
494
+ # Launch parallel deployment tasks
495
+ futures = []
496
+ for device_id, config in deployments.items():
497
+ future = deploy_to_device.remote(
498
+ device_id=device_id,
499
+ config=config,
500
+ gns3_client=gns3_client,
501
+ progress_tracker=progress_tracker,
502
+ max_retries=max_retries
503
  )
504
+ futures.append(future)
505
+
506
+ # Deploy in smaller batches to avoid overwhelming network
507
+ if len(futures) >= batch_size:
508
+ ray.get(futures)
509
+ futures = []
510
 
511
+ if futures:
512
+ ray.get(futures)
513
+
514
+ results = ray.get(progress_tracker.get_results.remote())
515
+ final_progress = ray.get(progress_tracker.get_progress.remote())
516
+
517
+ return results, final_progress
518
+
519
+ def get_cluster_resources(self) -> Dict[str, Any]:
520
+ """Get available cluster resources"""
521
+ self.initialize()
522
+ return {
523
+ 'available': ray.available_resources(),
524
+ 'total': ray.cluster_resources()
525
+ }
526
+
527
+ def staggered_rollout(self, deployments: Dict[str, str],
528
+ gns3_client: Any,
529
+ stages: List[float] = [0.01, 0.1, 0.5, 1.0],
530
+ validation_fn: Optional[Callable] = None) -> Tuple[List[TaskResult], ExecutionProgress]:
531
+ """
532
+ Deploy to devices in stages with validation between stages.
533
+
534
+ Implements canary deployment pattern:
535
+ - Stage 1: 1% of fleet
536
+ - Stage 2: 10% of fleet
537
+ - Stage 3: 50% of fleet
538
+ - Stage 4: 100% of fleet
539
+
540
+ Args:
541
+ deployments: Dict mapping device_id to config
542
+ gns3_client: GNS3 client instance
543
+ stages: List of percentages for each stage (0.0 to 1.0)
544
+ validation_fn: Optional function to validate stage success
545
+
546
+ Returns:
547
+ Tuple of (results, final_progress)
548
+ """
549
+ self.initialize()
550
+
551
+ device_ids = list(deployments.keys())
552
+ total_devices = len(device_ids)
553
+ all_results = []
554
+
555
+ current_index = 0
556
+
557
+ for stage_pct in stages:
558
+ stage_count = int(total_devices * stage_pct) - current_index
559
+ if stage_count <= 0:
560
+ continue
561
+
562
+ stage_devices = device_ids[current_index:current_index + stage_count]
563
+ stage_deployments = {did: deployments[did] for did in stage_devices}
564
+
565
+ logger.info(f"Starting stage {stage_pct*100}%: deploying to {len(stage_devices)} devices")
566
+
567
+ # Deploy this stage
568
+ results, progress = self.parallel_deployment(
569
+ deployments=stage_deployments,
570
+ gns3_client=gns3_client,
571
+ batch_size=min(20, len(stage_devices))
572
+ )
573
+
574
+ all_results.extend(results)
575
+
576
+ # Check for failures
577
+ failed_count = sum(1 for r in results if r.status == TaskStatus.FAILED)
578
+ failure_rate = failed_count / len(results) if results else 0
579
+
580
+ if failure_rate > 0.1: # More than 10% failure rate
581
+ logger.error(f"Stage failed with {failure_rate*100}% failure rate. Stopping rollout.")
582
+ # Return partial results
583
  final_progress = ExecutionProgress(
584
  total_devices=total_devices,
585
  completed=sum(1 for r in all_results if r.status == TaskStatus.SUCCESS),
586
  failed=sum(1 for r in all_results if r.status == TaskStatus.FAILED)
587
  )
588
  return all_results, final_progress.to_dict()
589
+
590
+ # Run validation if provided
591
+ if validation_fn:
592
+ try:
593
+ if not validation_fn(stage_devices, results):
594
+ logger.error("Stage validation failed. Stopping rollout.")
595
+ final_progress = ExecutionProgress(
596
+ total_devices=total_devices,
597
+ completed=sum(1 for r in all_results if r.status == TaskStatus.SUCCESS),
598
+ failed=sum(1 for r in all_results if r.status == TaskStatus.FAILED)
599
+ )
600
+ return all_results, final_progress.to_dict()
601
+ except Exception as e:
602
+ logger.error(f"Stage validation error: {e}. Stopping rollout.")
603
+ final_progress = ExecutionProgress(
604
+ total_devices=total_devices,
605
+ completed=sum(1 for r in all_results if r.status == TaskStatus.SUCCESS),
606
+ failed=sum(1 for r in all_results if r.status == TaskStatus.FAILED)
607
+ )
608
+ return all_results, final_progress.to_dict()
609
+
610
+ logger.info(f"Stage {stage_pct*100}% completed successfully")
611
+ current_index += stage_count
612
 
613
+ # Create final progress
614
+ final_progress = ExecutionProgress(
615
+ total_devices=total_devices,
616
+ completed=sum(1 for r in all_results if r.status == TaskStatus.SUCCESS),
617
+ failed=sum(1 for r in all_results if r.status == TaskStatus.FAILED)
618
+ )
619
+
620
+ return all_results, final_progress.to_dict()
621
+
622
+ else:
623
+ # Fallback executor when ray is not available
624
+ class RayExecutor:
625
+ """Fallback executor without distributed capabilities"""
626
+
627
+ def __init__(self, ray_address: Optional[str] = None, num_cpus: Optional[int] = None):
628
+ logging.warning("Ray not available - using fallback sequential executor")
629
+ self.ray_address = ray_address
630
+ self.num_cpus = num_cpus
631
+ self.initialized = False
632
+
633
+ def initialize(self):
634
+ """No-op initialization for fallback"""
635
+ self.initialized = True
636
+
637
+ def shutdown(self):
638
+ """No-op shutdown for fallback"""
639
+ self.initialized = False
640
+
641
+ def parallel_config_generation(self, devices: List[Dict[str, Any]],
642
+ template_fn: Callable,
643
+ batch_size: int = 100) -> Tuple[List[TaskResult], ExecutionProgress]:
644
+ """Sequential fallback for config generation"""
645
+ raise NotImplementedError(
646
+ "Distributed execution requires ray. Install with: pip install ray"
647
+ )
648
 
649
+ def parallel_batfish_analysis(self, configs: Dict[str, str],
650
+ batfish_client: Any,
651
+ batch_size: int = 50) -> Tuple[List[TaskResult], ExecutionProgress]:
652
+ """Sequential fallback for analysis"""
653
+ raise NotImplementedError(
654
+ "Distributed execution requires ray. Install with: pip install ray"
655
+ )
656
 
657
+ def parallel_deployment(self, deployments: Dict[str, str],
658
+ gns3_client: Any,
659
+ batch_size: int = 20,
660
+ max_retries: int = 3) -> Tuple[List[TaskResult], ExecutionProgress]:
661
+ """Sequential fallback for deployment"""
662
+ raise NotImplementedError(
663
+ "Distributed execution requires ray. Install with: pip install ray"
664
+ )
665
+
666
+ def staged_rollout(self, deployments: Dict[str, str],
667
+ gns3_client: Any,
668
+ stages: List[float] = None,
669
+ validation_fn: Optional[Callable] = None) -> Tuple[List[TaskResult], Dict[str, Any]]:
670
+ """Sequential fallback for staged rollout"""
671
+ raise NotImplementedError(
672
+ "Distributed execution requires ray. Install with: pip install ray"
673
+ )
app.py CHANGED
@@ -237,6 +237,162 @@ def build_ui():
237
 
238
  gr.Markdown("---")
239
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
240
  # Main Pipeline UI
241
  gr.Markdown("""### 🌿 Network Automation Pipeline
242
  **From Consultation → Production**
 
237
 
238
  gr.Markdown("---")
239
 
240
+ # ===== INTERACTIVE CONSULTATION TAB =====
241
+ gr.Markdown("""### 💬 Interactive Consultation
242
+ **Have a conversation before running the full pipeline**
243
+
244
+ Not sure what you need? Start here! The AI will ask clarifying questions to understand your network requirements.
245
+ """)
246
+
247
+ with gr.Row(elem_classes=["og-main-row"]):
248
+ with gr.Column(elem_classes=["og-panel"]):
249
+ gr.Markdown("#### 🤝 Consultation Chat")
250
+ consultation_chat = gr.Chatbot(
251
+ label="AI Network Consultant",
252
+ value=[],
253
+ height=400,
254
+ show_label=False,
255
+ )
256
+ consultation_input = gr.Textbox(
257
+ label="Your message",
258
+ placeholder="Hi, I need help designing a network for my small business...",
259
+ lines=2,
260
+ )
261
+ with gr.Row():
262
+ send_consultation_btn = gr.Button("📤 Send", variant="primary")
263
+ start_new_consultation_btn = gr.Button("🔄 New Consultation", variant="secondary")
264
+ use_consultation_btn = gr.Button("✅ Use This for Pipeline", variant="primary")
265
+
266
+ consultation_summary = gr.Textbox(
267
+ label="Consultation Summary (will be used in pipeline)",
268
+ lines=5,
269
+ interactive=False,
270
+ )
271
+
272
+ # Consultation state
273
+ consultation_state = gr.State(value={"consultant": None, "is_complete": False})
274
+
275
+ def start_consultation():
276
+ """Initialize a new consultation"""
277
+ return {
278
+ "consultant": None,
279
+ "is_complete": False
280
+ }, [], ""
281
+
282
+ def send_consultation_message(user_message, state):
283
+ """Send a message in the consultation"""
284
+ if not user_message.strip():
285
+ # Get current chat history from state
286
+ consultant = state.get("consultant")
287
+ if consultant and hasattr(consultant, 'conversation_history'):
288
+ # Convert conversation history to chatbot format
289
+ chatbot_messages = []
290
+ for msg in consultant.conversation_history:
291
+ if msg.role == "user" and not msg.content.startswith("Initial request:"):
292
+ chatbot_messages.append((msg.content, None))
293
+ elif msg.role == "assistant" and chatbot_messages:
294
+ # Pair with previous user message
295
+ if chatbot_messages[-1][1] is None:
296
+ chatbot_messages[-1] = (chatbot_messages[-1][0], msg.content)
297
+ else:
298
+ chatbot_messages.append((None, msg.content))
299
+ return state, chatbot_messages, ""
300
+ return state, [], ""
301
+
302
+ from agent.consultation import NetworkConsultant
303
+
304
+ # Get or create consultant
305
+ consultant = state.get("consultant")
306
+ first_message = consultant is None
307
+
308
+ if first_message:
309
+ consultant = NetworkConsultant()
310
+ state["consultant"] = consultant
311
+ # Start consultation with user's first message
312
+ is_complete, response, intent_data = consultant.start_consultation(user_message)
313
+ else:
314
+ # Continue existing consultation
315
+ is_complete, response, intent_data = consultant.continue_consultation(user_message)
316
+
317
+ # Update completion state
318
+ state["is_complete"] = is_complete
319
+ if is_complete and intent_data:
320
+ state["intent_data"] = intent_data
321
+
322
+ # Build chatbot display from conversation history
323
+ chatbot_messages = []
324
+ for msg in consultant.conversation_history:
325
+ # Skip system messages and "Initial request:" prefix
326
+ if msg.role == "system":
327
+ continue
328
+ if msg.role == "user":
329
+ content = msg.content
330
+ if content.startswith("Initial request: "):
331
+ content = content[len("Initial request: "):]
332
+ chatbot_messages.append((content, None))
333
+ elif msg.role == "assistant":
334
+ # Pair with the last user message
335
+ if chatbot_messages and chatbot_messages[-1][1] is None:
336
+ chatbot_messages[-1] = (chatbot_messages[-1][0], msg.content)
337
+ else:
338
+ # Standalone assistant message (shouldn't happen normally)
339
+ chatbot_messages.append((None, msg.content))
340
+
341
+ return state, chatbot_messages, ""
342
+
343
+ def use_consultation_summary(state):
344
+ """Extract the consultation summary for use in the pipeline"""
345
+ consultant = state.get("consultant")
346
+ if not consultant or not consultant.conversation_history:
347
+ return "No consultation history yet. Start a conversation first!"
348
+
349
+ # If consultation is complete, use the structured intent
350
+ if state.get("is_complete") and state.get("intent_data"):
351
+ intent = state["intent_data"]
352
+ summary = "# Consultation Summary (Complete)\n\n"
353
+ for key, value in intent.items():
354
+ summary += f"**{key}:** {value}\n\n"
355
+ return summary
356
+
357
+ # Otherwise, combine all messages into a summary
358
+ summary = "# Consultation Summary (In Progress)\n\n"
359
+ for msg in consultant.conversation_history:
360
+ if msg.role == "system":
361
+ continue
362
+ prefix = "**You:** " if msg.role == "user" else "**AI:** "
363
+ content = msg.content
364
+ if content.startswith("Initial request: "):
365
+ content = content[len("Initial request: "):]
366
+ summary += f"{prefix}{content}\n\n"
367
+
368
+ return summary
369
+
370
+ # Wire up consultation handlers
371
+ start_new_consultation_btn.click(
372
+ fn=start_consultation,
373
+ outputs=[consultation_state, consultation_chat, consultation_input]
374
+ )
375
+
376
+ send_consultation_btn.click(
377
+ fn=send_consultation_message,
378
+ inputs=[consultation_input, consultation_state],
379
+ outputs=[consultation_state, consultation_chat, consultation_input]
380
+ )
381
+
382
+ consultation_input.submit(
383
+ fn=send_consultation_message,
384
+ inputs=[consultation_input, consultation_state],
385
+ outputs=[consultation_state, consultation_chat, consultation_input]
386
+ )
387
+
388
+ use_consultation_btn.click(
389
+ fn=use_consultation_summary,
390
+ inputs=[consultation_state],
391
+ outputs=[consultation_summary]
392
+ )
393
+
394
+ gr.Markdown("---")
395
+
396
  # Main Pipeline UI
397
  gr.Markdown("""### 🌿 Network Automation Pipeline
398
  **From Consultation → Production**