empathy-framework 4.7.1__py3-none-any.whl → 4.8.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.
- {empathy_framework-4.7.1.dist-info → empathy_framework-4.8.0.dist-info}/METADATA +65 -2
- {empathy_framework-4.7.1.dist-info → empathy_framework-4.8.0.dist-info}/RECORD +73 -52
- {empathy_framework-4.7.1.dist-info → empathy_framework-4.8.0.dist-info}/WHEEL +1 -1
- {empathy_framework-4.7.1.dist-info → empathy_framework-4.8.0.dist-info}/entry_points.txt +2 -1
- {empathy_framework-4.7.1.dist-info → empathy_framework-4.8.0.dist-info}/top_level.txt +0 -1
- empathy_os/__init__.py +2 -0
- empathy_os/cache/hash_only.py +6 -3
- empathy_os/cache/hybrid.py +6 -3
- empathy_os/cli/__init__.py +128 -238
- empathy_os/cli/__main__.py +5 -33
- empathy_os/cli/commands/__init__.py +1 -8
- empathy_os/cli/commands/help.py +331 -0
- empathy_os/cli/commands/info.py +140 -0
- empathy_os/cli/commands/inspect.py +437 -0
- empathy_os/cli/commands/metrics.py +92 -0
- empathy_os/cli/commands/orchestrate.py +184 -0
- empathy_os/cli/commands/patterns.py +207 -0
- empathy_os/cli/commands/provider.py +93 -81
- empathy_os/cli/commands/setup.py +96 -0
- empathy_os/cli/commands/status.py +235 -0
- empathy_os/cli/commands/sync.py +166 -0
- empathy_os/cli/commands/tier.py +121 -0
- empathy_os/cli/commands/workflow.py +574 -0
- empathy_os/cli/parsers/__init__.py +62 -0
- empathy_os/cli/parsers/help.py +41 -0
- empathy_os/cli/parsers/info.py +26 -0
- empathy_os/cli/parsers/inspect.py +66 -0
- empathy_os/cli/parsers/metrics.py +42 -0
- empathy_os/cli/parsers/orchestrate.py +61 -0
- empathy_os/cli/parsers/patterns.py +54 -0
- empathy_os/cli/parsers/provider.py +40 -0
- empathy_os/cli/parsers/setup.py +42 -0
- empathy_os/cli/parsers/status.py +47 -0
- empathy_os/cli/parsers/sync.py +31 -0
- empathy_os/cli/parsers/tier.py +33 -0
- empathy_os/cli/parsers/workflow.py +77 -0
- empathy_os/cli/utils/__init__.py +1 -0
- empathy_os/cli/utils/data.py +242 -0
- empathy_os/cli/utils/helpers.py +68 -0
- empathy_os/{cli.py → cli_legacy.py} +27 -27
- empathy_os/cli_minimal.py +662 -0
- empathy_os/cli_router.py +384 -0
- empathy_os/cli_unified.py +38 -2
- empathy_os/memory/__init__.py +19 -5
- empathy_os/memory/short_term.py +14 -404
- empathy_os/memory/types.py +437 -0
- empathy_os/memory/unified.py +61 -48
- empathy_os/models/fallback.py +1 -1
- empathy_os/models/provider_config.py +59 -344
- empathy_os/models/registry.py +31 -180
- empathy_os/monitoring/alerts.py +14 -20
- empathy_os/monitoring/alerts_cli.py +24 -7
- empathy_os/project_index/__init__.py +2 -0
- empathy_os/project_index/index.py +210 -5
- empathy_os/project_index/scanner.py +45 -14
- empathy_os/project_index/scanner_parallel.py +291 -0
- empathy_os/socratic/ab_testing.py +1 -1
- empathy_os/workflows/__init__.py +31 -2
- empathy_os/workflows/base.py +349 -325
- empathy_os/workflows/bug_predict.py +8 -0
- empathy_os/workflows/builder.py +273 -0
- empathy_os/workflows/caching.py +253 -0
- empathy_os/workflows/code_review_pipeline.py +1 -0
- empathy_os/workflows/history.py +510 -0
- empathy_os/workflows/output.py +410 -0
- empathy_os/workflows/perf_audit.py +125 -19
- empathy_os/workflows/progress.py +324 -22
- empathy_os/workflows/routing.py +168 -0
- empathy_os/workflows/secure_release.py +1 -0
- empathy_os/workflows/security_audit.py +190 -0
- empathy_os/workflows/security_audit_phase3.py +328 -0
- empathy_os/workflows/telemetry_mixin.py +269 -0
- empathy_os/dashboard/__init__.py +0 -15
- empathy_os/dashboard/server.py +0 -941
- patterns/README.md +0 -119
- patterns/__init__.py +0 -95
- patterns/behavior.py +0 -298
- patterns/code_review_memory.json +0 -441
- patterns/core.py +0 -97
- patterns/debugging.json +0 -3763
- patterns/empathy.py +0 -268
- patterns/health_check_memory.json +0 -505
- patterns/input.py +0 -161
- patterns/memory_graph.json +0 -8
- patterns/refactoring_memory.json +0 -1113
- patterns/registry.py +0 -663
- patterns/security_memory.json +0 -8
- patterns/structural.py +0 -415
- patterns/validation.py +0 -194
- {empathy_framework-4.7.1.dist-info → empathy_framework-4.8.0.dist-info}/licenses/LICENSE +0 -0
empathy_os/workflows/progress.py
CHANGED
|
@@ -11,12 +11,35 @@ from __future__ import annotations
|
|
|
11
11
|
|
|
12
12
|
import asyncio
|
|
13
13
|
import json
|
|
14
|
-
|
|
14
|
+
import logging
|
|
15
|
+
import sys
|
|
16
|
+
import uuid
|
|
17
|
+
from collections.abc import Callable, Coroutine, Generator
|
|
18
|
+
from contextlib import contextmanager
|
|
15
19
|
from dataclasses import dataclass, field
|
|
16
20
|
from datetime import datetime
|
|
17
21
|
from enum import Enum
|
|
18
22
|
from typing import Any, Protocol
|
|
19
23
|
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
# Rich imports with fallback
|
|
27
|
+
try:
|
|
28
|
+
from rich.console import Console, Group
|
|
29
|
+
from rich.live import Live
|
|
30
|
+
from rich.panel import Panel
|
|
31
|
+
from rich.progress import BarColumn, Progress, SpinnerColumn, TaskID, TextColumn
|
|
32
|
+
from rich.table import Table
|
|
33
|
+
from rich.text import Text
|
|
34
|
+
|
|
35
|
+
RICH_AVAILABLE = True
|
|
36
|
+
except ImportError:
|
|
37
|
+
RICH_AVAILABLE = False
|
|
38
|
+
Console = None # type: ignore
|
|
39
|
+
Live = None # type: ignore
|
|
40
|
+
Panel = None # type: ignore
|
|
41
|
+
Progress = None # type: ignore
|
|
42
|
+
|
|
20
43
|
|
|
21
44
|
class ProgressStatus(Enum):
|
|
22
45
|
"""Status of a workflow or stage."""
|
|
@@ -361,9 +384,9 @@ class ProgressTracker:
|
|
|
361
384
|
for callback in self._callbacks:
|
|
362
385
|
try:
|
|
363
386
|
callback(update)
|
|
364
|
-
except Exception
|
|
365
|
-
#
|
|
366
|
-
|
|
387
|
+
except Exception: # noqa: BLE001
|
|
388
|
+
# INTENTIONAL: Callbacks are optional - never fail workflow on callback error
|
|
389
|
+
logger.warning("Progress callback error", exc_info=True)
|
|
367
390
|
|
|
368
391
|
# Call async callbacks
|
|
369
392
|
for async_callback in self._async_callbacks:
|
|
@@ -387,39 +410,111 @@ class ProgressReporter(Protocol):
|
|
|
387
410
|
|
|
388
411
|
|
|
389
412
|
class ConsoleProgressReporter:
|
|
390
|
-
"""
|
|
413
|
+
"""Console-based progress reporter optimized for IDE environments.
|
|
391
414
|
|
|
392
|
-
|
|
415
|
+
Provides clear, readable progress output that works reliably in:
|
|
416
|
+
- VSCode integrated terminal
|
|
417
|
+
- VSCode output panel
|
|
418
|
+
- IDE debug consoles
|
|
419
|
+
- Standard terminals
|
|
420
|
+
|
|
421
|
+
Uses Unicode symbols that render correctly in most environments.
|
|
422
|
+
"""
|
|
423
|
+
|
|
424
|
+
def __init__(self, verbose: bool = False, show_tokens: bool = False):
|
|
425
|
+
"""Initialize console progress reporter.
|
|
426
|
+
|
|
427
|
+
Args:
|
|
428
|
+
verbose: Show additional details (fallback info, errors)
|
|
429
|
+
show_tokens: Include token counts in output
|
|
430
|
+
"""
|
|
393
431
|
self.verbose = verbose
|
|
432
|
+
self.show_tokens = show_tokens
|
|
433
|
+
self._start_time: datetime | None = None
|
|
434
|
+
self._stage_times: dict[str, int] = {}
|
|
394
435
|
|
|
395
436
|
def report(self, update: ProgressUpdate) -> None:
|
|
396
|
-
"""Print progress to console.
|
|
397
|
-
|
|
437
|
+
"""Print progress to console.
|
|
438
|
+
|
|
439
|
+
Args:
|
|
440
|
+
update: Progress update from the tracker
|
|
441
|
+
"""
|
|
442
|
+
# Track start time for elapsed calculation
|
|
443
|
+
if self._start_time is None:
|
|
444
|
+
self._start_time = datetime.now()
|
|
445
|
+
|
|
446
|
+
percent = f"{update.percent_complete:3.0f}%"
|
|
398
447
|
cost = f"${update.cost_so_far:.4f}"
|
|
448
|
+
|
|
449
|
+
# Status icons that work in most environments
|
|
399
450
|
status_icon = {
|
|
400
451
|
ProgressStatus.PENDING: "○",
|
|
401
|
-
ProgressStatus.RUNNING: "
|
|
402
|
-
ProgressStatus.COMPLETED: "
|
|
452
|
+
ProgressStatus.RUNNING: "►",
|
|
453
|
+
ProgressStatus.COMPLETED: "✓",
|
|
403
454
|
ProgressStatus.FAILED: "✗",
|
|
404
|
-
ProgressStatus.SKIPPED: "
|
|
405
|
-
ProgressStatus.FALLBACK: "
|
|
455
|
+
ProgressStatus.SKIPPED: "–",
|
|
456
|
+
ProgressStatus.FALLBACK: "↻",
|
|
406
457
|
ProgressStatus.RETRYING: "↻",
|
|
407
458
|
}.get(update.status, "?")
|
|
408
459
|
|
|
409
460
|
# Get current tier from running stage
|
|
410
461
|
tier_info = ""
|
|
462
|
+
model_info = ""
|
|
411
463
|
if update.current_stage and update.stages:
|
|
412
464
|
for stage in update.stages:
|
|
413
|
-
if stage.name == update.current_stage
|
|
414
|
-
|
|
465
|
+
if stage.name == update.current_stage:
|
|
466
|
+
if stage.status == ProgressStatus.RUNNING:
|
|
467
|
+
tier_info = f" [{stage.tier.upper()}]"
|
|
468
|
+
if stage.model:
|
|
469
|
+
model_info = f" ({stage.model})"
|
|
470
|
+
# Track stage duration
|
|
471
|
+
if stage.duration_ms > 0:
|
|
472
|
+
self._stage_times[stage.name] = stage.duration_ms
|
|
415
473
|
break
|
|
416
474
|
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
if self.
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
475
|
+
# Build output line
|
|
476
|
+
elapsed = ""
|
|
477
|
+
if self._start_time:
|
|
478
|
+
elapsed_sec = (datetime.now() - self._start_time).total_seconds()
|
|
479
|
+
if elapsed_sec >= 1:
|
|
480
|
+
elapsed = f" [{elapsed_sec:.1f}s]"
|
|
481
|
+
|
|
482
|
+
tokens_str = ""
|
|
483
|
+
if self.show_tokens and update.tokens_so_far > 0:
|
|
484
|
+
tokens_str = f" | {update.tokens_so_far:,} tokens"
|
|
485
|
+
|
|
486
|
+
# Format: [100%] ✓ Completed optimize [PREMIUM] ($0.0279) [12.3s]
|
|
487
|
+
output = f"[{percent}] {status_icon} {update.message}{tier_info} ({cost}{tokens_str}){elapsed}"
|
|
488
|
+
print(output)
|
|
489
|
+
|
|
490
|
+
# Verbose output
|
|
491
|
+
if self.verbose:
|
|
492
|
+
if update.fallback_info:
|
|
493
|
+
print(f" ↳ Fallback: {update.fallback_info}")
|
|
494
|
+
if update.error:
|
|
495
|
+
print(f" ↳ Error: {update.error}")
|
|
496
|
+
|
|
497
|
+
# Print summary only on final workflow completion (not stage completion)
|
|
498
|
+
if update.status == ProgressStatus.COMPLETED and "workflow" in update.message.lower():
|
|
499
|
+
self._print_summary(update)
|
|
500
|
+
|
|
501
|
+
def _print_summary(self, update: ProgressUpdate) -> None:
|
|
502
|
+
"""Print workflow completion summary."""
|
|
503
|
+
if not self._stage_times:
|
|
504
|
+
return
|
|
505
|
+
|
|
506
|
+
print("")
|
|
507
|
+
print("─" * 50)
|
|
508
|
+
print("Stage Summary:")
|
|
509
|
+
for stage in update.stages:
|
|
510
|
+
if stage.status == ProgressStatus.COMPLETED:
|
|
511
|
+
duration_ms = stage.duration_ms or self._stage_times.get(stage.name, 0)
|
|
512
|
+
duration_str = f"{duration_ms}ms" if duration_ms < 1000 else f"{duration_ms/1000:.1f}s"
|
|
513
|
+
cost_str = f"${stage.cost:.4f}" if stage.cost > 0 else "—"
|
|
514
|
+
print(f" {stage.name}: {duration_str} | {cost_str}")
|
|
515
|
+
elif stage.status == ProgressStatus.SKIPPED:
|
|
516
|
+
print(f" {stage.name}: skipped")
|
|
517
|
+
print("─" * 50)
|
|
423
518
|
|
|
424
519
|
async def report_async(self, update: ProgressUpdate) -> None:
|
|
425
520
|
"""Async version just calls sync."""
|
|
@@ -463,8 +558,6 @@ def create_progress_tracker(
|
|
|
463
558
|
Configured ProgressTracker instance
|
|
464
559
|
|
|
465
560
|
"""
|
|
466
|
-
import uuid
|
|
467
|
-
|
|
468
561
|
tracker = ProgressTracker(
|
|
469
562
|
workflow_name=workflow_name,
|
|
470
563
|
workflow_id=uuid.uuid4().hex[:12],
|
|
@@ -475,3 +568,212 @@ def create_progress_tracker(
|
|
|
475
568
|
tracker.add_callback(reporter.report)
|
|
476
569
|
|
|
477
570
|
return tracker
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
class RichProgressReporter:
|
|
574
|
+
"""Rich-based live progress display with spinner, progress bar, and metrics.
|
|
575
|
+
|
|
576
|
+
Provides real-time visual feedback during workflow execution:
|
|
577
|
+
- Progress bar showing stage completion (1/3, 2/3, etc.)
|
|
578
|
+
- Spinner during active LLM API calls
|
|
579
|
+
- Real-time cost and token display
|
|
580
|
+
- In-place updates (no terminal scrolling)
|
|
581
|
+
|
|
582
|
+
Requires Rich library. Falls back gracefully if unavailable.
|
|
583
|
+
"""
|
|
584
|
+
|
|
585
|
+
def __init__(self, workflow_name: str, stage_names: list[str]) -> None:
|
|
586
|
+
"""Initialize the Rich progress reporter.
|
|
587
|
+
|
|
588
|
+
Args:
|
|
589
|
+
workflow_name: Name of the workflow for display
|
|
590
|
+
stage_names: List of stage names for progress tracking
|
|
591
|
+
"""
|
|
592
|
+
if not RICH_AVAILABLE:
|
|
593
|
+
raise RuntimeError("Rich library required for RichProgressReporter")
|
|
594
|
+
|
|
595
|
+
self.workflow_name = workflow_name
|
|
596
|
+
self.stage_names = stage_names
|
|
597
|
+
self.console = Console()
|
|
598
|
+
self._live: Live | None = None
|
|
599
|
+
self._progress: Progress | None = None
|
|
600
|
+
self._task_id: TaskID | None = None
|
|
601
|
+
self._current_stage = ""
|
|
602
|
+
self._cost = 0.0
|
|
603
|
+
self._tokens = 0
|
|
604
|
+
self._status = ProgressStatus.PENDING
|
|
605
|
+
|
|
606
|
+
def start(self) -> None:
|
|
607
|
+
"""Start the live progress display."""
|
|
608
|
+
if not RICH_AVAILABLE or Progress is None or Live is None:
|
|
609
|
+
return
|
|
610
|
+
|
|
611
|
+
self._progress = Progress(
|
|
612
|
+
SpinnerColumn(),
|
|
613
|
+
TextColumn("[bold blue]{task.description}"),
|
|
614
|
+
BarColumn(bar_width=30),
|
|
615
|
+
TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
|
|
616
|
+
TextColumn("({task.completed}/{task.total})"),
|
|
617
|
+
console=self.console,
|
|
618
|
+
transient=False,
|
|
619
|
+
)
|
|
620
|
+
|
|
621
|
+
self._task_id = self._progress.add_task(
|
|
622
|
+
self.workflow_name,
|
|
623
|
+
total=len(self.stage_names),
|
|
624
|
+
)
|
|
625
|
+
|
|
626
|
+
self._live = Live(
|
|
627
|
+
self._create_display(),
|
|
628
|
+
console=self.console,
|
|
629
|
+
refresh_per_second=4,
|
|
630
|
+
transient=False,
|
|
631
|
+
)
|
|
632
|
+
self._live.start()
|
|
633
|
+
|
|
634
|
+
def stop(self) -> None:
|
|
635
|
+
"""Stop the live progress display."""
|
|
636
|
+
if self._live:
|
|
637
|
+
self._live.stop()
|
|
638
|
+
self._live = None
|
|
639
|
+
|
|
640
|
+
def report(self, update: ProgressUpdate) -> None:
|
|
641
|
+
"""Handle a progress update.
|
|
642
|
+
|
|
643
|
+
Args:
|
|
644
|
+
update: Progress update from the tracker
|
|
645
|
+
"""
|
|
646
|
+
self._current_stage = update.current_stage
|
|
647
|
+
self._cost = update.cost_so_far
|
|
648
|
+
self._tokens = update.tokens_so_far
|
|
649
|
+
self._status = update.status
|
|
650
|
+
|
|
651
|
+
# Update progress bar
|
|
652
|
+
if self._progress is not None and self._task_id is not None:
|
|
653
|
+
completed = sum(
|
|
654
|
+
1 for s in update.stages if s.status == ProgressStatus.COMPLETED
|
|
655
|
+
)
|
|
656
|
+
self._progress.update(
|
|
657
|
+
self._task_id,
|
|
658
|
+
completed=completed,
|
|
659
|
+
description=f"{self.workflow_name}: {update.current_stage}",
|
|
660
|
+
)
|
|
661
|
+
|
|
662
|
+
# Refresh display
|
|
663
|
+
if self._live:
|
|
664
|
+
self._live.update(self._create_display())
|
|
665
|
+
|
|
666
|
+
async def report_async(self, update: ProgressUpdate) -> None:
|
|
667
|
+
"""Async version of report."""
|
|
668
|
+
self.report(update)
|
|
669
|
+
|
|
670
|
+
def _create_display(self) -> Panel:
|
|
671
|
+
"""Create the Rich display panel.
|
|
672
|
+
|
|
673
|
+
Returns:
|
|
674
|
+
Rich Panel containing progress information
|
|
675
|
+
"""
|
|
676
|
+
if not RICH_AVAILABLE or Panel is None or Table is None:
|
|
677
|
+
raise RuntimeError("Rich not available")
|
|
678
|
+
|
|
679
|
+
# Build metrics table
|
|
680
|
+
metrics = Table(show_header=False, box=None, padding=(0, 2))
|
|
681
|
+
metrics.add_column("Label", style="dim")
|
|
682
|
+
metrics.add_column("Value", style="bold")
|
|
683
|
+
|
|
684
|
+
metrics.add_row("Cost:", f"${self._cost:.4f}")
|
|
685
|
+
metrics.add_row("Tokens:", f"{self._tokens:,}")
|
|
686
|
+
metrics.add_row("Stage:", self._current_stage or "Starting...")
|
|
687
|
+
|
|
688
|
+
# Status indicator
|
|
689
|
+
status_style = {
|
|
690
|
+
ProgressStatus.PENDING: "dim",
|
|
691
|
+
ProgressStatus.RUNNING: "blue",
|
|
692
|
+
ProgressStatus.COMPLETED: "green",
|
|
693
|
+
ProgressStatus.FAILED: "red",
|
|
694
|
+
ProgressStatus.FALLBACK: "yellow",
|
|
695
|
+
ProgressStatus.RETRYING: "yellow",
|
|
696
|
+
}.get(self._status, "white")
|
|
697
|
+
|
|
698
|
+
status_text = Text(self._status.value.upper(), style=status_style)
|
|
699
|
+
|
|
700
|
+
# Combine into panel
|
|
701
|
+
if self._progress is not None:
|
|
702
|
+
content = Group(self._progress, metrics)
|
|
703
|
+
else:
|
|
704
|
+
content = metrics
|
|
705
|
+
|
|
706
|
+
return Panel(
|
|
707
|
+
content,
|
|
708
|
+
title=f"[bold]{self.workflow_name}[/bold]",
|
|
709
|
+
subtitle=status_text,
|
|
710
|
+
border_style=status_style,
|
|
711
|
+
)
|
|
712
|
+
|
|
713
|
+
def __enter__(self) -> RichProgressReporter:
|
|
714
|
+
"""Context manager entry."""
|
|
715
|
+
self.start()
|
|
716
|
+
return self
|
|
717
|
+
|
|
718
|
+
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
|
|
719
|
+
"""Context manager exit."""
|
|
720
|
+
self.stop()
|
|
721
|
+
|
|
722
|
+
|
|
723
|
+
@contextmanager
|
|
724
|
+
def live_progress(
|
|
725
|
+
workflow_name: str,
|
|
726
|
+
stage_names: list[str],
|
|
727
|
+
console: Console | None = None,
|
|
728
|
+
) -> Generator[tuple[ProgressTracker, RichProgressReporter | None], None, None]:
|
|
729
|
+
"""Context manager for live progress display during workflow execution.
|
|
730
|
+
|
|
731
|
+
Provides a ProgressTracker with optional Rich-based live display.
|
|
732
|
+
Falls back gracefully when Rich is unavailable or output is not a TTY.
|
|
733
|
+
|
|
734
|
+
Args:
|
|
735
|
+
workflow_name: Name of the workflow
|
|
736
|
+
stage_names: List of stage names in order
|
|
737
|
+
console: Optional Rich Console (creates new one if not provided)
|
|
738
|
+
|
|
739
|
+
Yields:
|
|
740
|
+
Tuple of (ProgressTracker, RichProgressReporter or None)
|
|
741
|
+
|
|
742
|
+
Example:
|
|
743
|
+
with live_progress("Code Review", ["analyze", "review", "summarize"]) as (tracker, _):
|
|
744
|
+
tracker.start_workflow()
|
|
745
|
+
for stage in stages:
|
|
746
|
+
tracker.start_stage(stage)
|
|
747
|
+
# ... do work ...
|
|
748
|
+
tracker.complete_stage(stage, cost=0.01, tokens_in=100, tokens_out=50)
|
|
749
|
+
tracker.complete_workflow()
|
|
750
|
+
"""
|
|
751
|
+
tracker = ProgressTracker(
|
|
752
|
+
workflow_name=workflow_name,
|
|
753
|
+
workflow_id=uuid.uuid4().hex[:12],
|
|
754
|
+
stage_names=stage_names,
|
|
755
|
+
)
|
|
756
|
+
|
|
757
|
+
reporter: RichProgressReporter | None = None
|
|
758
|
+
|
|
759
|
+
# Use Rich if available and output is a TTY
|
|
760
|
+
if RICH_AVAILABLE and sys.stdout.isatty():
|
|
761
|
+
try:
|
|
762
|
+
reporter = RichProgressReporter(workflow_name, stage_names)
|
|
763
|
+
tracker.add_callback(reporter.report)
|
|
764
|
+
reporter.start()
|
|
765
|
+
except Exception: # noqa: BLE001
|
|
766
|
+
# INTENTIONAL: Rich display is optional - fall back to console output
|
|
767
|
+
reporter = None
|
|
768
|
+
simple_reporter = ConsoleProgressReporter(verbose=False)
|
|
769
|
+
tracker.add_callback(simple_reporter.report)
|
|
770
|
+
else:
|
|
771
|
+
# No Rich or not a TTY - use simple console reporter
|
|
772
|
+
simple_reporter = ConsoleProgressReporter(verbose=False)
|
|
773
|
+
tracker.add_callback(simple_reporter.report)
|
|
774
|
+
|
|
775
|
+
try:
|
|
776
|
+
yield tracker, reporter
|
|
777
|
+
finally:
|
|
778
|
+
if reporter:
|
|
779
|
+
reporter.stop()
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""Tier routing strategies for workflow execution.
|
|
2
|
+
|
|
3
|
+
Provides pluggable routing algorithms to determine which model tier
|
|
4
|
+
should handle each workflow stage.
|
|
5
|
+
|
|
6
|
+
Copyright 2025 Smart-AI-Memory
|
|
7
|
+
Licensed under Fair Source License 0.9
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from abc import ABC, abstractmethod
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from typing import TYPE_CHECKING
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from empathy_os.workflows.base import ModelTier
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class RoutingContext:
|
|
22
|
+
"""Context information for routing decisions.
|
|
23
|
+
|
|
24
|
+
Attributes:
|
|
25
|
+
task_type: Type of task (analyze, generate, review, etc.)
|
|
26
|
+
input_size: Estimated input tokens
|
|
27
|
+
complexity: Task complexity (simple, moderate, complex)
|
|
28
|
+
budget_remaining: Remaining budget in USD
|
|
29
|
+
latency_sensitivity: Latency requirements (low, medium, high)
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
task_type: str
|
|
33
|
+
input_size: int
|
|
34
|
+
complexity: str # "simple" | "moderate" | "complex"
|
|
35
|
+
budget_remaining: float
|
|
36
|
+
latency_sensitivity: str # "low" | "medium" | "high"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class TierRoutingStrategy(ABC):
|
|
40
|
+
"""Abstract base class for tier routing strategies.
|
|
41
|
+
|
|
42
|
+
Subclasses implement different routing algorithms:
|
|
43
|
+
- CostOptimizedRouting: Minimize cost
|
|
44
|
+
- PerformanceOptimizedRouting: Minimize latency
|
|
45
|
+
- BalancedRouting: Balance cost and performance
|
|
46
|
+
- HybridRouting: User-configured tier mappings
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
@abstractmethod
|
|
50
|
+
def route(self, context: RoutingContext) -> ModelTier:
|
|
51
|
+
"""Route task to appropriate tier.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
context: Routing context with task information
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
ModelTier to use for this task
|
|
58
|
+
"""
|
|
59
|
+
pass
|
|
60
|
+
|
|
61
|
+
@abstractmethod
|
|
62
|
+
def can_fallback(self, tier: ModelTier) -> bool:
|
|
63
|
+
"""Whether fallback to cheaper tier is allowed.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
tier: The tier that failed or exceeded budget
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
True if fallback is allowed, False otherwise
|
|
70
|
+
"""
|
|
71
|
+
pass
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class CostOptimizedRouting(TierRoutingStrategy):
|
|
75
|
+
"""Route to cheapest tier that can handle the task.
|
|
76
|
+
|
|
77
|
+
Default strategy. Prioritizes cost savings over speed.
|
|
78
|
+
|
|
79
|
+
Example:
|
|
80
|
+
>>> strategy = CostOptimizedRouting()
|
|
81
|
+
>>> tier = strategy.route(context) # CHEAP for simple tasks
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
def route(self, context: RoutingContext) -> ModelTier:
|
|
85
|
+
"""Route based on task complexity, preferring cheaper tiers."""
|
|
86
|
+
from empathy_os.workflows.base import ModelTier
|
|
87
|
+
|
|
88
|
+
if context.complexity == "simple":
|
|
89
|
+
return ModelTier.CHEAP
|
|
90
|
+
elif context.complexity == "complex":
|
|
91
|
+
return ModelTier.PREMIUM
|
|
92
|
+
return ModelTier.CAPABLE
|
|
93
|
+
|
|
94
|
+
def can_fallback(self, tier: ModelTier) -> bool:
|
|
95
|
+
"""Allow fallback except for CHEAP tier."""
|
|
96
|
+
from empathy_os.workflows.base import ModelTier
|
|
97
|
+
|
|
98
|
+
return tier != ModelTier.CHEAP
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class PerformanceOptimizedRouting(TierRoutingStrategy):
|
|
102
|
+
"""Route to fastest tier regardless of cost.
|
|
103
|
+
|
|
104
|
+
Use for latency-sensitive workflows like interactive tools.
|
|
105
|
+
|
|
106
|
+
Example:
|
|
107
|
+
>>> strategy = PerformanceOptimizedRouting()
|
|
108
|
+
>>> tier = strategy.route(context) # PREMIUM for high latency sensitivity
|
|
109
|
+
"""
|
|
110
|
+
|
|
111
|
+
def route(self, context: RoutingContext) -> ModelTier:
|
|
112
|
+
"""Route based on latency requirements."""
|
|
113
|
+
from empathy_os.workflows.base import ModelTier
|
|
114
|
+
|
|
115
|
+
if context.latency_sensitivity == "high":
|
|
116
|
+
return ModelTier.PREMIUM
|
|
117
|
+
return ModelTier.CAPABLE
|
|
118
|
+
|
|
119
|
+
def can_fallback(self, tier: ModelTier) -> bool:
|
|
120
|
+
"""Never fallback - performance is priority."""
|
|
121
|
+
return False
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class BalancedRouting(TierRoutingStrategy):
|
|
125
|
+
"""Balance cost and performance with budget awareness.
|
|
126
|
+
|
|
127
|
+
Adjusts tier selection based on remaining budget and task complexity.
|
|
128
|
+
|
|
129
|
+
Example:
|
|
130
|
+
>>> strategy = BalancedRouting(total_budget=50.0)
|
|
131
|
+
>>> tier = strategy.route(context) # Adapts based on budget
|
|
132
|
+
"""
|
|
133
|
+
|
|
134
|
+
def __init__(self, total_budget: float):
|
|
135
|
+
"""Initialize with total budget.
|
|
136
|
+
|
|
137
|
+
Args:
|
|
138
|
+
total_budget: Total budget in USD for this workflow execution
|
|
139
|
+
|
|
140
|
+
Raises:
|
|
141
|
+
ValueError: If total_budget is not positive
|
|
142
|
+
"""
|
|
143
|
+
if total_budget <= 0:
|
|
144
|
+
raise ValueError("total_budget must be positive")
|
|
145
|
+
self.total_budget = total_budget
|
|
146
|
+
|
|
147
|
+
def route(self, context: RoutingContext) -> ModelTier:
|
|
148
|
+
"""Route based on budget ratio and complexity."""
|
|
149
|
+
from empathy_os.workflows.base import ModelTier
|
|
150
|
+
|
|
151
|
+
budget_ratio = context.budget_remaining / self.total_budget
|
|
152
|
+
|
|
153
|
+
# Low budget - use cheap tier
|
|
154
|
+
if budget_ratio < 0.2:
|
|
155
|
+
return ModelTier.CHEAP
|
|
156
|
+
|
|
157
|
+
# High budget + complex task - use premium
|
|
158
|
+
if budget_ratio > 0.7 and context.complexity == "complex":
|
|
159
|
+
return ModelTier.PREMIUM
|
|
160
|
+
|
|
161
|
+
# Default to capable
|
|
162
|
+
return ModelTier.CAPABLE
|
|
163
|
+
|
|
164
|
+
def can_fallback(self, tier: ModelTier) -> bool:
|
|
165
|
+
"""Allow fallback when budget-constrained."""
|
|
166
|
+
return True
|
|
167
|
+
|
|
168
|
+
|