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.
- attune/cli/__init__.py +3 -59
- attune/cli/commands/batch.py +4 -12
- attune/cli/commands/cache.py +8 -16
- attune/cli/commands/provider.py +17 -0
- attune/cli/commands/routing.py +3 -1
- attune/cli/commands/setup.py +122 -0
- attune/cli/commands/tier.py +1 -3
- attune/cli/commands/workflow.py +31 -0
- attune/cli/parsers/cache.py +1 -0
- attune/cli/parsers/help.py +1 -3
- attune/cli/parsers/provider.py +7 -0
- attune/cli/parsers/routing.py +1 -3
- attune/cli/parsers/setup.py +7 -0
- attune/cli/parsers/status.py +1 -3
- attune/cli/parsers/tier.py +1 -3
- attune/cli_minimal.py +9 -3
- attune/cli_router.py +9 -7
- attune/cli_unified.py +3 -0
- attune/dashboard/app.py +3 -1
- attune/dashboard/simple_server.py +3 -1
- attune/dashboard/standalone_server.py +7 -3
- attune/mcp/server.py +54 -102
- attune/memory/long_term.py +0 -2
- attune/memory/short_term/__init__.py +84 -0
- attune/memory/short_term/base.py +465 -0
- attune/memory/short_term/batch.py +219 -0
- attune/memory/short_term/caching.py +227 -0
- attune/memory/short_term/conflicts.py +265 -0
- attune/memory/short_term/cross_session.py +122 -0
- attune/memory/short_term/facade.py +653 -0
- attune/memory/short_term/pagination.py +207 -0
- attune/memory/short_term/patterns.py +271 -0
- attune/memory/short_term/pubsub.py +286 -0
- attune/memory/short_term/queues.py +244 -0
- attune/memory/short_term/security.py +300 -0
- attune/memory/short_term/sessions.py +250 -0
- attune/memory/short_term/streams.py +242 -0
- attune/memory/short_term/timelines.py +234 -0
- attune/memory/short_term/transactions.py +184 -0
- attune/memory/short_term/working.py +252 -0
- attune/meta_workflows/cli_commands/__init__.py +3 -0
- attune/meta_workflows/cli_commands/agent_commands.py +0 -4
- attune/meta_workflows/cli_commands/analytics_commands.py +0 -6
- attune/meta_workflows/cli_commands/config_commands.py +0 -5
- attune/meta_workflows/cli_commands/memory_commands.py +0 -5
- attune/meta_workflows/cli_commands/template_commands.py +0 -5
- attune/meta_workflows/cli_commands/workflow_commands.py +0 -6
- attune/meta_workflows/plan_generator.py +2 -4
- attune/models/adaptive_routing.py +4 -8
- attune/models/auth_cli.py +3 -9
- attune/models/auth_strategy.py +2 -4
- attune/models/telemetry/analytics.py +0 -2
- attune/models/telemetry/backend.py +0 -3
- attune/models/telemetry/storage.py +0 -2
- attune/monitoring/alerts.py +6 -10
- attune/orchestration/_strategies/__init__.py +156 -0
- attune/orchestration/_strategies/base.py +227 -0
- attune/orchestration/_strategies/conditional_strategies.py +365 -0
- attune/orchestration/_strategies/conditions.py +369 -0
- attune/orchestration/_strategies/core_strategies.py +479 -0
- attune/orchestration/_strategies/data_classes.py +64 -0
- attune/orchestration/_strategies/nesting.py +233 -0
- attune/orchestration/execution_strategies.py +58 -1567
- attune/orchestration/meta_orchestrator.py +1 -3
- attune/project_index/scanner.py +1 -3
- attune/project_index/scanner_parallel.py +7 -5
- attune/socratic/storage.py +2 -4
- attune/socratic_router.py +1 -3
- attune/telemetry/agent_coordination.py +9 -3
- attune/telemetry/agent_tracking.py +16 -3
- attune/telemetry/approval_gates.py +22 -5
- attune/telemetry/cli.py +1 -3
- attune/telemetry/commands/dashboard_commands.py +24 -8
- attune/telemetry/event_streaming.py +8 -2
- attune/telemetry/feedback_loop.py +10 -2
- attune/tools.py +2 -1
- attune/workflow_commands.py +1 -3
- attune/workflow_patterns/structural.py +4 -8
- attune/workflows/__init__.py +54 -10
- attune/workflows/autonomous_test_gen.py +158 -102
- attune/workflows/base.py +48 -672
- attune/workflows/batch_processing.py +1 -3
- attune/workflows/compat.py +156 -0
- attune/workflows/cost_mixin.py +141 -0
- attune/workflows/data_classes.py +92 -0
- attune/workflows/document_gen/workflow.py +11 -14
- attune/workflows/history.py +16 -9
- attune/workflows/llm_base.py +1 -3
- attune/workflows/migration.py +432 -0
- attune/workflows/output.py +2 -7
- attune/workflows/parsing_mixin.py +427 -0
- attune/workflows/perf_audit.py +3 -1
- attune/workflows/progress.py +9 -11
- attune/workflows/release_prep.py +5 -1
- attune/workflows/routing.py +0 -2
- attune/workflows/secure_release.py +4 -1
- attune/workflows/security_audit.py +20 -14
- attune/workflows/security_audit_phase3.py +28 -22
- attune/workflows/seo_optimization.py +27 -27
- attune/workflows/test_gen/test_templates.py +1 -4
- attune/workflows/test_gen/workflow.py +0 -2
- attune/workflows/test_gen_behavioral.py +6 -19
- attune/workflows/test_gen_parallel.py +8 -6
- {attune_ai-2.1.5.dist-info → attune_ai-2.2.1.dist-info}/METADATA +4 -3
- {attune_ai-2.1.5.dist-info → attune_ai-2.2.1.dist-info}/RECORD +121 -96
- {attune_ai-2.1.5.dist-info → attune_ai-2.2.1.dist-info}/entry_points.txt +0 -2
- attune_healthcare/monitors/monitoring/__init__.py +9 -9
- attune_llm/agent_factory/__init__.py +6 -6
- attune_llm/agent_factory/adapters/haystack_adapter.py +1 -4
- attune_llm/commands/__init__.py +10 -10
- attune_llm/commands/models.py +3 -3
- attune_llm/config/__init__.py +8 -8
- attune_llm/learning/__init__.py +3 -3
- attune_llm/learning/extractor.py +5 -3
- attune_llm/learning/storage.py +5 -3
- attune_llm/security/__init__.py +17 -17
- attune_llm/utils/tokens.py +3 -1
- attune/cli_legacy.py +0 -3978
- attune/memory/short_term.py +0 -2192
- attune/workflows/manage_docs.py +0 -87
- attune/workflows/test5.py +0 -125
- {attune_ai-2.1.5.dist-info → attune_ai-2.2.1.dist-info}/WHEEL +0 -0
- {attune_ai-2.1.5.dist-info → attune_ai-2.2.1.dist-info}/licenses/LICENSE +0 -0
- {attune_ai-2.1.5.dist-info → attune_ai-2.2.1.dist-info}/licenses/LICENSE_CHANGE_ANNOUNCEMENT.md +0 -0
- {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
|
|
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
|
-
#
|
|
89
|
-
#
|
|
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
|
|
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
|
-
|
|
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"
|