attune-ai 2.1.5__py3-none-any.whl → 2.2.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. attune/cli/__init__.py +3 -59
  2. attune/cli/commands/batch.py +4 -12
  3. attune/cli/commands/cache.py +8 -16
  4. attune/cli/commands/provider.py +17 -0
  5. attune/cli/commands/routing.py +3 -1
  6. attune/cli/commands/setup.py +122 -0
  7. attune/cli/commands/tier.py +1 -3
  8. attune/cli/commands/workflow.py +31 -0
  9. attune/cli/parsers/cache.py +1 -0
  10. attune/cli/parsers/help.py +1 -3
  11. attune/cli/parsers/provider.py +7 -0
  12. attune/cli/parsers/routing.py +1 -3
  13. attune/cli/parsers/setup.py +7 -0
  14. attune/cli/parsers/status.py +1 -3
  15. attune/cli/parsers/tier.py +1 -3
  16. attune/cli_minimal.py +9 -3
  17. attune/cli_router.py +9 -7
  18. attune/cli_unified.py +3 -0
  19. attune/dashboard/app.py +3 -1
  20. attune/dashboard/simple_server.py +3 -1
  21. attune/dashboard/standalone_server.py +7 -3
  22. attune/mcp/server.py +54 -102
  23. attune/memory/long_term.py +0 -2
  24. attune/memory/short_term/__init__.py +84 -0
  25. attune/memory/short_term/base.py +465 -0
  26. attune/memory/short_term/batch.py +219 -0
  27. attune/memory/short_term/caching.py +227 -0
  28. attune/memory/short_term/conflicts.py +265 -0
  29. attune/memory/short_term/cross_session.py +122 -0
  30. attune/memory/short_term/facade.py +653 -0
  31. attune/memory/short_term/pagination.py +207 -0
  32. attune/memory/short_term/patterns.py +271 -0
  33. attune/memory/short_term/pubsub.py +286 -0
  34. attune/memory/short_term/queues.py +244 -0
  35. attune/memory/short_term/security.py +300 -0
  36. attune/memory/short_term/sessions.py +250 -0
  37. attune/memory/short_term/streams.py +242 -0
  38. attune/memory/short_term/timelines.py +234 -0
  39. attune/memory/short_term/transactions.py +184 -0
  40. attune/memory/short_term/working.py +252 -0
  41. attune/meta_workflows/cli_commands/__init__.py +3 -0
  42. attune/meta_workflows/cli_commands/agent_commands.py +0 -4
  43. attune/meta_workflows/cli_commands/analytics_commands.py +0 -6
  44. attune/meta_workflows/cli_commands/config_commands.py +0 -5
  45. attune/meta_workflows/cli_commands/memory_commands.py +0 -5
  46. attune/meta_workflows/cli_commands/template_commands.py +0 -5
  47. attune/meta_workflows/cli_commands/workflow_commands.py +0 -6
  48. attune/meta_workflows/plan_generator.py +2 -4
  49. attune/models/adaptive_routing.py +4 -8
  50. attune/models/auth_cli.py +3 -9
  51. attune/models/auth_strategy.py +2 -4
  52. attune/models/telemetry/analytics.py +0 -2
  53. attune/models/telemetry/backend.py +0 -3
  54. attune/models/telemetry/storage.py +0 -2
  55. attune/monitoring/alerts.py +6 -10
  56. attune/orchestration/_strategies/__init__.py +156 -0
  57. attune/orchestration/_strategies/base.py +227 -0
  58. attune/orchestration/_strategies/conditional_strategies.py +365 -0
  59. attune/orchestration/_strategies/conditions.py +369 -0
  60. attune/orchestration/_strategies/core_strategies.py +479 -0
  61. attune/orchestration/_strategies/data_classes.py +64 -0
  62. attune/orchestration/_strategies/nesting.py +233 -0
  63. attune/orchestration/execution_strategies.py +58 -1567
  64. attune/orchestration/meta_orchestrator.py +1 -3
  65. attune/project_index/scanner.py +1 -3
  66. attune/project_index/scanner_parallel.py +7 -5
  67. attune/socratic/storage.py +2 -4
  68. attune/socratic_router.py +1 -3
  69. attune/telemetry/agent_coordination.py +9 -3
  70. attune/telemetry/agent_tracking.py +16 -3
  71. attune/telemetry/approval_gates.py +22 -5
  72. attune/telemetry/cli.py +1 -3
  73. attune/telemetry/commands/dashboard_commands.py +24 -8
  74. attune/telemetry/event_streaming.py +8 -2
  75. attune/telemetry/feedback_loop.py +10 -2
  76. attune/tools.py +2 -1
  77. attune/workflow_commands.py +1 -3
  78. attune/workflow_patterns/structural.py +4 -8
  79. attune/workflows/__init__.py +54 -10
  80. attune/workflows/autonomous_test_gen.py +158 -102
  81. attune/workflows/base.py +48 -672
  82. attune/workflows/batch_processing.py +1 -3
  83. attune/workflows/compat.py +156 -0
  84. attune/workflows/cost_mixin.py +141 -0
  85. attune/workflows/data_classes.py +92 -0
  86. attune/workflows/document_gen/workflow.py +11 -14
  87. attune/workflows/history.py +16 -9
  88. attune/workflows/llm_base.py +1 -3
  89. attune/workflows/migration.py +432 -0
  90. attune/workflows/output.py +2 -7
  91. attune/workflows/parsing_mixin.py +427 -0
  92. attune/workflows/perf_audit.py +3 -1
  93. attune/workflows/progress.py +9 -11
  94. attune/workflows/release_prep.py +5 -1
  95. attune/workflows/routing.py +0 -2
  96. attune/workflows/secure_release.py +4 -1
  97. attune/workflows/security_audit.py +20 -14
  98. attune/workflows/security_audit_phase3.py +28 -22
  99. attune/workflows/seo_optimization.py +27 -27
  100. attune/workflows/test_gen/test_templates.py +1 -4
  101. attune/workflows/test_gen/workflow.py +0 -2
  102. attune/workflows/test_gen_behavioral.py +6 -19
  103. attune/workflows/test_gen_parallel.py +8 -6
  104. {attune_ai-2.1.5.dist-info → attune_ai-2.2.1.dist-info}/METADATA +4 -3
  105. {attune_ai-2.1.5.dist-info → attune_ai-2.2.1.dist-info}/RECORD +121 -96
  106. {attune_ai-2.1.5.dist-info → attune_ai-2.2.1.dist-info}/entry_points.txt +0 -2
  107. attune_healthcare/monitors/monitoring/__init__.py +9 -9
  108. attune_llm/agent_factory/__init__.py +6 -6
  109. attune_llm/agent_factory/adapters/haystack_adapter.py +1 -4
  110. attune_llm/commands/__init__.py +10 -10
  111. attune_llm/commands/models.py +3 -3
  112. attune_llm/config/__init__.py +8 -8
  113. attune_llm/learning/__init__.py +3 -3
  114. attune_llm/learning/extractor.py +5 -3
  115. attune_llm/learning/storage.py +5 -3
  116. attune_llm/security/__init__.py +17 -17
  117. attune_llm/utils/tokens.py +3 -1
  118. attune/cli_legacy.py +0 -3978
  119. attune/memory/short_term.py +0 -2192
  120. attune/workflows/manage_docs.py +0 -87
  121. attune/workflows/test5.py +0 -125
  122. {attune_ai-2.1.5.dist-info → attune_ai-2.2.1.dist-info}/WHEEL +0 -0
  123. {attune_ai-2.1.5.dist-info → attune_ai-2.2.1.dist-info}/licenses/LICENSE +0 -0
  124. {attune_ai-2.1.5.dist-info → attune_ai-2.2.1.dist-info}/licenses/LICENSE_CHANGE_ANNOUNCEMENT.md +0 -0
  125. {attune_ai-2.1.5.dist-info → attune_ai-2.2.1.dist-info}/top_level.txt +0 -0
