attune-ai 2.1.4__py3-none-any.whl → 2.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. attune/cli/__init__.py +3 -55
  2. attune/cli/commands/batch.py +4 -12
  3. attune/cli/commands/cache.py +7 -15
  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 +34 -28
  17. attune/cli_router.py +9 -7
  18. attune/cli_unified.py +3 -0
  19. attune/core.py +190 -0
  20. attune/dashboard/app.py +4 -2
  21. attune/dashboard/simple_server.py +3 -1
  22. attune/dashboard/standalone_server.py +7 -3
  23. attune/mcp/server.py +54 -102
  24. attune/memory/long_term.py +0 -2
  25. attune/memory/short_term/__init__.py +84 -0
  26. attune/memory/short_term/base.py +467 -0
  27. attune/memory/short_term/batch.py +219 -0
  28. attune/memory/short_term/caching.py +227 -0
  29. attune/memory/short_term/conflicts.py +265 -0
  30. attune/memory/short_term/cross_session.py +122 -0
  31. attune/memory/short_term/facade.py +655 -0
  32. attune/memory/short_term/pagination.py +215 -0
  33. attune/memory/short_term/patterns.py +271 -0
  34. attune/memory/short_term/pubsub.py +286 -0
  35. attune/memory/short_term/queues.py +244 -0
  36. attune/memory/short_term/security.py +300 -0
  37. attune/memory/short_term/sessions.py +250 -0
  38. attune/memory/short_term/streams.py +249 -0
  39. attune/memory/short_term/timelines.py +234 -0
  40. attune/memory/short_term/transactions.py +186 -0
  41. attune/memory/short_term/working.py +252 -0
  42. attune/meta_workflows/cli_commands/__init__.py +3 -0
  43. attune/meta_workflows/cli_commands/agent_commands.py +0 -4
  44. attune/meta_workflows/cli_commands/analytics_commands.py +0 -6
  45. attune/meta_workflows/cli_commands/config_commands.py +0 -5
  46. attune/meta_workflows/cli_commands/memory_commands.py +0 -5
  47. attune/meta_workflows/cli_commands/template_commands.py +0 -5
  48. attune/meta_workflows/cli_commands/workflow_commands.py +0 -6
  49. attune/meta_workflows/workflow.py +1 -1
  50. attune/models/adaptive_routing.py +4 -8
  51. attune/models/auth_cli.py +3 -9
  52. attune/models/auth_strategy.py +2 -4
  53. attune/models/provider_config.py +20 -1
  54. attune/models/telemetry/analytics.py +0 -2
  55. attune/models/telemetry/backend.py +0 -3
  56. attune/models/telemetry/storage.py +0 -2
  57. attune/orchestration/_strategies/__init__.py +156 -0
  58. attune/orchestration/_strategies/base.py +231 -0
  59. attune/orchestration/_strategies/conditional_strategies.py +373 -0
  60. attune/orchestration/_strategies/conditions.py +369 -0
  61. attune/orchestration/_strategies/core_strategies.py +491 -0
  62. attune/orchestration/_strategies/data_classes.py +64 -0
  63. attune/orchestration/_strategies/nesting.py +233 -0
  64. attune/orchestration/execution_strategies.py +58 -1567
  65. attune/orchestration/meta_orchestrator.py +1 -3
  66. attune/project_index/scanner.py +1 -3
  67. attune/project_index/scanner_parallel.py +7 -5
  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 +3 -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 +1 -0
  77. attune/workflow_commands.py +1 -3
  78. attune/workflows/__init__.py +53 -10
  79. attune/workflows/autonomous_test_gen.py +160 -104
  80. attune/workflows/base.py +48 -664
  81. attune/workflows/batch_processing.py +2 -4
  82. attune/workflows/compat.py +156 -0
  83. attune/workflows/cost_mixin.py +141 -0
  84. attune/workflows/data_classes.py +92 -0
  85. attune/workflows/document_gen/workflow.py +11 -14
  86. attune/workflows/history.py +62 -37
  87. attune/workflows/llm_base.py +2 -4
  88. attune/workflows/migration.py +422 -0
  89. attune/workflows/output.py +3 -9
  90. attune/workflows/parsing_mixin.py +427 -0
  91. attune/workflows/perf_audit.py +3 -1
  92. attune/workflows/progress.py +10 -13
  93. attune/workflows/release_prep.py +5 -1
  94. attune/workflows/routing.py +0 -2
  95. attune/workflows/secure_release.py +2 -1
  96. attune/workflows/security_audit.py +19 -14
  97. attune/workflows/security_audit_phase3.py +28 -22
  98. attune/workflows/seo_optimization.py +29 -29
  99. attune/workflows/test_gen/test_templates.py +1 -4
  100. attune/workflows/test_gen/workflow.py +0 -2
  101. attune/workflows/test_gen_behavioral.py +7 -20
  102. attune/workflows/test_gen_parallel.py +6 -4
  103. {attune_ai-2.1.4.dist-info → attune_ai-2.2.0.dist-info}/METADATA +4 -3
  104. {attune_ai-2.1.4.dist-info → attune_ai-2.2.0.dist-info}/RECORD +119 -94
  105. {attune_ai-2.1.4.dist-info → attune_ai-2.2.0.dist-info}/entry_points.txt +0 -2
  106. attune_healthcare/monitors/monitoring/__init__.py +9 -9
  107. attune_llm/agent_factory/__init__.py +6 -6
  108. attune_llm/commands/__init__.py +10 -10
  109. attune_llm/commands/models.py +3 -3
  110. attune_llm/config/__init__.py +8 -8
  111. attune_llm/learning/__init__.py +3 -3
  112. attune_llm/learning/extractor.py +5 -3
  113. attune_llm/learning/storage.py +5 -3
  114. attune_llm/security/__init__.py +17 -17
  115. attune_llm/utils/tokens.py +3 -1
  116. attune/cli_legacy.py +0 -3957
  117. attune/memory/short_term.py +0 -2192
  118. attune/workflows/manage_docs.py +0 -87
  119. attune/workflows/test5.py +0 -125
  120. {attune_ai-2.1.4.dist-info → attune_ai-2.2.0.dist-info}/WHEEL +0 -0
  121. {attune_ai-2.1.4.dist-info → attune_ai-2.2.0.dist-info}/licenses/LICENSE +0 -0
  122. {attune_ai-2.1.4.dist-info → attune_ai-2.2.0.dist-info}/licenses/LICENSE_CHANGE_ANNOUNCEMENT.md +0 -0
  123. {attune_ai-2.1.4.dist-info → attune_ai-2.2.0.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,177 +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)
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
258
109
 
