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.
- attune/cli/__init__.py +3 -55
- attune/cli/commands/batch.py +4 -12
- attune/cli/commands/cache.py +7 -15
- 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 +34 -28
- attune/cli_router.py +9 -7
- attune/cli_unified.py +3 -0
- attune/core.py +190 -0
- attune/dashboard/app.py +4 -2
- 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 +467 -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 +655 -0
- attune/memory/short_term/pagination.py +215 -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 +249 -0
- attune/memory/short_term/timelines.py +234 -0
- attune/memory/short_term/transactions.py +186 -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/workflow.py +1 -1
- attune/models/adaptive_routing.py +4 -8
- attune/models/auth_cli.py +3 -9
- attune/models/auth_strategy.py +2 -4
- attune/models/provider_config.py +20 -1
- attune/models/telemetry/analytics.py +0 -2
- attune/models/telemetry/backend.py +0 -3
- attune/models/telemetry/storage.py +0 -2
- attune/orchestration/_strategies/__init__.py +156 -0
- attune/orchestration/_strategies/base.py +231 -0
- attune/orchestration/_strategies/conditional_strategies.py +373 -0
- attune/orchestration/_strategies/conditions.py +369 -0
- attune/orchestration/_strategies/core_strategies.py +491 -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_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 +3 -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 +1 -0
- attune/workflow_commands.py +1 -3
- attune/workflows/__init__.py +53 -10
- attune/workflows/autonomous_test_gen.py +160 -104
- attune/workflows/base.py +48 -664
- attune/workflows/batch_processing.py +2 -4
- 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 +62 -37
- attune/workflows/llm_base.py +2 -4
- attune/workflows/migration.py +422 -0
- attune/workflows/output.py +3 -9
- attune/workflows/parsing_mixin.py +427 -0
- attune/workflows/perf_audit.py +3 -1
- attune/workflows/progress.py +10 -13
- attune/workflows/release_prep.py +5 -1
- attune/workflows/routing.py +0 -2
- attune/workflows/secure_release.py +2 -1
- attune/workflows/security_audit.py +19 -14
- attune/workflows/security_audit_phase3.py +28 -22
- attune/workflows/seo_optimization.py +29 -29
- attune/workflows/test_gen/test_templates.py +1 -4
- attune/workflows/test_gen/workflow.py +0 -2
- attune/workflows/test_gen_behavioral.py +7 -20
- attune/workflows/test_gen_parallel.py +6 -4
- {attune_ai-2.1.4.dist-info → attune_ai-2.2.0.dist-info}/METADATA +4 -3
- {attune_ai-2.1.4.dist-info → attune_ai-2.2.0.dist-info}/RECORD +119 -94
- {attune_ai-2.1.4.dist-info → attune_ai-2.2.0.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/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 -3957
- attune/memory/short_term.py +0 -2192
- attune/workflows/manage_docs.py +0 -87
- attune/workflows/test5.py +0 -125
- {attune_ai-2.1.4.dist-info → attune_ai-2.2.0.dist-info}/WHEEL +0 -0
- {attune_ai-2.1.4.dist-info → attune_ai-2.2.0.dist-info}/licenses/LICENSE +0 -0
- {attune_ai-2.1.4.dist-info → attune_ai-2.2.0.dist-info}/licenses/LICENSE_CHANGE_ANNOUNCEMENT.md +0 -0
- {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
|
|
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
|
-
#
|
|
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)
|
|
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
|
|
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
|
-
|
|
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"
|