attune/workflows/base.py CHANGED
@@ -21,9 +21,7 @@ import sys
21
21
  import time
22
22
  import uuid
23
23
  from abc import ABC, abstractmethod
24
- from dataclasses import dataclass, field
25
24
  from datetime import datetime
26
- from enum import Enum
27
25
  from pathlib import Path
28
26
  from typing import TYPE_CHECKING, Any
29
27
 
@@ -42,7 +40,7 @@ except ImportError:
42
40
  # Import caching infrastructure
43
41
  from attune.cache import BaseCache
44
42
  from attune.config import _validate_file_path
45
- from attune.cost_tracker import MODEL_PRICING, CostTracker
43
+ from attune.cost_tracker import CostTracker
46
44
 
47
45
  # Import unified types from attune.models
48
46
  from attune.models import (
@@ -51,12 +49,33 @@ from attune.models import (
51
49
  TaskRoutingRecord,
52
50
  TelemetryBackend,
53
51
  )
54
- from attune.models import ModelProvider as UnifiedModelProvider
55
- from attune.models import ModelTier as UnifiedModelTier
56
52
 
57
53
  # Import mixins (extracted for maintainability)
58
54
  from .caching import CachedResponse, CachingMixin
59
55
 
56
+ # Import deprecated enums from compat module (extracted for maintainability)
57
+ # These are re-exported for backward compatibility
58
+ from .compat import (
59
+ PROVIDER_MODELS, # noqa: F401 - re-exported
60
+ ModelProvider,
61
+ ModelTier,
62
+ _build_provider_models, # noqa: F401 - re-exported
63
+ )
64
+
65
+ # Import cost tracking mixin (extracted for maintainability)
66
+ from .cost_mixin import CostTrackingMixin
67
+
68
+ # Import data classes (extracted for maintainability)
69
+ from .data_classes import (
70
+ CostReport, # noqa: F401 - re-exported
71
+ StageQualityMetrics, # noqa: F401 - re-exported
72
+ WorkflowResult,
73
+ WorkflowStage,
74
+ )
75
+
76
+ # Import parsing mixin (extracted for maintainability)
77
+ from .parsing_mixin import ResponseParsingMixin
78
+
60
79
  # Import progress tracking
61
80
  from .progress import (
62
81
  RICH_AVAILABLE,
@@ -84,185 +103,9 @@ logger = logging.getLogger(__name__)
84
103
  # Default path for workflow run history
85
104
  WORKFLOW_HISTORY_FILE = ".attune/workflow_runs.json"
86
105
 
87
-
88
- # Local enums for backward compatibility - DEPRECATED
89
- # New code should use attune.models.ModelTier/ModelProvider
90
- class ModelTier(Enum):
91
- """DEPRECATED: Model tier for cost optimization.
92
-
93
- This enum is deprecated and will be removed in v5.0.
94
- Use attune.models.ModelTier instead.
95
-
96
- Migration:
97
- # Old:
98
- from attune.workflows.base import ModelTier
99
-
100
- # New:
101
- from attune.models import ModelTier
102
-
103
- Why deprecated:
104
- - Creates confusion with dual definitions
105
- - attune.models.ModelTier is the canonical location
106
- - Simplifies imports and reduces duplication
107
- """
108
-
109
- CHEAP = "cheap" # Haiku/GPT-4o-mini - $0.25-1.25/M tokens
110
- CAPABLE = "capable" # Sonnet/GPT-4o - $3-15/M tokens
111
- PREMIUM = "premium" # Opus/o1 - $15-75/M tokens
112
-
113
- def __init__(self, value: str):
114
- """Initialize with deprecation warning."""
115
- # Only warn once per process, not per instance
116
- import warnings
117
-
118
- # Use self.__class__ instead of ModelTier (class not yet defined during creation)
119
- if not hasattr(self.__class__, "_deprecation_warned"):
120
- warnings.warn(
121
- "workflows.base.ModelTier is deprecated and will be removed in v5.0. "
122
- "Use attune.models.ModelTier instead. "
123
- "Update imports: from attune.models import ModelTier",
124
- DeprecationWarning,
125
- stacklevel=4,
126
- )
127
- self.__class__._deprecation_warned = True
128
-
129
- def to_unified(self) -> UnifiedModelTier:
130
- """Convert to unified ModelTier from attune.models."""
131
- return UnifiedModelTier(self.value)
132
-
133
-
134
- class ModelProvider(Enum):
135
- """Supported model providers."""
136
-
137
- ANTHROPIC = "anthropic"
138
- OPENAI = "openai"
139
- GOOGLE = "google" # Google Gemini models
140
- OLLAMA = "ollama"
141
- HYBRID = "hybrid" # Mix of best models from different providers
142
- CUSTOM = "custom" # User-defined custom models
143
-
144
- def to_unified(self) -> UnifiedModelProvider:
145
- """Convert to unified ModelProvider from attune.models.
146
-
147
- As of v5.0.0, framework is Claude-native. All providers map to ANTHROPIC.
148
- """
149
- # v5.0.0: Framework is Claude-native, only ANTHROPIC supported
150
- return UnifiedModelProvider.ANTHROPIC
151
-
152
-
153
- # Import unified MODEL_REGISTRY as single source of truth
154
- # This import is placed here intentionally to avoid circular imports
155
- from attune.models import MODEL_REGISTRY # noqa: E402
156
-
157
-
158
- def _build_provider_models() -> dict[ModelProvider, dict[ModelTier, str]]:
159
- """Build PROVIDER_MODELS from MODEL_REGISTRY.
160
-
161
- This ensures PROVIDER_MODELS stays in sync with the single source of truth.
162
- """
163
- result: dict[ModelProvider, dict[ModelTier, str]] = {}
164
-
165
- # Map string provider names to ModelProvider enum
166
- provider_map = {
167
- "anthropic": ModelProvider.ANTHROPIC,
168
- "openai": ModelProvider.OPENAI,
169
- "google": ModelProvider.GOOGLE,
170
- "ollama": ModelProvider.OLLAMA,
171
- "hybrid": ModelProvider.HYBRID,
172
- }
173
-
174
- # Map string tier names to ModelTier enum
175
- tier_map = {
176
- "cheap": ModelTier.CHEAP,
177
- "capable": ModelTier.CAPABLE,
178
- "premium": ModelTier.PREMIUM,
179
- }
180
-
181
- for provider_str, tiers in MODEL_REGISTRY.items():
182
- if provider_str not in provider_map:
183
- continue # Skip custom providers
184
- provider_enum = provider_map[provider_str]
185
- result[provider_enum] = {}
186
- for tier_str, model_info in tiers.items():
187
- if tier_str in tier_map:
188
- result[provider_enum][tier_map[tier_str]] = model_info.id
189
-
190
- return result
191
-
192
-
193
- # Model mappings by provider and tier (derived from MODEL_REGISTRY)
194
- PROVIDER_MODELS: dict[ModelProvider, dict[ModelTier, str]] = _build_provider_models()
195
-
196
-
197
- @dataclass
198
- class WorkflowStage:
199
- """Represents a single stage in a workflow."""
200
-
201
- name: str
202
- tier: ModelTier
203
- description: str
204
- input_tokens: int = 0
205
- output_tokens: int = 0
206
- cost: float = 0.0
207
- result: Any = None
208
- duration_ms: int = 0
209
- skipped: bool = False
210
- skip_reason: str | None = None
211
-
212
-
213
- @dataclass
214
- class CostReport:
215
- """Cost breakdown for a workflow execution."""
216
-
217
- total_cost: float
218
- baseline_cost: float # If all stages used premium
219
- savings: float
220
- savings_percent: float
221
- by_stage: dict[str, float] = field(default_factory=dict)
222
- by_tier: dict[str, float] = field(default_factory=dict)
223
- # Cache metrics
224
- cache_hits: int = 0
225
- cache_misses: int = 0
226
- cache_hit_rate: float = 0.0
227
- estimated_cost_without_cache: float = 0.0
228
- savings_from_cache: float = 0.0
229
-
230
-
231
- @dataclass
232
- class StageQualityMetrics:
233
- """Quality metrics for stage output validation."""
234
-
235
- execution_succeeded: bool
236
- output_valid: bool
237
- quality_improved: bool # Workflow-specific (e.g., health score improved)
238
- error_type: str | None
239
- validation_error: str | None
240
-
241
-
242
- @dataclass
243
- class WorkflowResult:
244
- """Result of a workflow execution."""
245
-
246
- success: bool
247
- stages: list[WorkflowStage]
248
- final_output: Any
249
- cost_report: CostReport
250
- started_at: datetime
251
- completed_at: datetime
252
- total_duration_ms: int
253
- provider: str = "unknown"
254
- error: str | None = None
255
- # Structured error taxonomy for reliability
256
- error_type: str | None = None # "config" | "runtime" | "provider" | "timeout" | "validation"
257
- transient: bool = False # True if retry is reasonable (e.g., provider timeout)
258
- # Optional metadata and summary for extended reporting
259
- metadata: dict[str, Any] = field(default_factory=dict)
260
- summary: str | None = None
261
-
262
- @property
263
- def duration_seconds(self) -> float:
264
- """Get duration in seconds (computed from total_duration_ms)."""
265
- return self.total_duration_ms / 1000.0
106
+ # Data classes moved to data_classes.py for improved import performance
107
+ # WorkflowStage, CostReport, StageQualityMetrics, WorkflowResult are now
108
+ # imported from .data_classes and re-exported for backward compatibility
266
109
 
267
110
 
268
111
  # Global singleton for workflow history store (lazy-initialized)
@@ -522,10 +365,11 @@ def get_workflow_stats(history_file: str = WORKFLOW_HISTORY_FILE) -> dict:
522
365
  }
523
366
 
524
367
 
525
- class BaseWorkflow(CachingMixin, TelemetryMixin, ABC):
368
+ class BaseWorkflow(CachingMixin, TelemetryMixin, ResponseParsingMixin, CostTrackingMixin, ABC):
526
369
  """Base class for multi-model workflows.
527
370
 
528
- Inherits from CachingMixin and TelemetryMixin (extracted for maintainability).
371
+ Inherits from CachingMixin, TelemetryMixin, ResponseParsingMixin, and
372
+ CostTrackingMixin (extracted for maintainability).
529
373
 
530
374
  Subclasses define stages and tier mappings:
531
375
 
@@ -707,13 +551,13 @@ class BaseWorkflow(CachingMixin, TelemetryMixin, ABC):
707
551
  logger.debug(
708
552
  "adaptive_routing_initialized",
709
553
  workflow=self.name,
710
- message="Adaptive routing enabled for cost optimization"
554
+ message="Adaptive routing enabled for cost optimization",
711
555
  )
712
556
  else:
713
557
  logger.warning(
714
558
  "adaptive_routing_unavailable",
715
559
  workflow=self.name,
716
- message="Telemetry not available, adaptive routing disabled"
560
+ message="Telemetry not available, adaptive routing disabled",
717
561
  )
718
562
  self._enable_adaptive_routing = False
719
563
  except ImportError as e:
@@ -721,7 +565,7 @@ class BaseWorkflow(CachingMixin, TelemetryMixin, ABC):
721
565
  "adaptive_routing_import_error",
722
566
  workflow=self.name,
723
567
  error=str(e),
724
- message="Failed to import AdaptiveModelRouter"
568
+ message="Failed to import AdaptiveModelRouter",
725
569
  )
726
570
  self._enable_adaptive_routing = False
727
571
 
@@ -745,14 +589,14 @@ class BaseWorkflow(CachingMixin, TelemetryMixin, ABC):
745
589
  "heartbeat_tracking_initialized",
746
590
  workflow=self.name,
747
591
  agent_id=self._agent_id,
748
- message="Heartbeat tracking enabled for agent liveness monitoring"
592
+ message="Heartbeat tracking enabled for agent liveness monitoring",
749
593
  )
750
594
  except ImportError as e:
751
595
  logger.warning(
752
596
  "heartbeat_tracking_import_error",
753
597
  workflow=self.name,
754
598
  error=str(e),
755
- message="Failed to import HeartbeatCoordinator"
599
+ message="Failed to import HeartbeatCoordinator",
756
600
  )
757
601
  self._enable_heartbeat_tracking = False
758
602
  except Exception as e:
@@ -760,7 +604,7 @@ class BaseWorkflow(CachingMixin, TelemetryMixin, ABC):
760
604
  "heartbeat_tracking_init_error",
761
605
  workflow=self.name,
762
606
  error=str(e),
763
- message="Failed to initialize HeartbeatCoordinator (Redis unavailable?)"
607
+ message="Failed to initialize HeartbeatCoordinator (Redis unavailable?)",
764
608
  )
765
609
  self._enable_heartbeat_tracking = False
766
610
 
@@ -784,14 +628,14 @@ class BaseWorkflow(CachingMixin, TelemetryMixin, ABC):
784
628
  "coordination_initialized",
785
629
  workflow=self.name,
786
630
  agent_id=self._agent_id,
787
- message="Coordination signals enabled for inter-agent communication"
631
+ message="Coordination signals enabled for inter-agent communication",
788
632
  )
789
633
  except ImportError as e:
790
634
  logger.warning(
791
635
  "coordination_import_error",
792
636
  workflow=self.name,
793
637
  error=str(e),
794
- message="Failed to import CoordinationSignals"
638
+ message="Failed to import CoordinationSignals",
795
639
  )
796
640
  self._enable_coordination = False
797
641
  except Exception as e:
@@ -799,7 +643,7 @@ class BaseWorkflow(CachingMixin, TelemetryMixin, ABC):
799
643
  "coordination_init_error",
800
644
  workflow=self.name,
801
645
  error=str(e),
802
- message="Failed to initialize CoordinationSignals (Redis unavailable?)"
646
+ message="Failed to initialize CoordinationSignals (Redis unavailable?)",
803
647
  )
804
648
  self._enable_coordination = False
805
649
 
@@ -823,10 +667,7 @@ class BaseWorkflow(CachingMixin, TelemetryMixin, ABC):
823
667
  return current_tier
824
668
 
825
669
  # Check if tier upgrade is recommended
826
- should_upgrade, reason = router.recommend_tier_upgrade(
827
- workflow=self.name,
828
- stage=stage_name
829
- )
670
+ should_upgrade, reason = router.recommend_tier_upgrade(workflow=self.name, stage=stage_name)
830
671
 
831
672
  if should_upgrade:
832
673
  # Upgrade to next tier: CHEAP → CAPABLE → PREMIUM
@@ -843,7 +684,7 @@ class BaseWorkflow(CachingMixin, TelemetryMixin, ABC):
843
684
  stage=stage_name,
844
685
  old_tier=current_tier.value,
845
686
  new_tier=new_tier.value,
846
- reason=reason
687
+ reason=reason,
847
688
  )
848
689
 
849
690
  return new_tier
@@ -1195,73 +1036,8 @@ class BaseWorkflow(CachingMixin, TelemetryMixin, ABC):
1195
1036
  return f"Error calling LLM: {type(e).__name__}", 0, 0
1196
1037
 
1197
1038
  # Note: _track_telemetry is inherited from TelemetryMixin
1198
-
1199
- def _calculate_cost(self, tier: ModelTier, input_tokens: int, output_tokens: int) -> float:
1200
- """Calculate cost for a stage."""
1201
- tier_name = tier.value
1202
- pricing = MODEL_PRICING.get(tier_name, MODEL_PRICING["capable"])
1203
- input_cost = (input_tokens / 1_000_000) * pricing["input"]
1204
- output_cost = (output_tokens / 1_000_000) * pricing["output"]
1205
- return input_cost + output_cost
1206
-
1207
- def _calculate_baseline_cost(self, input_tokens: int, output_tokens: int) -> float:
1208
- """Calculate what the cost would be using premium tier."""
1209
- pricing = MODEL_PRICING["premium"]
1210
- input_cost = (input_tokens / 1_000_000) * pricing["input"]
1211
- output_cost = (output_tokens / 1_000_000) * pricing["output"]
1212
- return input_cost + output_cost
1213
-
1214
- def _generate_cost_report(self) -> CostReport:
1215
- """Generate cost report from completed stages."""
1216
- total_cost = 0.0
1217
- baseline_cost = 0.0
1218
- by_stage: dict[str, float] = {}
1219
- by_tier: dict[str, float] = {}
1220
-
1221
- for stage in self._stages_run:
1222
- if stage.skipped:
1223
- continue
1224
-
1225
- total_cost += stage.cost
1226
- by_stage[stage.name] = stage.cost
1227
-
1228
- tier_name = stage.tier.value
1229
- by_tier[tier_name] = by_tier.get(tier_name, 0.0) + stage.cost
1230
-
1231
- # Calculate what this would cost at premium tier
1232
- baseline_cost += self._calculate_baseline_cost(stage.input_tokens, stage.output_tokens)
1233
-
1234
- savings = baseline_cost - total_cost
1235
- savings_percent = (savings / baseline_cost * 100) if baseline_cost > 0 else 0.0
1236
-
1237
- # Calculate cache metrics using CachingMixin
1238
- cache_stats = self._get_cache_stats()
1239
- cache_hits = cache_stats["hits"]
1240
- cache_misses = cache_stats["misses"]
1241
- cache_hit_rate = cache_stats["hit_rate"]
1242
- estimated_cost_without_cache = total_cost
1243
- savings_from_cache = 0.0
1244
-
1245
- # Estimate cost without cache (assumes cache hits would have incurred full cost)
1246
- if cache_hits > 0:
1247
- avg_cost_per_call = total_cost / cache_misses if cache_misses > 0 else 0.0
1248
- estimated_additional_cost = cache_hits * avg_cost_per_call
1249
- estimated_cost_without_cache = total_cost + estimated_additional_cost
1250
- savings_from_cache = estimated_additional_cost
1251
-
1252
- return CostReport(
1253
- total_cost=total_cost,
1254
- baseline_cost=baseline_cost,
1255
- savings=savings,
1256
- savings_percent=savings_percent,
1257
- by_stage=by_stage,
1258
- by_tier=by_tier,
1259
- cache_hits=cache_hits,
1260
- cache_misses=cache_misses,
1261
- cache_hit_rate=cache_hit_rate,
1262
- estimated_cost_without_cache=estimated_cost_without_cache,
1263
- savings_from_cache=savings_from_cache,
1264
- )
1039
+ # Note: _calculate_cost, _calculate_baseline_cost, and _generate_cost_report
1040
+ # are inherited from CostTrackingMixin
1265
1041
 
1266
1042
  @abstractmethod
1267
1043
  async def run_stage(
@@ -1429,13 +1205,13 @@ class BaseWorkflow(CachingMixin, TelemetryMixin, ABC):
1429
1205
  "run_id": self._run_id,
1430
1206
  "provider": getattr(self, "_provider_str", "unknown"),
1431
1207
  "stages": len(self.stages),
1432
- }
1208
+ },
1433
1209
  )
1434
1210
  logger.debug(
1435
1211
  "heartbeat_started",
1436
1212
  workflow=self.name,
1437
1213
  agent_id=self._agent_id,
1438
- message="Agent heartbeat tracking started"
1214
+ message="Agent heartbeat tracking started",
1439
1215
  )
1440
1216
  except Exception as e:
1441
1217
  logger.warning(f"Failed to start heartbeat tracking: {e}")
@@ -1534,7 +1310,7 @@ class BaseWorkflow(CachingMixin, TelemetryMixin, ABC):
1534
1310
  heartbeat_coordinator.beat(
1535
1311
  status="running",
1536
1312
  progress=progress,
1537
- current_task=f"Running stage: {stage_name} ({tier.value})"
1313
+ current_task=f"Running stage: {stage_name} ({tier.value})",
1538
1314
  )
1539
1315
  except Exception as e:
1540
1316
  logger.debug(f"Heartbeat update failed: {e}")
@@ -1590,7 +1366,7 @@ class BaseWorkflow(CachingMixin, TelemetryMixin, ABC):
1590
1366
  heartbeat_coordinator.beat(
1591
1367
  status="running",
1592
1368
  progress=progress,
1593
- current_task=f"Completed stage: {stage_name}"
1369
+ current_task=f"Completed stage: {stage_name}",
1594
1370
  )
1595
1371
  except Exception as e:
1596
1372
  logger.debug(f"Heartbeat update failed: {e}")
@@ -1868,7 +1644,7 @@ class BaseWorkflow(CachingMixin, TelemetryMixin, ABC):
1868
1644
  workflow=self.name,
1869
1645
  agent_id=self._agent_id,
1870
1646
  status=final_status,
1871
- message="Agent heartbeat tracking stopped"
1647
+ message="Agent heartbeat tracking stopped",
1872
1648
  )
1873
1649
  except Exception as e:
1874
1650
  logger.warning(f"Failed to stop heartbeat tracking: {e}")
@@ -2273,403 +2049,3 @@ class BaseWorkflow(CachingMixin, TelemetryMixin, ABC):
2273
2049
  parts.append(input_payload)
2274
2050
 
2275
2051
  return "\n".join(parts)
2276
-
2277
- def _parse_xml_response(self, response: str) -> dict[str, Any]:
2278
- """Parse an XML response if XML enforcement is enabled.
2279
-
2280
- Args:
2281
- response: The LLM response text.
2282
-
2283
- Returns:
2284
- Dictionary with parsed fields or raw response data.
2285
-
2286
- """
2287
- from attune.prompts import XmlResponseParser
2288
-
2289
- config = self._get_xml_config()
2290
-
2291
- if not config.get("enforce_response_xml", False):
2292
- # No parsing needed, return as-is
2293
- return {
2294
- "_parsed_response": None,
2295
- "_raw": response,
2296
- }
2297
-
2298
- fallback = config.get("fallback_on_parse_error", True)
2299
- parser = XmlResponseParser(fallback_on_error=fallback)
2300
- parsed = parser.parse(response)
2301
-
2302
- return {
2303
- "_parsed_response": parsed,
2304
- "_raw": response,
2305
- "summary": parsed.summary,
2306
- "findings": [f.to_dict() for f in parsed.findings],
2307
- "checklist": parsed.checklist,
2308
- "xml_parsed": parsed.success,
2309
- "parse_errors": parsed.errors,
2310
- }
2311
-
2312
- def _extract_findings_from_response(
2313
- self,
2314
- response: str,
2315
- files_changed: list[str],
2316
- code_context: str = "",
2317
- ) -> list[dict[str, Any]]:
2318
- """Extract structured findings from LLM response.
2319
-
2320
- Tries multiple strategies in order:
2321
- 1. XML parsing (if XML tags present)
2322
- 2. Regex-based extraction for file:line patterns
2323
- 3. Returns empty list if no findings extractable
2324
-
2325
- Args:
2326
- response: Raw LLM response text
2327
- files_changed: List of files being analyzed (for context)
2328
- code_context: Original code being reviewed (optional)
2329
-
2330
- Returns:
2331
- List of findings matching WorkflowFinding schema:
2332
- [
2333
- {
2334
- "id": "unique-id",
2335
- "file": "relative/path.py",
2336
- "line": 42,
2337
- "column": 10,
2338
- "severity": "high",
2339
- "category": "security",
2340
- "message": "Brief message",
2341
- "details": "Extended explanation",
2342
- "recommendation": "Fix suggestion"
2343
- }
2344
- ]
2345
-
2346
- """
2347
- import re
2348
- import uuid
2349
-
2350
- findings: list[dict[str, Any]] = []
2351
-
2352
- # Strategy 1: Try XML parsing first
2353
- response_lower = response.lower()
2354
- if (
2355
- "<finding>" in response_lower
2356
- or "<issue>" in response_lower
2357
- or "<findings>" in response_lower
2358
- ):
2359
- # Parse XML directly (bypass config checks)
2360
- from attune.prompts import XmlResponseParser
2361
-
2362
- parser = XmlResponseParser(fallback_on_error=True)
2363
- parsed = parser.parse(response)
2364
-
2365
- if parsed.success and parsed.findings:
2366
- for raw_finding in parsed.findings:
2367
- enriched = self._enrich_finding_with_location(
2368
- raw_finding.to_dict(),
2369
- files_changed,
2370
- )
2371
- findings.append(enriched)
2372
- return findings
2373
-
2374
- # Strategy 2: Regex-based extraction for common patterns
2375
- # Match patterns like:
2376
- # - "src/auth.py:42: SQL injection found"
2377
- # - "In file src/auth.py line 42"
2378
- # - "auth.py (line 42, column 10)"
2379
- patterns = [
2380
- # Pattern 1: file.py:line:column: message
2381
- r"([^\s:]+\.(?:py|ts|tsx|js|jsx|java|go|rb|php)):(\d+):(\d+):\s*(.+)",
2382
- # Pattern 2: file.py:line: message
2383
- r"([^\s:]+\.(?:py|ts|tsx|js|jsx|java|go|rb|php)):(\d+):\s*(.+)",
2384
- # Pattern 3: in file X line Y
2385
- r"(?:in file|file)\s+([^\s]+\.(?:py|ts|tsx|js|jsx|java|go|rb|php))\s+line\s+(\d+)",
2386
- # Pattern 4: file.py (line X)
2387
- r"([^\s]+\.(?:py|ts|tsx|js|jsx|java|go|rb|php))\s*\(line\s+(\d+)(?:,\s*col(?:umn)?\s+(\d+))?\)",
2388
- ]
2389
-
2390
- for pattern in patterns:
2391
- matches = re.findall(pattern, response, re.IGNORECASE)
2392
- for match in matches:
2393
- if len(match) >= 2:
2394
- file_path = match[0]
2395
- line = int(match[1])
2396
-
2397
- # Handle different pattern formats
2398
- if len(match) == 4 and match[2].isdigit():
2399
- # Pattern 1: file:line:col:message
2400
- column = int(match[2])
2401
- message = match[3]
2402
- elif len(match) == 3 and match[2] and not match[2].isdigit():
2403
- # Pattern 2: file:line:message
2404
- column = 1
2405
- message = match[2]
2406
- elif len(match) == 3 and match[2].isdigit():
2407
- # Pattern 4: file (line col)
2408
- column = int(match[2])
2409
- message = ""
2410
- else:
2411
- # Pattern 3: in file X line Y (no message)
2412
- column = 1
2413
- message = ""
2414
-
2415
- # Determine severity from keywords in message
2416
- severity = self._infer_severity(message)
2417
- category = self._infer_category(message)
2418
-
2419
- findings.append(
2420
- {
2421
- "id": str(uuid.uuid4())[:8],
2422
- "file": file_path,
2423
- "line": line,
2424
- "column": column,
2425
- "severity": severity,
2426
- "category": category,
2427
- "message": message.strip() if message else "",
2428
- "details": "",
2429
- "recommendation": "",
2430
- },
2431
- )
2432
-
2433
- # Deduplicate by file:line
2434
- seen = set()
2435
- unique_findings = []
2436
- for finding in findings:
2437
- key = (finding["file"], finding["line"])
2438
- if key not in seen:
2439
- seen.add(key)
2440
- unique_findings.append(finding)
2441
-
2442
- return unique_findings
2443
-
2444
- def _enrich_finding_with_location(
2445
- self,
2446
- raw_finding: dict[str, Any],
2447
- files_changed: list[str],
2448
- ) -> dict[str, Any]:
2449
- """Enrich a finding from XML parser with file/line/column fields.
2450
-
2451
- Args:
2452
- raw_finding: Finding dict from XML parser (has 'location' string field)
2453
- files_changed: List of files being analyzed
2454
-
2455
- Returns:
2456
- Enriched finding dict with file, line, column fields
2457
-
2458
- """
2459
- import uuid
2460
-
2461
- location_str = raw_finding.get("location", "")
2462
- file_path, line, column = self._parse_location_string(location_str, files_changed)
2463
-
2464
- # Map category from severity or title keywords
2465
- category = self._infer_category(
2466
- raw_finding.get("title", "") + " " + raw_finding.get("details", ""),
2467
- )
2468
-
2469
- return {
2470
- "id": str(uuid.uuid4())[:8],
2471
- "file": file_path,
2472
- "line": line,
2473
- "column": column,
2474
- "severity": raw_finding.get("severity", "medium"),
2475
- "category": category,
2476
- "message": raw_finding.get("title", ""),
2477
- "details": raw_finding.get("details", ""),
2478
- "recommendation": raw_finding.get("fix", ""),
2479
- }
2480
-
2481
- def _parse_location_string(
2482
- self,
2483
- location: str,
2484
- files_changed: list[str],
2485
- ) -> tuple[str, int, int]:
2486
- """Parse a location string to extract file, line, column.
2487
-
2488
- Handles formats like:
2489
- - "src/auth.py:42:10"
2490
- - "src/auth.py:42"
2491
- - "auth.py line 42"
2492
- - "line 42 in auth.py"
2493
-
2494
- Args:
2495
- location: Location string from finding
2496
- files_changed: List of files being analyzed (for fallback)
2497
-
2498
- Returns:
2499
- Tuple of (file_path, line_number, column_number)
2500
- Defaults: ("", 1, 1) if parsing fails
2501
-
2502
- """
2503
- import re
2504
-
2505
- if not location:
2506
- # Fallback: use first file if available
2507
- return (files_changed[0] if files_changed else "", 1, 1)
2508
-
2509
- # Try colon-separated format: file.py:line:col
2510
- match = re.search(
2511
- r"([^\s:]+\.(?:py|ts|tsx|js|jsx|java|go|rb|php)):(\d+)(?::(\d+))?",
2512
- location,
2513
- )
2514
- if match:
2515
- file_path = match.group(1)
2516
- line = int(match.group(2))
2517
- column = int(match.group(3)) if match.group(3) else 1
2518
- return (file_path, line, column)
2519
-
2520
- # Try "line X in file.py" format
2521
- match = re.search(
2522
- r"line\s+(\d+)\s+(?:in|of)\s+([^\s]+\.(?:py|ts|tsx|js|jsx|java|go|rb|php))",
2523
- location,
2524
- re.IGNORECASE,
2525
- )
2526
- if match:
2527
- line = int(match.group(1))
2528
- file_path = match.group(2)
2529
- return (file_path, line, 1)
2530
-
2531
- # Try "file.py line X" format
2532
- match = re.search(
2533
- r"([^\s]+\.(?:py|ts|tsx|js|jsx|java|go|rb|php))\s+line\s+(\d+)",
2534
- location,
2535
- re.IGNORECASE,
2536
- )
2537
- if match:
2538
- file_path = match.group(1)
2539
- line = int(match.group(2))
2540
- return (file_path, line, 1)
2541
-
2542
- # Extract just line number if present
2543
- match = re.search(r"line\s+(\d+)", location, re.IGNORECASE)
2544
- if match:
2545
- line = int(match.group(1))
2546
- # Use first file from files_changed as fallback
2547
- file_path = files_changed[0] if files_changed else ""
2548
- return (file_path, line, 1)
2549
-
2550
- # Couldn't parse - return defaults
2551
- return (files_changed[0] if files_changed else "", 1, 1)
2552
-
2553
- def _infer_severity(self, text: str) -> str:
2554
- """Infer severity from keywords in text.
2555
-
2556
- Args:
2557
- text: Message or title text
2558
-
2559
- Returns:
2560
- Severity level: critical, high, medium, low, or info
2561
-
2562
- """
2563
- text_lower = text.lower()
2564
-
2565
- if any(
2566
- word in text_lower
2567
- for word in [
2568
- "critical",
2569
- "severe",
2570
- "exploit",
2571
- "vulnerability",
2572
- "injection",
2573
- "remote code execution",
2574
- "rce",
2575
- ]
2576
- ):
2577
- return "critical"
2578
-
2579
- if any(
2580
- word in text_lower
2581
- for word in [
2582
- "high",
2583
- "security",
2584
- "unsafe",
2585
- "dangerous",
2586
- "xss",
2587
- "csrf",
2588
- "auth",
2589
- "password",
2590
- "secret",
2591
- ]
2592
- ):
2593
- return "high"
2594
-
2595
- if any(
2596
- word in text_lower
2597
- for word in [
2598
- "warning",
2599
- "issue",
2600
- "problem",
2601
- "bug",
2602
- "error",
2603
- "deprecated",
2604
- "leak",
2605
- ]
2606
- ):
2607
- return "medium"
2608
-
2609
- if any(word in text_lower for word in ["low", "minor", "style", "format", "typo"]):
2610
- return "low"
2611
-
2612
- return "info"
2613
-
2614
- def _infer_category(self, text: str) -> str:
2615
- """Infer finding category from keywords.
2616
-
2617
- Args:
2618
- text: Message or title text
2619
-
2620
- Returns:
2621
- Category: security, performance, maintainability, style, or correctness
2622
-
2623
- """
2624
- text_lower = text.lower()
2625
-
2626
- if any(
2627
- word in text_lower
2628
- for word in [
2629
- "security",
2630
- "vulnerability",
2631
- "injection",
2632
- "xss",
2633
- "csrf",
2634
- "auth",
2635
- "encrypt",
2636
- "password",
2637
- "secret",
2638
- "unsafe",
2639
- ]
2640
- ):
2641
- return "security"
2642
-
2643
- if any(
2644
- word in text_lower
2645
- for word in [
2646
- "performance",
2647
- "slow",
2648
- "memory",
2649
- "leak",
2650
- "inefficient",
2651
- "optimization",
2652
- "cache",
2653
- ]
2654
- ):
2655
- return "performance"
2656
-
2657
- if any(
2658
- word in text_lower
2659
- for word in [
2660
- "complex",
2661
- "refactor",
2662
- "duplicate",
2663
- "maintainability",
2664
- "readability",
2665
- "documentation",
2666
- ]
2667
- ):
2668
- return "maintainability"
2669
-
2670
- if any(
2671
- word in text_lower for word in ["style", "format", "lint", "convention", "whitespace"]
2672
- ):
2673
- return "style"
2674
-
2675
- return "correctness"