259
110
 
260
111
  # Global singleton for workflow history store (lazy-initialized)
@@ -514,10 +365,11 @@ def get_workflow_stats(history_file: str = WORKFLOW_HISTORY_FILE) -> dict:
514
365
  }
515
366
 
516
367
 
517
- class BaseWorkflow(CachingMixin, TelemetryMixin, ABC):
368
+ class BaseWorkflow(CachingMixin, TelemetryMixin, ResponseParsingMixin, CostTrackingMixin, ABC):
518
369
  """Base class for multi-model workflows.
519
370
 
520
- Inherits from CachingMixin and TelemetryMixin (extracted for maintainability).
371
+ Inherits from CachingMixin, TelemetryMixin, ResponseParsingMixin, and
372
+ CostTrackingMixin (extracted for maintainability).
521
373
 
522
374
  Subclasses define stages and tier mappings:
523
375
 
@@ -699,13 +551,13 @@ class BaseWorkflow(CachingMixin, TelemetryMixin, ABC):
699
551
  logger.debug(
700
552
  "adaptive_routing_initialized",
701
553
  workflow=self.name,
702
- message="Adaptive routing enabled for cost optimization"
554
+ message="Adaptive routing enabled for cost optimization",
703
555
  )
704
556
  else:
705
557
  logger.warning(
706
558
  "adaptive_routing_unavailable",
707
559
  workflow=self.name,
708
- message="Telemetry not available, adaptive routing disabled"
560
+ message="Telemetry not available, adaptive routing disabled",
709
561
  )
710
562
  self._enable_adaptive_routing = False
711
563
  except ImportError as e:
@@ -713,7 +565,7 @@ class BaseWorkflow(CachingMixin, TelemetryMixin, ABC):
713
565
  "adaptive_routing_import_error",
714
566
  workflow=self.name,
715
567
  error=str(e),
716
- message="Failed to import AdaptiveModelRouter"
568
+ message="Failed to import AdaptiveModelRouter",
717
569
  )
718
570
  self._enable_adaptive_routing = False
719
571
 
@@ -737,14 +589,14 @@ class BaseWorkflow(CachingMixin, TelemetryMixin, ABC):
737
589
  "heartbeat_tracking_initialized",
738
590
  workflow=self.name,
739
591
  agent_id=self._agent_id,
740
- message="Heartbeat tracking enabled for agent liveness monitoring"
592
+ message="Heartbeat tracking enabled for agent liveness monitoring",
741
593
  )
742
594
  except ImportError as e:
743
595
  logger.warning(
744
596
  "heartbeat_tracking_import_error",
745
597
  workflow=self.name,
746
598
  error=str(e),
747
- message="Failed to import HeartbeatCoordinator"
599
+ message="Failed to import HeartbeatCoordinator",
748
600
  )
749
601
  self._enable_heartbeat_tracking = False
750
602
  except Exception as e:
@@ -752,7 +604,7 @@ class BaseWorkflow(CachingMixin, TelemetryMixin, ABC):
752
604
  "heartbeat_tracking_init_error",
753
605
  workflow=self.name,
754
606
  error=str(e),
755
- message="Failed to initialize HeartbeatCoordinator (Redis unavailable?)"
607
+ message="Failed to initialize HeartbeatCoordinator (Redis unavailable?)",
756
608
  )
757
609
  self._enable_heartbeat_tracking = False
758
610
 
@@ -776,14 +628,14 @@ class BaseWorkflow(CachingMixin, TelemetryMixin, ABC):
776
628
  "coordination_initialized",
777
629
  workflow=self.name,
778
630
  agent_id=self._agent_id,
779
- message="Coordination signals enabled for inter-agent communication"
631
+ message="Coordination signals enabled for inter-agent communication",
780
632
  )
781
633
  except ImportError as e:
782
634
  logger.warning(
783
635
  "coordination_import_error",
784
636
  workflow=self.name,
785
637
  error=str(e),
786
- message="Failed to import CoordinationSignals"
638
+ message="Failed to import CoordinationSignals",
787
639
  )
788
640
  self._enable_coordination = False
789
641
  except Exception as e:
@@ -791,7 +643,7 @@ class BaseWorkflow(CachingMixin, TelemetryMixin, ABC):
791
643
  "coordination_init_error",
792
644
  workflow=self.name,
793
645
  error=str(e),
794
- message="Failed to initialize CoordinationSignals (Redis unavailable?)"
646
+ message="Failed to initialize CoordinationSignals (Redis unavailable?)",
795
647
  )
796
648
  self._enable_coordination = False
797
649
 
@@ -815,10 +667,7 @@ class BaseWorkflow(CachingMixin, TelemetryMixin, ABC):
815
667
  return current_tier
816
668
 
817
669
  # Check if tier upgrade is recommended
818
- should_upgrade, reason = router.recommend_tier_upgrade(
819
- workflow=self.name,
820
- stage=stage_name
821
- )
670
+ should_upgrade, reason = router.recommend_tier_upgrade(workflow=self.name, stage=stage_name)
822
671
 
823
672
  if should_upgrade:
824
673
  # Upgrade to next tier: CHEAP → CAPABLE → PREMIUM
@@ -835,7 +684,7 @@ class BaseWorkflow(CachingMixin, TelemetryMixin, ABC):
835
684
  stage=stage_name,
836
685
  old_tier=current_tier.value,
837
686
  new_tier=new_tier.value,
838
- reason=reason
687
+ reason=reason,
839
688
  )
840
689
 
841
690
  return new_tier
@@ -1187,73 +1036,8 @@ class BaseWorkflow(CachingMixin, TelemetryMixin, ABC):
1187
1036
  return f"Error calling LLM: {type(e).__name__}", 0, 0
1188
1037
 
1189
1038
  # Note: _track_telemetry is inherited from TelemetryMixin
1190
-
1191
- def _calculate_cost(self, tier: ModelTier, input_tokens: int, output_tokens: int) -> float:
1192
- """Calculate cost for a stage."""
1193
- tier_name = tier.value
1194
- pricing = MODEL_PRICING.get(tier_name, MODEL_PRICING["capable"])
1195
- input_cost = (input_tokens / 1_000_000) * pricing["input"]
1196
- output_cost = (output_tokens / 1_000_000) * pricing["output"]
1197
- return input_cost + output_cost
1198
-
1199
- def _calculate_baseline_cost(self, input_tokens: int, output_tokens: int) -> float:
1200
- """Calculate what the cost would be using premium tier."""
1201
- pricing = MODEL_PRICING["premium"]
1202
- input_cost = (input_tokens / 1_000_000) * pricing["input"]
1203
- output_cost = (output_tokens / 1_000_000) * pricing["output"]
1204
- return input_cost + output_cost
1205
-
1206
- def _generate_cost_report(self) -> CostReport:
1207
- """Generate cost report from completed stages."""
1208
- total_cost = 0.0
1209
- baseline_cost = 0.0
1210
- by_stage: dict[str, float] = {}
1211
- by_tier: dict[str, float] = {}
1212
-
1213
- for stage in self._stages_run:
1214
- if stage.skipped:
1215
- continue
1216
-
1217
- total_cost += stage.cost
1218
- by_stage[stage.name] = stage.cost
1219
-
1220
- tier_name = stage.tier.value
1221
- by_tier[tier_name] = by_tier.get(tier_name, 0.0) + stage.cost
1222
-
1223
- # Calculate what this would cost at premium tier
1224
- baseline_cost += self._calculate_baseline_cost(stage.input_tokens, stage.output_tokens)
1225
-
1226
- savings = baseline_cost - total_cost
1227
- savings_percent = (savings / baseline_cost * 100) if baseline_cost > 0 else 0.0
1228
-
1229
- # Calculate cache metrics using CachingMixin
1230
- cache_stats = self._get_cache_stats()
1231
- cache_hits = cache_stats["hits"]
1232
- cache_misses = cache_stats["misses"]
1233
- cache_hit_rate = cache_stats["hit_rate"]
1234
- estimated_cost_without_cache = total_cost
1235
- savings_from_cache = 0.0
1236
-
1237
- # Estimate cost without cache (assumes cache hits would have incurred full cost)
1238
- if cache_hits > 0:
1239
- avg_cost_per_call = total_cost / cache_misses if cache_misses > 0 else 0.0
1240
- estimated_additional_cost = cache_hits * avg_cost_per_call
1241
- estimated_cost_without_cache = total_cost + estimated_additional_cost
1242
- savings_from_cache = estimated_additional_cost
1243
-
1244
- return CostReport(
1245
- total_cost=total_cost,
1246
- baseline_cost=baseline_cost,
1247
- savings=savings,
1248
- savings_percent=savings_percent,
1249
- by_stage=by_stage,
1250
- by_tier=by_tier,
1251
- cache_hits=cache_hits,
1252
- cache_misses=cache_misses,
1253
- cache_hit_rate=cache_hit_rate,
1254
- estimated_cost_without_cache=estimated_cost_without_cache,
1255
- savings_from_cache=savings_from_cache,
1256
- )
1039
+ # Note: _calculate_cost, _calculate_baseline_cost, and _generate_cost_report
1040
+ # are inherited from CostTrackingMixin
1257
1041
 
1258
1042
  @abstractmethod
1259
1043
  async def run_stage(
@@ -1421,13 +1205,13 @@ class BaseWorkflow(CachingMixin, TelemetryMixin, ABC):
1421
1205
  "run_id": self._run_id,
1422
1206
  "provider": getattr(self, "_provider_str", "unknown"),
1423
1207
  "stages": len(self.stages),
1424
- }
1208
+ },
1425
1209
  )
1426
1210
  logger.debug(
1427
1211
  "heartbeat_started",
1428
1212
  workflow=self.name,
1429
1213
  agent_id=self._agent_id,
1430
- message="Agent heartbeat tracking started"
1214
+ message="Agent heartbeat tracking started",
1431
1215
  )
1432
1216
  except Exception as e:
1433
1217
  logger.warning(f"Failed to start heartbeat tracking: {e}")
@@ -1526,7 +1310,7 @@ class BaseWorkflow(CachingMixin, TelemetryMixin, ABC):
1526
1310
  heartbeat_coordinator.beat(
1527
1311
  status="running",
1528
1312
  progress=progress,
1529
- current_task=f"Running stage: {stage_name} ({tier.value})"
1313
+ current_task=f"Running stage: {stage_name} ({tier.value})",
1530
1314
  )
1531
1315
  except Exception as e:
1532
1316
  logger.debug(f"Heartbeat update failed: {e}")
@@ -1582,7 +1366,7 @@ class BaseWorkflow(CachingMixin, TelemetryMixin, ABC):
1582
1366
  heartbeat_coordinator.beat(
1583
1367
  status="running",
1584
1368
  progress=progress,
1585
- current_task=f"Completed stage: {stage_name}"
1369
+ current_task=f"Completed stage: {stage_name}",
1586
1370
  )
1587
1371
  except Exception as e:
1588
1372
  logger.debug(f"Heartbeat update failed: {e}")
@@ -1860,7 +1644,7 @@ class BaseWorkflow(CachingMixin, TelemetryMixin, ABC):
1860
1644
  workflow=self.name,
1861
1645
  agent_id=self._agent_id,
1862
1646
  status=final_status,
1863
- message="Agent heartbeat tracking stopped"
1647
+ message="Agent heartbeat tracking stopped",
1864
1648
  )
1865
1649
  except Exception as e:
1866
1650
  logger.warning(f"Failed to stop heartbeat tracking: {e}")
@@ -2265,403 +2049,3 @@ class BaseWorkflow(CachingMixin, TelemetryMixin, ABC):
2265
2049
  parts.append(input_payload)
2266
2050
 
2267
2051
  return "\n".join(parts)
2268
-
2269
- def _parse_xml_response(self, response: str) -> dict[str, Any]:
2270
- """Parse an XML response if XML enforcement is enabled.
2271
-
2272
- Args:
2273
- response: The LLM response text.
2274
-
2275
- Returns:
2276
- Dictionary with parsed fields or raw response data.
2277
-
2278
- """
2279
- from attune.prompts import XmlResponseParser
2280
-
2281
- config = self._get_xml_config()
2282
-
2283
- if not config.get("enforce_response_xml", False):
2284
- # No parsing needed, return as-is
2285
- return {
2286
- "_parsed_response": None,
2287
- "_raw": response,
2288
- }
2289
-
2290
- fallback = config.get("fallback_on_parse_error", True)
2291
- parser = XmlResponseParser(fallback_on_error=fallback)
2292
- parsed = parser.parse(response)
2293
-
2294
- return {
2295
- "_parsed_response": parsed,
2296
- "_raw": response,
2297
- "summary": parsed.summary,
2298
- "findings": [f.to_dict() for f in parsed.findings],
2299
- "checklist": parsed.checklist,
2300
- "xml_parsed": parsed.success,
2301
- "parse_errors": parsed.errors,
2302
- }
2303
-
2304
- def _extract_findings_from_response(
2305
- self,
2306
- response: str,
2307
- files_changed: list[str],
2308
- code_context: str = "",
2309
- ) -> list[dict[str, Any]]:
2310
- """Extract structured findings from LLM response.
2311
-
2312
- Tries multiple strategies in order:
2313
- 1. XML parsing (if XML tags present)
2314
- 2. Regex-based extraction for file:line patterns
2315
- 3. Returns empty list if no findings extractable
2316
-
2317
- Args:
2318
- response: Raw LLM response text
2319
- files_changed: List of files being analyzed (for context)
2320
- code_context: Original code being reviewed (optional)
2321
-
2322
- Returns:
2323
- List of findings matching WorkflowFinding schema:
2324
- [
2325
- {
2326
- "id": "unique-id",
2327
- "file": "relative/path.py",
2328
- "line": 42,
2329
- "column": 10,
2330
- "severity": "high",
2331
- "category": "security",
2332
- "message": "Brief message",
2333
- "details": "Extended explanation",
2334
- "recommendation": "Fix suggestion"
2335
- }
2336
- ]
2337
-
2338
- """
2339
- import re
2340
- import uuid
2341
-
2342
- findings: list[dict[str, Any]] = []
2343
-
2344
- # Strategy 1: Try XML parsing first
2345
- response_lower = response.lower()
2346
- if (
2347
- "<finding>" in response_lower
2348
- or "<issue>" in response_lower
2349
- or "<findings>" in response_lower
2350
- ):
2351
- # Parse XML directly (bypass config checks)
2352
- from attune.prompts import XmlResponseParser
2353
-
2354
- parser = XmlResponseParser(fallback_on_error=True)
2355
- parsed = parser.parse(response)
2356
-
2357
- if parsed.success and parsed.findings:
2358
- for raw_finding in parsed.findings:
2359
- enriched = self._enrich_finding_with_location(
2360
- raw_finding.to_dict(),
2361
- files_changed,
2362
- )
2363
- findings.append(enriched)
2364
- return findings
2365
-
2366
- # Strategy 2: Regex-based extraction for common patterns
2367
- # Match patterns like:
2368
- # - "src/auth.py:42: SQL injection found"
2369
- # - "In file src/auth.py line 42"
2370
- # - "auth.py (line 42, column 10)"
2371
- patterns = [
2372
- # Pattern 1: file.py:line:column: message
2373
- r"([^\s:]+\.(?:py|ts|tsx|js|jsx|java|go|rb|php)):(\d+):(\d+):\s*(.+)",
2374
- # Pattern 2: file.py:line: message
2375
- r"([^\s:]+\.(?:py|ts|tsx|js|jsx|java|go|rb|php)):(\d+):\s*(.+)",
2376
- # Pattern 3: in file X line Y
2377
- r"(?:in file|file)\s+([^\s]+\.(?:py|ts|tsx|js|jsx|java|go|rb|php))\s+line\s+(\d+)",
2378
- # Pattern 4: file.py (line X)
2379
- r"([^\s]+\.(?:py|ts|tsx|js|jsx|java|go|rb|php))\s*\(line\s+(\d+)(?:,\s*col(?:umn)?\s+(\d+))?\)",
2380
- ]
2381
-
2382
- for pattern in patterns:
2383
- matches = re.findall(pattern, response, re.IGNORECASE)
2384
- for match in matches:
2385
- if len(match) >= 2:
2386
- file_path = match[0]
2387
- line = int(match[1])
2388
-
2389
- # Handle different pattern formats
2390
- if len(match) == 4 and match[2].isdigit():
2391
- # Pattern 1: file:line:col:message
2392
- column = int(match[2])
2393
- message = match[3]
2394
- elif len(match) == 3 and match[2] and not match[2].isdigit():
2395
- # Pattern 2: file:line:message
2396
- column = 1
2397
- message = match[2]
2398
- elif len(match) == 3 and match[2].isdigit():
2399
- # Pattern 4: file (line col)
2400
- column = int(match[2])
2401
- message = ""
2402
- else:
2403
- # Pattern 3: in file X line Y (no message)
2404
- column = 1
2405
- message = ""
2406
-
2407
- # Determine severity from keywords in message
2408
- severity = self._infer_severity(message)
2409
- category = self._infer_category(message)
2410
-
2411
- findings.append(
2412
- {
2413
- "id": str(uuid.uuid4())[:8],
2414
- "file": file_path,
2415
- "line": line,
2416
- "column": column,
2417
- "severity": severity,
2418
- "category": category,
2419
- "message": message.strip() if message else "",
2420
- "details": "",
2421
- "recommendation": "",
2422
- },
2423
- )
2424
-
2425
- # Deduplicate by file:line
2426
- seen = set()
2427
- unique_findings = []
2428
- for finding in findings:
2429
- key = (finding["file"], finding["line"])
2430
- if key not in seen:
2431
- seen.add(key)
2432
- unique_findings.append(finding)
2433
-
2434
- return unique_findings
2435
-
2436
- def _enrich_finding_with_location(
2437
- self,
2438
- raw_finding: dict[str, Any],
2439
- files_changed: list[str],
2440
- ) -> dict[str, Any]:
2441
- """Enrich a finding from XML parser with file/line/column fields.
2442
-
2443
- Args:
2444
- raw_finding: Finding dict from XML parser (has 'location' string field)
2445
- files_changed: List of files being analyzed
2446
-
2447
- Returns:
2448
- Enriched finding dict with file, line, column fields
2449
-
2450
- """
2451
- import uuid
2452
-
2453
- location_str = raw_finding.get("location", "")
2454
- file_path, line, column = self._parse_location_string(location_str, files_changed)
2455
-
2456
- # Map category from severity or title keywords
2457
- category = self._infer_category(
2458
- raw_finding.get("title", "") + " " + raw_finding.get("details", ""),
2459
- )
2460
-
2461
- return {
2462
- "id": str(uuid.uuid4())[:8],
2463
- "file": file_path,
2464
- "line": line,
2465
- "column": column,
2466
- "severity": raw_finding.get("severity", "medium"),
2467
- "category": category,
2468
- "message": raw_finding.get("title", ""),
2469
- "details": raw_finding.get("details", ""),
2470
- "recommendation": raw_finding.get("fix", ""),
2471
- }
2472
-
2473
- def _parse_location_string(
2474
- self,
2475
- location: str,
2476
- files_changed: list[str],
2477
- ) -> tuple[str, int, int]:
2478
- """Parse a location string to extract file, line, column.
2479
-
2480
- Handles formats like:
2481
- - "src/auth.py:42:10"
2482
- - "src/auth.py:42"
2483
- - "auth.py line 42"
2484
- - "line 42 in auth.py"
2485
-
2486
- Args:
2487
- location: Location string from finding
2488
- files_changed: List of files being analyzed (for fallback)
2489
-
2490
- Returns:
2491
- Tuple of (file_path, line_number, column_number)
2492
- Defaults: ("", 1, 1) if parsing fails
2493
-
2494
- """
2495
- import re
2496
-
2497
- if not location:
2498
- # Fallback: use first file if available
2499
- return (files_changed[0] if files_changed else "", 1, 1)
2500
-
2501
- # Try colon-separated format: file.py:line:col
2502
- match = re.search(
2503
- r"([^\s:]+\.(?:py|ts|tsx|js|jsx|java|go|rb|php)):(\d+)(?::(\d+))?",
2504
- location,
2505
- )
2506
- if match:
2507
- file_path = match.group(1)
2508
- line = int(match.group(2))
2509
- column = int(match.group(3)) if match.group(3) else 1
2510
- return (file_path, line, column)
2511
-
2512
- # Try "line X in file.py" format
2513
- match = re.search(
2514
- r"line\s+(\d+)\s+(?:in|of)\s+([^\s]+\.(?:py|ts|tsx|js|jsx|java|go|rb|php))",
2515
- location,
2516
- re.IGNORECASE,
2517
- )
2518
- if match:
2519
- line = int(match.group(1))
2520
- file_path = match.group(2)
2521
- return (file_path, line, 1)
2522
-
2523
- # Try "file.py line X" format
2524
- match = re.search(
2525
- r"([^\s]+\.(?:py|ts|tsx|js|jsx|java|go|rb|php))\s+line\s+(\d+)",
2526
- location,
2527
- re.IGNORECASE,
2528
- )
2529
- if match:
2530
- file_path = match.group(1)
2531
- line = int(match.group(2))
2532
- return (file_path, line, 1)
2533
-
2534
- # Extract just line number if present
2535
- match = re.search(r"line\s+(\d+)", location, re.IGNORECASE)
2536
- if match:
2537
- line = int(match.group(1))
2538
- # Use first file from files_changed as fallback
2539
- file_path = files_changed[0] if files_changed else ""
2540
- return (file_path, line, 1)
2541
-
2542
- # Couldn't parse - return defaults
2543
- return (files_changed[0] if files_changed else "", 1, 1)
2544
-
2545
- def _infer_severity(self, text: str) -> str:
2546
- """Infer severity from keywords in text.
2547
-
2548
- Args:
2549
- text: Message or title text
2550
-
2551
- Returns:
2552
- Severity level: critical, high, medium, low, or info
2553
-
2554
- """
2555
- text_lower = text.lower()
2556
-
2557
- if any(
2558
- word in text_lower
2559
- for word in [
2560
- "critical",
2561
- "severe",
2562
- "exploit",
2563
- "vulnerability",
2564
- "injection",
2565
- "remote code execution",
2566
- "rce",
2567
- ]
2568
- ):
2569
- return "critical"
2570
-
2571
- if any(
2572
- word in text_lower
2573
- for word in [
2574
- "high",
2575
- "security",
2576
- "unsafe",
2577
- "dangerous",
2578
- "xss",
2579
- "csrf",
2580
- "auth",
2581
- "password",
2582
- "secret",
2583
- ]
2584
- ):
2585
- return "high"
2586
-
2587
- if any(
2588
- word in text_lower
2589
- for word in [
2590
- "warning",
2591
- "issue",
2592
- "problem",
2593
- "bug",
2594
- "error",
2595
- "deprecated",
2596
- "leak",
2597
- ]
2598
- ):
2599
- return "medium"
2600
-
2601
- if any(word in text_lower for word in ["low", "minor", "style", "format", "typo"]):
2602
- return "low"
2603
-
2604
- return "info"
2605
-
2606
- def _infer_category(self, text: str) -> str:
2607
- """Infer finding category from keywords.
2608
-
2609
- Args:
2610
- text: Message or title text
2611
-
2612
- Returns:
2613
- Category: security, performance, maintainability, style, or correctness
2614
-
2615
- """
2616
- text_lower = text.lower()
2617
-
2618
- if any(
2619
- word in text_lower
2620
- for word in [
2621
- "security",
2622
- "vulnerability",
2623
- "injection",
2624
- "xss",
2625
- "csrf",
2626
- "auth",
2627
- "encrypt",
2628
- "password",
2629
- "secret",
2630
- "unsafe",
2631
- ]
2632
- ):
2633
- return "security"
2634
-
2635
- if any(
2636
- word in text_lower
2637
- for word in [
2638
- "performance",
2639
- "slow",
2640
- "memory",
2641
- "leak",
2642
- "inefficient",
2643
- "optimization",
2644
- "cache",
2645
- ]
2646
- ):
2647
- return "performance"
2648
-
2649
- if any(
2650
- word in text_lower
2651
- for word in [
2652
- "complex",
2653
- "refactor",
2654
- "duplicate",
2655
- "maintainability",
2656
- "readability",
2657
- "documentation",
2658
- ]
2659
- ):
2660
- return "maintainability"
2661
-
2662
- if any(
2663
- word in text_lower for word in ["style", "format", "lint", "convention", "whitespace"]
2664
- ):
2665
- return "style"
2666
-
2667
- return "correctness"