empathy-framework 3.7.0__py3-none-any.whl → 3.7.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (267) hide show
  1. coach_wizards/code_reviewer_README.md +60 -0
  2. coach_wizards/code_reviewer_wizard.py +180 -0
  3. {empathy_framework-3.7.0.dist-info → empathy_framework-3.7.1.dist-info}/METADATA +20 -2
  4. empathy_framework-3.7.1.dist-info/RECORD +327 -0
  5. {empathy_framework-3.7.0.dist-info → empathy_framework-3.7.1.dist-info}/top_level.txt +5 -1
  6. empathy_healthcare_plugin/monitors/__init__.py +9 -0
  7. empathy_healthcare_plugin/monitors/clinical_protocol_monitor.py +315 -0
  8. empathy_healthcare_plugin/monitors/monitoring/__init__.py +44 -0
  9. empathy_healthcare_plugin/monitors/monitoring/protocol_checker.py +300 -0
  10. empathy_healthcare_plugin/monitors/monitoring/protocol_loader.py +214 -0
  11. empathy_healthcare_plugin/monitors/monitoring/sensor_parsers.py +306 -0
  12. empathy_healthcare_plugin/monitors/monitoring/trajectory_analyzer.py +389 -0
  13. empathy_llm_toolkit/agent_factory/__init__.py +53 -0
  14. empathy_llm_toolkit/agent_factory/adapters/__init__.py +85 -0
  15. empathy_llm_toolkit/agent_factory/adapters/autogen_adapter.py +312 -0
  16. empathy_llm_toolkit/agent_factory/adapters/crewai_adapter.py +454 -0
  17. empathy_llm_toolkit/agent_factory/adapters/haystack_adapter.py +298 -0
  18. empathy_llm_toolkit/agent_factory/adapters/langchain_adapter.py +362 -0
  19. empathy_llm_toolkit/agent_factory/adapters/langgraph_adapter.py +333 -0
  20. empathy_llm_toolkit/agent_factory/adapters/native.py +228 -0
  21. empathy_llm_toolkit/agent_factory/adapters/wizard_adapter.py +426 -0
  22. empathy_llm_toolkit/agent_factory/base.py +305 -0
  23. empathy_llm_toolkit/agent_factory/crews/__init__.py +67 -0
  24. empathy_llm_toolkit/agent_factory/crews/code_review.py +1113 -0
  25. empathy_llm_toolkit/agent_factory/crews/health_check.py +1246 -0
  26. empathy_llm_toolkit/agent_factory/crews/refactoring.py +1128 -0
  27. empathy_llm_toolkit/agent_factory/crews/security_audit.py +1018 -0
  28. empathy_llm_toolkit/agent_factory/decorators.py +286 -0
  29. empathy_llm_toolkit/agent_factory/factory.py +558 -0
  30. empathy_llm_toolkit/agent_factory/framework.py +192 -0
  31. empathy_llm_toolkit/agent_factory/memory_integration.py +324 -0
  32. empathy_llm_toolkit/agent_factory/resilient.py +320 -0
  33. empathy_llm_toolkit/cli/__init__.py +8 -0
  34. empathy_llm_toolkit/cli/sync_claude.py +487 -0
  35. empathy_llm_toolkit/code_health.py +150 -3
  36. empathy_llm_toolkit/config/__init__.py +29 -0
  37. empathy_llm_toolkit/config/unified.py +295 -0
  38. empathy_llm_toolkit/routing/__init__.py +32 -0
  39. empathy_llm_toolkit/routing/model_router.py +362 -0
  40. empathy_llm_toolkit/security/IMPLEMENTATION_SUMMARY.md +413 -0
  41. empathy_llm_toolkit/security/PHASE2_COMPLETE.md +384 -0
  42. empathy_llm_toolkit/security/PHASE2_SECRETS_DETECTOR_COMPLETE.md +271 -0
  43. empathy_llm_toolkit/security/QUICK_REFERENCE.md +316 -0
  44. empathy_llm_toolkit/security/README.md +262 -0
  45. empathy_llm_toolkit/security/__init__.py +62 -0
  46. empathy_llm_toolkit/security/audit_logger.py +929 -0
  47. empathy_llm_toolkit/security/audit_logger_example.py +152 -0
  48. empathy_llm_toolkit/security/pii_scrubber.py +640 -0
  49. empathy_llm_toolkit/security/secrets_detector.py +678 -0
  50. empathy_llm_toolkit/security/secrets_detector_example.py +304 -0
  51. empathy_llm_toolkit/security/secure_memdocs.py +1192 -0
  52. empathy_llm_toolkit/security/secure_memdocs_example.py +278 -0
  53. empathy_llm_toolkit/wizards/__init__.py +38 -0
  54. empathy_llm_toolkit/wizards/base_wizard.py +364 -0
  55. empathy_llm_toolkit/wizards/customer_support_wizard.py +190 -0
  56. empathy_llm_toolkit/wizards/healthcare_wizard.py +362 -0
  57. empathy_llm_toolkit/wizards/patient_assessment_README.md +64 -0
  58. empathy_llm_toolkit/wizards/patient_assessment_wizard.py +193 -0
  59. empathy_llm_toolkit/wizards/technology_wizard.py +194 -0
  60. empathy_os/__init__.py +52 -52
  61. empathy_os/adaptive/__init__.py +13 -0
  62. empathy_os/adaptive/task_complexity.py +127 -0
  63. empathy_os/cli.py +118 -8
  64. empathy_os/cli_unified.py +121 -1
  65. empathy_os/config/__init__.py +63 -0
  66. empathy_os/config/xml_config.py +239 -0
  67. empathy_os/dashboard/__init__.py +15 -0
  68. empathy_os/dashboard/server.py +743 -0
  69. empathy_os/memory/__init__.py +195 -0
  70. empathy_os/memory/claude_memory.py +466 -0
  71. empathy_os/memory/config.py +224 -0
  72. empathy_os/memory/control_panel.py +1298 -0
  73. empathy_os/memory/edges.py +179 -0
  74. empathy_os/memory/graph.py +567 -0
  75. empathy_os/memory/long_term.py +1193 -0
  76. empathy_os/memory/nodes.py +179 -0
  77. empathy_os/memory/redis_bootstrap.py +540 -0
  78. empathy_os/memory/security/__init__.py +31 -0
  79. empathy_os/memory/security/audit_logger.py +930 -0
  80. empathy_os/memory/security/pii_scrubber.py +640 -0
  81. empathy_os/memory/security/secrets_detector.py +678 -0
  82. empathy_os/memory/short_term.py +2119 -0
  83. empathy_os/memory/storage/__init__.py +15 -0
  84. empathy_os/memory/summary_index.py +583 -0
  85. empathy_os/memory/unified.py +619 -0
  86. empathy_os/metrics/__init__.py +12 -0
  87. empathy_os/metrics/prompt_metrics.py +190 -0
  88. empathy_os/models/__init__.py +136 -0
  89. empathy_os/models/__main__.py +13 -0
  90. empathy_os/models/cli.py +655 -0
  91. empathy_os/models/empathy_executor.py +354 -0
  92. empathy_os/models/executor.py +252 -0
  93. empathy_os/models/fallback.py +671 -0
  94. empathy_os/models/provider_config.py +563 -0
  95. empathy_os/models/registry.py +382 -0
  96. empathy_os/models/tasks.py +302 -0
  97. empathy_os/models/telemetry.py +548 -0
  98. empathy_os/models/token_estimator.py +378 -0
  99. empathy_os/models/validation.py +274 -0
  100. empathy_os/monitoring/__init__.py +52 -0
  101. empathy_os/monitoring/alerts.py +23 -0
  102. empathy_os/monitoring/alerts_cli.py +268 -0
  103. empathy_os/monitoring/multi_backend.py +271 -0
  104. empathy_os/monitoring/otel_backend.py +363 -0
  105. empathy_os/optimization/__init__.py +19 -0
  106. empathy_os/optimization/context_optimizer.py +272 -0
  107. empathy_os/plugins/__init__.py +28 -0
  108. empathy_os/plugins/base.py +361 -0
  109. empathy_os/plugins/registry.py +268 -0
  110. empathy_os/project_index/__init__.py +30 -0
  111. empathy_os/project_index/cli.py +335 -0
  112. empathy_os/project_index/crew_integration.py +430 -0
  113. empathy_os/project_index/index.py +425 -0
  114. empathy_os/project_index/models.py +501 -0
  115. empathy_os/project_index/reports.py +473 -0
  116. empathy_os/project_index/scanner.py +538 -0
  117. empathy_os/prompts/__init__.py +61 -0
  118. empathy_os/prompts/config.py +77 -0
  119. empathy_os/prompts/context.py +177 -0
  120. empathy_os/prompts/parser.py +285 -0
  121. empathy_os/prompts/registry.py +313 -0
  122. empathy_os/prompts/templates.py +208 -0
  123. empathy_os/resilience/__init__.py +56 -0
  124. empathy_os/resilience/circuit_breaker.py +256 -0
  125. empathy_os/resilience/fallback.py +179 -0
  126. empathy_os/resilience/health.py +300 -0
  127. empathy_os/resilience/retry.py +209 -0
  128. empathy_os/resilience/timeout.py +135 -0
  129. empathy_os/routing/__init__.py +43 -0
  130. empathy_os/routing/chain_executor.py +433 -0
  131. empathy_os/routing/classifier.py +217 -0
  132. empathy_os/routing/smart_router.py +234 -0
  133. empathy_os/routing/wizard_registry.py +307 -0
  134. empathy_os/trust/__init__.py +28 -0
  135. empathy_os/trust/circuit_breaker.py +579 -0
  136. empathy_os/validation/__init__.py +19 -0
  137. empathy_os/validation/xml_validator.py +281 -0
  138. empathy_os/wizard_factory_cli.py +170 -0
  139. empathy_os/workflows/__init__.py +360 -0
  140. empathy_os/workflows/base.py +1530 -0
  141. empathy_os/workflows/bug_predict.py +962 -0
  142. empathy_os/workflows/code_review.py +960 -0
  143. empathy_os/workflows/code_review_adapters.py +310 -0
  144. empathy_os/workflows/code_review_pipeline.py +720 -0
  145. empathy_os/workflows/config.py +600 -0
  146. empathy_os/workflows/dependency_check.py +648 -0
  147. empathy_os/workflows/document_gen.py +1069 -0
  148. empathy_os/workflows/documentation_orchestrator.py +1205 -0
  149. empathy_os/workflows/health_check.py +679 -0
  150. empathy_os/workflows/keyboard_shortcuts/__init__.py +39 -0
  151. empathy_os/workflows/keyboard_shortcuts/generators.py +386 -0
  152. empathy_os/workflows/keyboard_shortcuts/parsers.py +414 -0
  153. empathy_os/workflows/keyboard_shortcuts/prompts.py +295 -0
  154. empathy_os/workflows/keyboard_shortcuts/schema.py +193 -0
  155. empathy_os/workflows/keyboard_shortcuts/workflow.py +505 -0
  156. empathy_os/workflows/manage_documentation.py +804 -0
  157. empathy_os/workflows/new_sample_workflow1.py +146 -0
  158. empathy_os/workflows/new_sample_workflow1_README.md +150 -0
  159. empathy_os/workflows/perf_audit.py +687 -0
  160. empathy_os/workflows/pr_review.py +748 -0
  161. empathy_os/workflows/progress.py +445 -0
  162. empathy_os/workflows/progress_server.py +322 -0
  163. empathy_os/workflows/refactor_plan.py +691 -0
  164. empathy_os/workflows/release_prep.py +808 -0
  165. empathy_os/workflows/research_synthesis.py +404 -0
  166. empathy_os/workflows/secure_release.py +585 -0
  167. empathy_os/workflows/security_adapters.py +297 -0
  168. empathy_os/workflows/security_audit.py +1050 -0
  169. empathy_os/workflows/step_config.py +234 -0
  170. empathy_os/workflows/test5.py +125 -0
  171. empathy_os/workflows/test5_README.md +158 -0
  172. empathy_os/workflows/test_gen.py +1855 -0
  173. empathy_os/workflows/test_lifecycle.py +526 -0
  174. empathy_os/workflows/test_maintenance.py +626 -0
  175. empathy_os/workflows/test_maintenance_cli.py +590 -0
  176. empathy_os/workflows/test_maintenance_crew.py +821 -0
  177. empathy_os/workflows/xml_enhanced_crew.py +285 -0
  178. empathy_software_plugin/cli/__init__.py +120 -0
  179. empathy_software_plugin/cli/inspect.py +362 -0
  180. empathy_software_plugin/cli.py +3 -1
  181. empathy_software_plugin/wizards/__init__.py +42 -0
  182. empathy_software_plugin/wizards/advanced_debugging_wizard.py +392 -0
  183. empathy_software_plugin/wizards/agent_orchestration_wizard.py +511 -0
  184. empathy_software_plugin/wizards/ai_collaboration_wizard.py +503 -0
  185. empathy_software_plugin/wizards/ai_context_wizard.py +441 -0
  186. empathy_software_plugin/wizards/ai_documentation_wizard.py +503 -0
  187. empathy_software_plugin/wizards/base_wizard.py +288 -0
  188. empathy_software_plugin/wizards/book_chapter_wizard.py +519 -0
  189. empathy_software_plugin/wizards/code_review_wizard.py +606 -0
  190. empathy_software_plugin/wizards/debugging/__init__.py +50 -0
  191. empathy_software_plugin/wizards/debugging/bug_risk_analyzer.py +414 -0
  192. empathy_software_plugin/wizards/debugging/config_loaders.py +442 -0
  193. empathy_software_plugin/wizards/debugging/fix_applier.py +469 -0
  194. empathy_software_plugin/wizards/debugging/language_patterns.py +383 -0
  195. empathy_software_plugin/wizards/debugging/linter_parsers.py +470 -0
  196. empathy_software_plugin/wizards/debugging/verification.py +369 -0
  197. empathy_software_plugin/wizards/enhanced_testing_wizard.py +537 -0
  198. empathy_software_plugin/wizards/memory_enhanced_debugging_wizard.py +816 -0
  199. empathy_software_plugin/wizards/multi_model_wizard.py +501 -0
  200. empathy_software_plugin/wizards/pattern_extraction_wizard.py +422 -0
  201. empathy_software_plugin/wizards/pattern_retriever_wizard.py +400 -0
  202. empathy_software_plugin/wizards/performance/__init__.py +9 -0
  203. empathy_software_plugin/wizards/performance/bottleneck_detector.py +221 -0
  204. empathy_software_plugin/wizards/performance/profiler_parsers.py +278 -0
  205. empathy_software_plugin/wizards/performance/trajectory_analyzer.py +429 -0
  206. empathy_software_plugin/wizards/performance_profiling_wizard.py +305 -0
  207. empathy_software_plugin/wizards/prompt_engineering_wizard.py +425 -0
  208. empathy_software_plugin/wizards/rag_pattern_wizard.py +461 -0
  209. empathy_software_plugin/wizards/security/__init__.py +32 -0
  210. empathy_software_plugin/wizards/security/exploit_analyzer.py +290 -0
  211. empathy_software_plugin/wizards/security/owasp_patterns.py +241 -0
  212. empathy_software_plugin/wizards/security/vulnerability_scanner.py +604 -0
  213. empathy_software_plugin/wizards/security_analysis_wizard.py +322 -0
  214. empathy_software_plugin/wizards/security_learning_wizard.py +740 -0
  215. empathy_software_plugin/wizards/tech_debt_wizard.py +726 -0
  216. empathy_software_plugin/wizards/testing/__init__.py +27 -0
  217. empathy_software_plugin/wizards/testing/coverage_analyzer.py +459 -0
  218. empathy_software_plugin/wizards/testing/quality_analyzer.py +531 -0
  219. empathy_software_plugin/wizards/testing/test_suggester.py +533 -0
  220. empathy_software_plugin/wizards/testing_wizard.py +274 -0
  221. hot_reload/README.md +473 -0
  222. hot_reload/__init__.py +62 -0
  223. hot_reload/config.py +84 -0
  224. hot_reload/integration.py +228 -0
  225. hot_reload/reloader.py +298 -0
  226. hot_reload/watcher.py +179 -0
  227. hot_reload/websocket.py +176 -0
  228. scaffolding/README.md +589 -0
  229. scaffolding/__init__.py +35 -0
  230. scaffolding/__main__.py +14 -0
  231. scaffolding/cli.py +240 -0
  232. test_generator/__init__.py +38 -0
  233. test_generator/__main__.py +14 -0
  234. test_generator/cli.py +226 -0
  235. test_generator/generator.py +325 -0
  236. test_generator/risk_analyzer.py +216 -0
  237. workflow_patterns/__init__.py +33 -0
  238. workflow_patterns/behavior.py +249 -0
  239. workflow_patterns/core.py +76 -0
  240. workflow_patterns/output.py +99 -0
  241. workflow_patterns/registry.py +255 -0
  242. workflow_patterns/structural.py +288 -0
  243. workflow_scaffolding/__init__.py +11 -0
  244. workflow_scaffolding/__main__.py +12 -0
  245. workflow_scaffolding/cli.py +206 -0
  246. workflow_scaffolding/generator.py +265 -0
  247. agents/code_inspection/patterns/inspection/recurring_B112.json +0 -18
  248. agents/code_inspection/patterns/inspection/recurring_F541.json +0 -16
  249. agents/code_inspection/patterns/inspection/recurring_FORMAT.json +0 -25
  250. agents/code_inspection/patterns/inspection/recurring_bug_20250822_def456.json +0 -16
  251. agents/code_inspection/patterns/inspection/recurring_bug_20250915_abc123.json +0 -16
  252. agents/code_inspection/patterns/inspection/recurring_bug_20251212_3c5b9951.json +0 -16
  253. agents/code_inspection/patterns/inspection/recurring_bug_20251212_97c0f72f.json +0 -16
  254. agents/code_inspection/patterns/inspection/recurring_bug_20251212_a0871d53.json +0 -16
  255. agents/code_inspection/patterns/inspection/recurring_bug_20251212_a9b6ec41.json +0 -16
  256. agents/code_inspection/patterns/inspection/recurring_bug_null_001.json +0 -16
  257. agents/code_inspection/patterns/inspection/recurring_builtin.json +0 -16
  258. agents/compliance_anticipation_agent.py +0 -1422
  259. agents/compliance_db.py +0 -339
  260. agents/epic_integration_wizard.py +0 -530
  261. agents/notifications.py +0 -291
  262. agents/trust_building_behaviors.py +0 -872
  263. empathy_framework-3.7.0.dist-info/RECORD +0 -105
  264. {empathy_framework-3.7.0.dist-info → empathy_framework-3.7.1.dist-info}/WHEEL +0 -0
  265. {empathy_framework-3.7.0.dist-info → empathy_framework-3.7.1.dist-info}/entry_points.txt +0 -0
  266. {empathy_framework-3.7.0.dist-info → empathy_framework-3.7.1.dist-info}/licenses/LICENSE +0 -0
  267. /empathy_os/{monitoring.py → agent_monitoring.py} +0 -0
@@ -0,0 +1,1530 @@
1
+ """Base Workflow Class for Multi-Model Pipelines
2
+
3
+ Provides a framework for creating cost-optimized workflows that
4
+ route tasks to the appropriate model tier.
5
+
6
+ Integration with empathy_os.models:
7
+ - Uses unified ModelTier/ModelProvider from empathy_os.models
8
+ - Supports LLMExecutor for abstracted LLM calls
9
+ - Supports TelemetryBackend for telemetry storage
10
+ - WorkflowStepConfig for declarative step definitions
11
+
12
+ Copyright 2025 Smart-AI-Memory
13
+ Licensed under Fair Source License 0.9
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import logging
20
+ import uuid
21
+ from abc import ABC, abstractmethod
22
+ from dataclasses import dataclass, field
23
+ from datetime import datetime
24
+ from enum import Enum
25
+ from pathlib import Path
26
+ from typing import TYPE_CHECKING, Any
27
+
28
+ # Load .env file for API keys if python-dotenv is available
29
+ try:
30
+ from dotenv import load_dotenv
31
+
32
+ load_dotenv()
33
+ except ImportError:
34
+ pass # python-dotenv not installed, rely on environment variables
35
+
36
+ from empathy_os.cost_tracker import MODEL_PRICING, CostTracker
37
+
38
+ # Import unified types from empathy_os.models
39
+ from empathy_os.models import (
40
+ ExecutionContext,
41
+ LLMCallRecord,
42
+ LLMExecutor,
43
+ TelemetryBackend,
44
+ WorkflowRunRecord,
45
+ WorkflowStageRecord,
46
+ get_telemetry_store,
47
+ )
48
+ from empathy_os.models import ModelProvider as UnifiedModelProvider
49
+ from empathy_os.models import ModelTier as UnifiedModelTier
50
+
51
+ # Import progress tracking
52
+ from .progress import ProgressCallback, ProgressTracker
53
+
54
+ if TYPE_CHECKING:
55
+ from .config import WorkflowConfig
56
+ from .step_config import WorkflowStepConfig
57
+
58
+ logger = logging.getLogger(__name__)
59
+
60
+ # Default path for workflow run history
61
+ WORKFLOW_HISTORY_FILE = ".empathy/workflow_runs.json"
62
+
63
+
64
+ # Local enums for backward compatibility
65
+ # New code should use empathy_os.models.ModelTier/ModelProvider
66
+ class ModelTier(Enum):
67
+ """Model tier for cost optimization."""
68
+
69
+ CHEAP = "cheap" # Haiku/GPT-4o-mini - $0.25-1.25/M tokens
70
+ CAPABLE = "capable" # Sonnet/GPT-4o - $3-15/M tokens
71
+ PREMIUM = "premium" # Opus/o1 - $15-75/M tokens
72
+
73
+ def to_unified(self) -> UnifiedModelTier:
74
+ """Convert to unified ModelTier from empathy_os.models."""
75
+ return UnifiedModelTier(self.value)
76
+
77
+
78
+ class ModelProvider(Enum):
79
+ """Supported model providers."""
80
+
81
+ ANTHROPIC = "anthropic"
82
+ OPENAI = "openai"
83
+ GOOGLE = "google" # Google Gemini models
84
+ OLLAMA = "ollama"
85
+ HYBRID = "hybrid" # Mix of best models from different providers
86
+ CUSTOM = "custom" # User-defined custom models
87
+
88
+ def to_unified(self) -> UnifiedModelProvider:
89
+ """Convert to unified ModelProvider from empathy_os.models."""
90
+ return UnifiedModelProvider(self.value)
91
+
92
+
93
+ # Import unified MODEL_REGISTRY as single source of truth
94
+ # This import is placed here intentionally to avoid circular imports
95
+ from empathy_os.models import MODEL_REGISTRY # noqa: E402
96
+
97
+
98
+ def _build_provider_models() -> dict[ModelProvider, dict[ModelTier, str]]:
99
+ """Build PROVIDER_MODELS from MODEL_REGISTRY.
100
+
101
+ This ensures PROVIDER_MODELS stays in sync with the single source of truth.
102
+ """
103
+ result: dict[ModelProvider, dict[ModelTier, str]] = {}
104
+
105
+ # Map string provider names to ModelProvider enum
106
+ provider_map = {
107
+ "anthropic": ModelProvider.ANTHROPIC,
108
+ "openai": ModelProvider.OPENAI,
109
+ "google": ModelProvider.GOOGLE,
110
+ "ollama": ModelProvider.OLLAMA,
111
+ "hybrid": ModelProvider.HYBRID,
112
+ }
113
+
114
+ # Map string tier names to ModelTier enum
115
+ tier_map = {
116
+ "cheap": ModelTier.CHEAP,
117
+ "capable": ModelTier.CAPABLE,
118
+ "premium": ModelTier.PREMIUM,
119
+ }
120
+
121
+ for provider_str, tiers in MODEL_REGISTRY.items():
122
+ if provider_str not in provider_map:
123
+ continue # Skip custom providers
124
+ provider_enum = provider_map[provider_str]
125
+ result[provider_enum] = {}
126
+ for tier_str, model_info in tiers.items():
127
+ if tier_str in tier_map:
128
+ result[provider_enum][tier_map[tier_str]] = model_info.id
129
+
130
+ return result
131
+
132
+
133
+ # Model mappings by provider and tier (derived from MODEL_REGISTRY)
134
+ PROVIDER_MODELS: dict[ModelProvider, dict[ModelTier, str]] = _build_provider_models()
135
+
136
+
137
+ @dataclass
138
+ class WorkflowStage:
139
+ """Represents a single stage in a workflow."""
140
+
141
+ name: str
142
+ tier: ModelTier
143
+ description: str
144
+ input_tokens: int = 0
145
+ output_tokens: int = 0
146
+ cost: float = 0.0
147
+ result: Any = None
148
+ duration_ms: int = 0
149
+ skipped: bool = False
150
+ skip_reason: str | None = None
151
+
152
+
153
+ @dataclass
154
+ class CostReport:
155
+ """Cost breakdown for a workflow execution."""
156
+
157
+ total_cost: float
158
+ baseline_cost: float # If all stages used premium
159
+ savings: float
160
+ savings_percent: float
161
+ by_stage: dict[str, float] = field(default_factory=dict)
162
+ by_tier: dict[str, float] = field(default_factory=dict)
163
+
164
+
165
+ @dataclass
166
+ class WorkflowResult:
167
+ """Result of a workflow execution."""
168
+
169
+ success: bool
170
+ stages: list[WorkflowStage]
171
+ final_output: Any
172
+ cost_report: CostReport
173
+ started_at: datetime
174
+ completed_at: datetime
175
+ total_duration_ms: int
176
+ provider: str = "unknown"
177
+ error: str | None = None
178
+ # Structured error taxonomy for reliability
179
+ error_type: str | None = None # "config" | "runtime" | "provider" | "timeout" | "validation"
180
+ transient: bool = False # True if retry is reasonable (e.g., provider timeout)
181
+
182
+
183
+ def _load_workflow_history(history_file: str = WORKFLOW_HISTORY_FILE) -> list[dict]:
184
+ """Load workflow run history from disk."""
185
+ path = Path(history_file)
186
+ if not path.exists():
187
+ return []
188
+ try:
189
+ with open(path) as f:
190
+ data = json.load(f)
191
+ return list(data) if isinstance(data, list) else []
192
+ except (json.JSONDecodeError, OSError):
193
+ return []
194
+
195
+
196
+ def _save_workflow_run(
197
+ workflow_name: str,
198
+ provider: str,
199
+ result: WorkflowResult,
200
+ history_file: str = WORKFLOW_HISTORY_FILE,
201
+ max_history: int = 100,
202
+ ) -> None:
203
+ """Save a workflow run to history."""
204
+ path = Path(history_file)
205
+ path.parent.mkdir(parents=True, exist_ok=True)
206
+
207
+ history = _load_workflow_history(history_file)
208
+
209
+ # Create run record
210
+ run: dict = {
211
+ "workflow": workflow_name,
212
+ "provider": provider,
213
+ "success": result.success,
214
+ "started_at": result.started_at.isoformat(),
215
+ "completed_at": result.completed_at.isoformat(),
216
+ "duration_ms": result.total_duration_ms,
217
+ "cost": result.cost_report.total_cost,
218
+ "baseline_cost": result.cost_report.baseline_cost,
219
+ "savings": result.cost_report.savings,
220
+ "savings_percent": result.cost_report.savings_percent,
221
+ "stages": [
222
+ {
223
+ "name": s.name,
224
+ "tier": s.tier.value,
225
+ "skipped": s.skipped,
226
+ "cost": s.cost,
227
+ "duration_ms": s.duration_ms,
228
+ }
229
+ for s in result.stages
230
+ ],
231
+ "error": result.error,
232
+ }
233
+
234
+ # Extract XML-parsed fields from final_output if present
235
+ if isinstance(result.final_output, dict):
236
+ if result.final_output.get("xml_parsed"):
237
+ run["xml_parsed"] = True
238
+ run["summary"] = result.final_output.get("summary")
239
+ run["findings"] = result.final_output.get("findings", [])
240
+ run["checklist"] = result.final_output.get("checklist", [])
241
+
242
+ # Add to history and trim
243
+ history.append(run)
244
+ history = history[-max_history:]
245
+
246
+ with open(path, "w") as f:
247
+ json.dump(history, f, indent=2)
248
+
249
+
250
+ def get_workflow_stats(history_file: str = WORKFLOW_HISTORY_FILE) -> dict:
251
+ """Get workflow statistics for dashboard.
252
+
253
+ Returns:
254
+ Dictionary with workflow stats including:
255
+ - total_runs: Total workflow runs
256
+ - by_workflow: Per-workflow stats
257
+ - by_provider: Per-provider stats
258
+ - recent_runs: Last 10 runs
259
+ - total_savings: Total cost savings
260
+
261
+ """
262
+ history = _load_workflow_history(history_file)
263
+
264
+ if not history:
265
+ return {
266
+ "total_runs": 0,
267
+ "by_workflow": {},
268
+ "by_provider": {},
269
+ "by_tier": {"cheap": 0, "capable": 0, "premium": 0},
270
+ "recent_runs": [],
271
+ "total_cost": 0.0,
272
+ "total_savings": 0.0,
273
+ "avg_savings_percent": 0.0,
274
+ }
275
+
276
+ # Aggregate stats
277
+ by_workflow: dict[str, dict] = {}
278
+ by_provider: dict[str, dict] = {}
279
+ by_tier: dict[str, float] = {"cheap": 0.0, "capable": 0.0, "premium": 0.0}
280
+ total_cost = 0.0
281
+ total_savings = 0.0
282
+ successful_runs = 0
283
+
284
+ for run in history:
285
+ wf_name = run.get("workflow", "unknown")
286
+ provider = run.get("provider", "unknown")
287
+ cost = run.get("cost", 0.0)
288
+ savings = run.get("savings", 0.0)
289
+
290
+ # By workflow
291
+ if wf_name not in by_workflow:
292
+ by_workflow[wf_name] = {"runs": 0, "cost": 0.0, "savings": 0.0, "success": 0}
293
+ by_workflow[wf_name]["runs"] += 1
294
+ by_workflow[wf_name]["cost"] += cost
295
+ by_workflow[wf_name]["savings"] += savings
296
+ if run.get("success"):
297
+ by_workflow[wf_name]["success"] += 1
298
+
299
+ # By provider
300
+ if provider not in by_provider:
301
+ by_provider[provider] = {"runs": 0, "cost": 0.0}
302
+ by_provider[provider]["runs"] += 1
303
+ by_provider[provider]["cost"] += cost
304
+
305
+ # By tier (from stages)
306
+ for stage in run.get("stages", []):
307
+ if not stage.get("skipped"):
308
+ tier = stage.get("tier", "capable")
309
+ by_tier[tier] = by_tier.get(tier, 0.0) + stage.get("cost", 0.0)
310
+
311
+ total_cost += cost
312
+ total_savings += savings
313
+ if run.get("success"):
314
+ successful_runs += 1
315
+
316
+ # Calculate average savings percent
317
+ avg_savings_percent = 0.0
318
+ if history:
319
+ savings_percents = [r.get("savings_percent", 0) for r in history if r.get("success")]
320
+ if savings_percents:
321
+ avg_savings_percent = sum(savings_percents) / len(savings_percents)
322
+
323
+ return {
324
+ "total_runs": len(history),
325
+ "successful_runs": successful_runs,
326
+ "by_workflow": by_workflow,
327
+ "by_provider": by_provider,
328
+ "by_tier": by_tier,
329
+ "recent_runs": history[-10:][::-1], # Last 10, most recent first
330
+ "total_cost": total_cost,
331
+ "total_savings": total_savings,
332
+ "avg_savings_percent": avg_savings_percent,
333
+ }
334
+
335
+
336
+ class BaseWorkflow(ABC):
337
+ """Base class for multi-model workflows.
338
+
339
+ Subclasses define stages and tier mappings:
340
+
341
+ class MyWorkflow(BaseWorkflow):
342
+ name = "my-workflow"
343
+ description = "Does something useful"
344
+ stages = ["stage1", "stage2", "stage3"]
345
+ tier_map = {
346
+ "stage1": ModelTier.CHEAP,
347
+ "stage2": ModelTier.CAPABLE,
348
+ "stage3": ModelTier.PREMIUM,
349
+ }
350
+
351
+ async def run_stage(self, stage_name, tier, input_data):
352
+ # Implement stage logic
353
+ return output_data
354
+ """
355
+
356
+ name: str = "base-workflow"
357
+ description: str = "Base workflow template"
358
+ stages: list[str] = []
359
+ tier_map: dict[str, ModelTier] = {}
360
+
361
+ def __init__(
362
+ self,
363
+ cost_tracker: CostTracker | None = None,
364
+ provider: ModelProvider | str | None = None,
365
+ config: WorkflowConfig | None = None,
366
+ executor: LLMExecutor | None = None,
367
+ telemetry_backend: TelemetryBackend | None = None,
368
+ progress_callback: ProgressCallback | None = None,
369
+ ):
370
+ """Initialize workflow with optional cost tracker, provider, and config.
371
+
372
+ Args:
373
+ cost_tracker: CostTracker instance for logging costs
374
+ provider: Model provider (anthropic, openai, ollama) or ModelProvider enum.
375
+ If None, uses config or defaults to anthropic.
376
+ config: WorkflowConfig for model customization. If None, loads from
377
+ .empathy/workflows.yaml or uses defaults.
378
+ executor: LLMExecutor for abstracted LLM calls (optional).
379
+ If provided, enables unified execution with telemetry.
380
+ telemetry_backend: TelemetryBackend for storing telemetry records.
381
+ Defaults to TelemetryStore (JSONL file backend).
382
+ progress_callback: Callback for real-time progress updates.
383
+ If provided, enables live progress tracking during execution.
384
+
385
+ """
386
+ from .config import WorkflowConfig
387
+
388
+ self.cost_tracker = cost_tracker or CostTracker()
389
+ self._stages_run: list[WorkflowStage] = []
390
+
391
+ # Progress tracking
392
+ self._progress_callback = progress_callback
393
+ self._progress_tracker: ProgressTracker | None = None
394
+
395
+ # New: LLMExecutor support
396
+ self._executor = executor
397
+ self._telemetry_backend = telemetry_backend or get_telemetry_store()
398
+ self._run_id: str | None = None # Set at start of execute()
399
+ self._api_key: str | None = None # For default executor creation
400
+
401
+ # Load config if not provided
402
+ self._config = config or WorkflowConfig.load()
403
+
404
+ # Determine provider (priority: arg > config > default)
405
+ if provider is None:
406
+ provider = self._config.get_provider_for_workflow(self.name)
407
+
408
+ # Handle string provider input
409
+ if isinstance(provider, str):
410
+ provider_str = provider.lower()
411
+ try:
412
+ provider = ModelProvider(provider_str)
413
+ self._provider_str = provider_str
414
+ except ValueError:
415
+ # Custom provider, keep as string
416
+ self._provider_str = provider_str
417
+ provider = ModelProvider.CUSTOM
418
+ else:
419
+ self._provider_str = provider.value
420
+
421
+ self.provider = provider
422
+
423
+ def get_tier_for_stage(self, stage_name: str) -> ModelTier:
424
+ """Get the model tier for a stage."""
425
+ return self.tier_map.get(stage_name, ModelTier.CAPABLE)
426
+
427
+ def get_model_for_tier(self, tier: ModelTier) -> str:
428
+ """Get the model for a tier based on configured provider and config."""
429
+ from .config import get_model
430
+
431
+ provider_str = getattr(self, "_provider_str", self.provider.value)
432
+
433
+ # Use config-aware model lookup
434
+ model = get_model(provider_str, tier.value, self._config)
435
+ return model
436
+
437
+ async def _call_llm(
438
+ self,
439
+ tier: ModelTier,
440
+ system: str,
441
+ user_message: str,
442
+ max_tokens: int = 4096,
443
+ ) -> tuple[str, int, int]:
444
+ """Provider-agnostic LLM call using the configured provider.
445
+
446
+ This method uses run_step_with_executor internally to make LLM calls
447
+ that respect the configured provider (anthropic, openai, google, etc.).
448
+
449
+ Args:
450
+ tier: Model tier to use (CHEAP, CAPABLE, PREMIUM)
451
+ system: System prompt
452
+ user_message: User message/prompt
453
+ max_tokens: Maximum tokens in response
454
+
455
+ Returns:
456
+ Tuple of (response_content, input_tokens, output_tokens)
457
+
458
+ """
459
+ from .step_config import WorkflowStepConfig
460
+
461
+ # Create a step config for this call
462
+ step = WorkflowStepConfig(
463
+ name=f"llm_call_{tier.value}",
464
+ task_type="general",
465
+ tier_hint=tier.value,
466
+ description="LLM call",
467
+ max_tokens=max_tokens,
468
+ )
469
+
470
+ try:
471
+ content, in_tokens, out_tokens, _cost = await self.run_step_with_executor(
472
+ step=step,
473
+ prompt=user_message,
474
+ system=system,
475
+ )
476
+ return content, in_tokens, out_tokens
477
+ except (ValueError, TypeError, KeyError) as e:
478
+ # Invalid input or configuration errors
479
+ return f"Error calling LLM (invalid input): {e}", 0, 0
480
+ except (TimeoutError, RuntimeError) as e:
481
+ # Timeout or API errors
482
+ return f"Error calling LLM (timeout/API error): {e}", 0, 0
483
+ except Exception:
484
+ # INTENTIONAL: Graceful degradation - return error message rather than crashing workflow
485
+ logger.exception("Unexpected error calling LLM")
486
+ return "Error calling LLM: unexpected error", 0, 0
487
+
488
+ def _calculate_cost(self, tier: ModelTier, input_tokens: int, output_tokens: int) -> float:
489
+ """Calculate cost for a stage."""
490
+ tier_name = tier.value
491
+ pricing = MODEL_PRICING.get(tier_name, MODEL_PRICING["capable"])
492
+ input_cost = (input_tokens / 1_000_000) * pricing["input"]
493
+ output_cost = (output_tokens / 1_000_000) * pricing["output"]
494
+ return input_cost + output_cost
495
+
496
+ def _calculate_baseline_cost(self, input_tokens: int, output_tokens: int) -> float:
497
+ """Calculate what the cost would be using premium tier."""
498
+ pricing = MODEL_PRICING["premium"]
499
+ input_cost = (input_tokens / 1_000_000) * pricing["input"]
500
+ output_cost = (output_tokens / 1_000_000) * pricing["output"]
501
+ return input_cost + output_cost
502
+
503
+ def _generate_cost_report(self) -> CostReport:
504
+ """Generate cost report from completed stages."""
505
+ total_cost = 0.0
506
+ baseline_cost = 0.0
507
+ by_stage: dict[str, float] = {}
508
+ by_tier: dict[str, float] = {}
509
+
510
+ for stage in self._stages_run:
511
+ if stage.skipped:
512
+ continue
513
+
514
+ total_cost += stage.cost
515
+ by_stage[stage.name] = stage.cost
516
+
517
+ tier_name = stage.tier.value
518
+ by_tier[tier_name] = by_tier.get(tier_name, 0.0) + stage.cost
519
+
520
+ # Calculate what this would cost at premium tier
521
+ baseline_cost += self._calculate_baseline_cost(stage.input_tokens, stage.output_tokens)
522
+
523
+ savings = baseline_cost - total_cost
524
+ savings_percent = (savings / baseline_cost * 100) if baseline_cost > 0 else 0.0
525
+
526
+ return CostReport(
527
+ total_cost=total_cost,
528
+ baseline_cost=baseline_cost,
529
+ savings=savings,
530
+ savings_percent=savings_percent,
531
+ by_stage=by_stage,
532
+ by_tier=by_tier,
533
+ )
534
+
535
+ @abstractmethod
536
+ async def run_stage(
537
+ self,
538
+ stage_name: str,
539
+ tier: ModelTier,
540
+ input_data: Any,
541
+ ) -> tuple[Any, int, int]:
542
+ """Execute a single workflow stage.
543
+
544
+ Args:
545
+ stage_name: Name of the stage to run
546
+ tier: Model tier to use
547
+ input_data: Input for this stage
548
+
549
+ Returns:
550
+ Tuple of (output_data, input_tokens, output_tokens)
551
+
552
+ """
553
+
554
+ def should_skip_stage(self, stage_name: str, input_data: Any) -> tuple[bool, str | None]:
555
+ """Determine if a stage should be skipped.
556
+
557
+ Override in subclasses for conditional stage execution.
558
+
559
+ Args:
560
+ stage_name: Name of the stage
561
+ input_data: Current workflow data
562
+
563
+ Returns:
564
+ Tuple of (should_skip, reason)
565
+
566
+ """
567
+ return False, None
568
+
569
+ async def execute(self, **kwargs: Any) -> WorkflowResult:
570
+ """Execute the full workflow.
571
+
572
+ Args:
573
+ **kwargs: Initial input data for the workflow
574
+
575
+ Returns:
576
+ WorkflowResult with stages, output, and cost report
577
+
578
+ """
579
+ # Set run ID for telemetry correlation
580
+ self._run_id = str(uuid.uuid4())
581
+
582
+ started_at = datetime.now()
583
+ self._stages_run = []
584
+ current_data = kwargs
585
+ error = None
586
+
587
+ # Initialize progress tracker if callback provided
588
+ if self._progress_callback:
589
+ self._progress_tracker = ProgressTracker(
590
+ workflow_name=self.name,
591
+ workflow_id=self._run_id,
592
+ stage_names=self.stages,
593
+ )
594
+ self._progress_tracker.add_callback(self._progress_callback)
595
+ self._progress_tracker.start_workflow()
596
+
597
+ try:
598
+ for stage_name in self.stages:
599
+ tier = self.get_tier_for_stage(stage_name)
600
+ stage_start = datetime.now()
601
+
602
+ # Check if stage should be skipped
603
+ should_skip, skip_reason = self.should_skip_stage(stage_name, current_data)
604
+
605
+ if should_skip:
606
+ stage = WorkflowStage(
607
+ name=stage_name,
608
+ tier=tier,
609
+ description=f"Stage: {stage_name}",
610
+ skipped=True,
611
+ skip_reason=skip_reason,
612
+ )
613
+ self._stages_run.append(stage)
614
+
615
+ # Report skip to progress tracker
616
+ if self._progress_tracker:
617
+ self._progress_tracker.skip_stage(stage_name, skip_reason or "")
618
+
619
+ continue
620
+
621
+ # Report stage start to progress tracker
622
+ model_id = self.get_model_for_tier(tier)
623
+ if self._progress_tracker:
624
+ self._progress_tracker.start_stage(stage_name, tier.value, model_id)
625
+
626
+ # Run the stage
627
+ output, input_tokens, output_tokens = await self.run_stage(
628
+ stage_name,
629
+ tier,
630
+ current_data,
631
+ )
632
+
633
+ stage_end = datetime.now()
634
+ duration_ms = int((stage_end - stage_start).total_seconds() * 1000)
635
+ cost = self._calculate_cost(tier, input_tokens, output_tokens)
636
+
637
+ stage = WorkflowStage(
638
+ name=stage_name,
639
+ tier=tier,
640
+ description=f"Stage: {stage_name}",
641
+ input_tokens=input_tokens,
642
+ output_tokens=output_tokens,
643
+ cost=cost,
644
+ result=output,
645
+ duration_ms=duration_ms,
646
+ )
647
+ self._stages_run.append(stage)
648
+
649
+ # Report stage completion to progress tracker
650
+ if self._progress_tracker:
651
+ self._progress_tracker.complete_stage(
652
+ stage_name,
653
+ cost=cost,
654
+ tokens_in=input_tokens,
655
+ tokens_out=output_tokens,
656
+ )
657
+
658
+ # Log to cost tracker
659
+ self.cost_tracker.log_request(
660
+ model=model_id,
661
+ input_tokens=input_tokens,
662
+ output_tokens=output_tokens,
663
+ task_type=f"workflow:{self.name}:{stage_name}",
664
+ )
665
+
666
+ # Pass output to next stage
667
+ current_data = output if isinstance(output, dict) else {"result": output}
668
+
669
+ except (ValueError, TypeError, KeyError) as e:
670
+ # Data validation or configuration errors
671
+ error = f"Workflow execution error (data/config): {e}"
672
+ logger.error(error)
673
+ if self._progress_tracker:
674
+ self._progress_tracker.fail_workflow(error)
675
+ except (TimeoutError, RuntimeError) as e:
676
+ # Timeout or API errors
677
+ error = f"Workflow execution error (timeout/API): {e}"
678
+ logger.error(error)
679
+ if self._progress_tracker:
680
+ self._progress_tracker.fail_workflow(error)
681
+ except Exception:
682
+ # INTENTIONAL: Workflow orchestration - catch all errors to report failure gracefully
683
+ logger.exception("Unexpected error in workflow execution")
684
+ error = "Workflow execution failed: unexpected error"
685
+ if self._progress_tracker:
686
+ self._progress_tracker.fail_workflow(error)
687
+
688
+ completed_at = datetime.now()
689
+ total_duration_ms = int((completed_at - started_at).total_seconds() * 1000)
690
+
691
+ # Get final output from last non-skipped stage
692
+ final_output = None
693
+ for stage in reversed(self._stages_run):
694
+ if not stage.skipped and stage.result is not None:
695
+ final_output = stage.result
696
+ break
697
+
698
+ # Classify error type and transient status
699
+ error_type = None
700
+ transient = False
701
+ if error:
702
+ error_lower = error.lower()
703
+ if "timeout" in error_lower or "timed out" in error_lower:
704
+ error_type = "timeout"
705
+ transient = True
706
+ elif "config" in error_lower or "configuration" in error_lower:
707
+ error_type = "config"
708
+ transient = False
709
+ elif "api" in error_lower or "rate limit" in error_lower or "quota" in error_lower:
710
+ error_type = "provider"
711
+ transient = True
712
+ elif "validation" in error_lower or "invalid" in error_lower:
713
+ error_type = "validation"
714
+ transient = False
715
+ else:
716
+ error_type = "runtime"
717
+ transient = False
718
+
719
+ provider_str = getattr(self, "_provider_str", "unknown")
720
+ result = WorkflowResult(
721
+ success=error is None,
722
+ stages=self._stages_run,
723
+ final_output=final_output,
724
+ cost_report=self._generate_cost_report(),
725
+ started_at=started_at,
726
+ completed_at=completed_at,
727
+ total_duration_ms=total_duration_ms,
728
+ provider=provider_str,
729
+ error=error,
730
+ error_type=error_type,
731
+ transient=transient,
732
+ )
733
+
734
+ # Report workflow completion to progress tracker
735
+ if self._progress_tracker and error is None:
736
+ self._progress_tracker.complete_workflow()
737
+
738
+ # Save to workflow history for dashboard
739
+ try:
740
+ _save_workflow_run(self.name, provider_str, result)
741
+ except (OSError, PermissionError):
742
+ # File system errors saving history - log but don't crash workflow
743
+ logger.warning("Failed to save workflow history (file system error)")
744
+ except (ValueError, TypeError, KeyError):
745
+ # Data serialization errors - log but don't crash workflow
746
+ logger.warning("Failed to save workflow history (serialization error)")
747
+ except Exception:
748
+ # INTENTIONAL: History save is optional diagnostics - never crash workflow
749
+ logger.exception("Unexpected error saving workflow history")
750
+
751
+ # Emit workflow telemetry to backend
752
+ self._emit_workflow_telemetry(result)
753
+
754
+ return result
755
+
756
+ def describe(self) -> str:
757
+ """Get a human-readable description of the workflow."""
758
+ lines = [
759
+ f"Workflow: {self.name}",
760
+ f"Description: {self.description}",
761
+ "",
762
+ "Stages:",
763
+ ]
764
+
765
+ for stage_name in self.stages:
766
+ tier = self.get_tier_for_stage(stage_name)
767
+ model = self.get_model_for_tier(tier)
768
+ lines.append(f" {stage_name}: {tier.value} ({model})")
769
+
770
+ return "\n".join(lines)
771
+
772
+ # =========================================================================
773
+ # New infrastructure methods (Phase 4)
774
+ # =========================================================================
775
+
776
+ def _create_execution_context(
777
+ self,
778
+ step_name: str,
779
+ task_type: str,
780
+ user_id: str | None = None,
781
+ session_id: str | None = None,
782
+ ) -> ExecutionContext:
783
+ """Create an ExecutionContext for a step execution.
784
+
785
+ Args:
786
+ step_name: Name of the workflow step
787
+ task_type: Task type for routing
788
+ user_id: Optional user ID
789
+ session_id: Optional session ID
790
+
791
+ Returns:
792
+ ExecutionContext populated with workflow info
793
+
794
+ """
795
+ return ExecutionContext(
796
+ workflow_name=self.name,
797
+ step_name=step_name,
798
+ user_id=user_id,
799
+ session_id=session_id,
800
+ metadata={
801
+ "task_type": task_type,
802
+ "run_id": self._run_id,
803
+ "provider": self._provider_str,
804
+ },
805
+ )
806
+
807
+ def _create_default_executor(self) -> LLMExecutor:
808
+ """Create a default EmpathyLLMExecutor wrapped in ResilientExecutor.
809
+
810
+ This method is called lazily when run_step_with_executor is used
811
+ without a pre-configured executor. The executor is wrapped with
812
+ resilience features (retry, fallback, circuit breaker).
813
+
814
+ Returns:
815
+ LLMExecutor instance (ResilientExecutor wrapping EmpathyLLMExecutor)
816
+
817
+ """
818
+ from empathy_os.models.empathy_executor import EmpathyLLMExecutor
819
+ from empathy_os.models.fallback import ResilientExecutor
820
+
821
+ # Create the base executor
822
+ base_executor = EmpathyLLMExecutor(
823
+ provider=self._provider_str,
824
+ api_key=self._api_key,
825
+ telemetry_store=self._telemetry_backend,
826
+ )
827
+ # Wrap with resilience layer (retry, fallback, circuit breaker)
828
+ return ResilientExecutor(executor=base_executor)
829
+
830
+ def _get_executor(self) -> LLMExecutor:
831
+ """Get or create the LLM executor.
832
+
833
+ Returns the configured executor or creates a default one.
834
+
835
+ Returns:
836
+ LLMExecutor instance
837
+
838
+ """
839
+ if self._executor is None:
840
+ self._executor = self._create_default_executor()
841
+ return self._executor
842
+
843
+ def _emit_call_telemetry(
844
+ self,
845
+ step_name: str,
846
+ task_type: str,
847
+ tier: str,
848
+ model_id: str,
849
+ input_tokens: int,
850
+ output_tokens: int,
851
+ cost: float,
852
+ latency_ms: int,
853
+ success: bool = True,
854
+ error_message: str | None = None,
855
+ fallback_used: bool = False,
856
+ ) -> None:
857
+ """Emit an LLMCallRecord to the telemetry backend.
858
+
859
+ Args:
860
+ step_name: Name of the workflow step
861
+ task_type: Task type used for routing
862
+ tier: Model tier used
863
+ model_id: Model ID used
864
+ input_tokens: Input token count
865
+ output_tokens: Output token count
866
+ cost: Estimated cost
867
+ latency_ms: Latency in milliseconds
868
+ success: Whether the call succeeded
869
+ error_message: Error message if failed
870
+ fallback_used: Whether fallback was used
871
+
872
+ """
873
+ record = LLMCallRecord(
874
+ call_id=str(uuid.uuid4()),
875
+ timestamp=datetime.now().isoformat(),
876
+ workflow_name=self.name,
877
+ step_name=step_name,
878
+ task_type=task_type,
879
+ provider=self._provider_str,
880
+ tier=tier,
881
+ model_id=model_id,
882
+ input_tokens=input_tokens,
883
+ output_tokens=output_tokens,
884
+ estimated_cost=cost,
885
+ latency_ms=latency_ms,
886
+ success=success,
887
+ error_message=error_message,
888
+ fallback_used=fallback_used,
889
+ metadata={"run_id": self._run_id},
890
+ )
891
+ try:
892
+ self._telemetry_backend.log_call(record)
893
+ except (AttributeError, ValueError, TypeError):
894
+ # Telemetry backend errors - log but don't crash workflow
895
+ logger.debug("Failed to log call telemetry (backend error)")
896
+ except OSError:
897
+ # File system errors - log but don't crash workflow
898
+ logger.debug("Failed to log call telemetry (file system error)")
899
+ except Exception: # noqa: BLE001
900
+ # INTENTIONAL: Telemetry is optional diagnostics - never crash workflow
901
+ logger.debug("Unexpected error logging call telemetry")
902
+
903
+ def _emit_workflow_telemetry(self, result: WorkflowResult) -> None:
904
+ """Emit a WorkflowRunRecord to the telemetry backend.
905
+
906
+ Args:
907
+ result: The workflow result to record
908
+
909
+ """
910
+ # Build stage records
911
+ stages = [
912
+ WorkflowStageRecord(
913
+ stage_name=s.name,
914
+ tier=s.tier.value,
915
+ model_id=self.get_model_for_tier(s.tier),
916
+ input_tokens=s.input_tokens,
917
+ output_tokens=s.output_tokens,
918
+ cost=s.cost,
919
+ latency_ms=s.duration_ms,
920
+ success=not s.skipped and result.error is None,
921
+ skipped=s.skipped,
922
+ skip_reason=s.skip_reason,
923
+ )
924
+ for s in result.stages
925
+ ]
926
+
927
+ record = WorkflowRunRecord(
928
+ run_id=self._run_id or str(uuid.uuid4()),
929
+ workflow_name=self.name,
930
+ started_at=result.started_at.isoformat(),
931
+ completed_at=result.completed_at.isoformat(),
932
+ stages=stages,
933
+ total_input_tokens=sum(s.input_tokens for s in result.stages if not s.skipped),
934
+ total_output_tokens=sum(s.output_tokens for s in result.stages if not s.skipped),
935
+ total_cost=result.cost_report.total_cost,
936
+ baseline_cost=result.cost_report.baseline_cost,
937
+ savings=result.cost_report.savings,
938
+ savings_percent=result.cost_report.savings_percent,
939
+ total_duration_ms=result.total_duration_ms,
940
+ success=result.success,
941
+ error=result.error,
942
+ providers_used=[self._provider_str],
943
+ tiers_used=list(result.cost_report.by_tier.keys()),
944
+ )
945
+ try:
946
+ self._telemetry_backend.log_workflow(record)
947
+ except (AttributeError, ValueError, TypeError):
948
+ # Telemetry backend errors - log but don't crash workflow
949
+ logger.debug("Failed to log workflow telemetry (backend error)")
950
+ except OSError:
951
+ # File system errors - log but don't crash workflow
952
+ logger.debug("Failed to log workflow telemetry (file system error)")
953
+ except Exception: # noqa: BLE001
954
+ # INTENTIONAL: Telemetry is optional diagnostics - never crash workflow
955
+ logger.debug("Unexpected error logging workflow telemetry")
956
+
957
+ async def run_step_with_executor(
958
+ self,
959
+ step: WorkflowStepConfig,
960
+ prompt: str,
961
+ system: str | None = None,
962
+ **kwargs: Any,
963
+ ) -> tuple[str, int, int, float]:
964
+ """Run a workflow step using the LLMExecutor.
965
+
966
+ This method provides a unified interface for executing steps with
967
+ automatic routing, telemetry, and cost tracking. If no executor
968
+ was provided at construction, a default EmpathyLLMExecutor is created.
969
+
970
+ Args:
971
+ step: WorkflowStepConfig defining the step
972
+ prompt: The prompt to send
973
+ system: Optional system prompt
974
+ **kwargs: Additional arguments passed to executor
975
+
976
+ Returns:
977
+ Tuple of (content, input_tokens, output_tokens, cost)
978
+
979
+ """
980
+ executor = self._get_executor()
981
+
982
+ context = self._create_execution_context(
983
+ step_name=step.name,
984
+ task_type=step.task_type,
985
+ )
986
+
987
+ start_time = datetime.now()
988
+ response = await executor.run(
989
+ task_type=step.task_type,
990
+ prompt=prompt,
991
+ system=system,
992
+ context=context,
993
+ **kwargs,
994
+ )
995
+ end_time = datetime.now()
996
+ latency_ms = int((end_time - start_time).total_seconds() * 1000)
997
+
998
+ # Emit telemetry
999
+ self._emit_call_telemetry(
1000
+ step_name=step.name,
1001
+ task_type=step.task_type,
1002
+ tier=response.tier,
1003
+ model_id=response.model_id,
1004
+ input_tokens=response.tokens_input,
1005
+ output_tokens=response.tokens_output,
1006
+ cost=response.cost_estimate,
1007
+ latency_ms=latency_ms,
1008
+ success=True,
1009
+ )
1010
+
1011
+ return (
1012
+ response.content,
1013
+ response.tokens_input,
1014
+ response.tokens_output,
1015
+ response.cost_estimate,
1016
+ )
1017
+
1018
+ # =========================================================================
1019
+ # XML Prompt Integration (Phase 4)
1020
+ # =========================================================================
1021
+
1022
+ def _get_xml_config(self) -> dict[str, Any]:
1023
+ """Get XML prompt configuration for this workflow.
1024
+
1025
+ Returns:
1026
+ Dictionary with XML configuration settings.
1027
+
1028
+ """
1029
+ if self._config is None:
1030
+ return {}
1031
+ return self._config.get_xml_config_for_workflow(self.name)
1032
+
1033
+ def _is_xml_enabled(self) -> bool:
1034
+ """Check if XML prompts are enabled for this workflow."""
1035
+ config = self._get_xml_config()
1036
+ return bool(config.get("enabled", False))
1037
+
1038
+ def _render_xml_prompt(
1039
+ self,
1040
+ role: str,
1041
+ goal: str,
1042
+ instructions: list[str],
1043
+ constraints: list[str],
1044
+ input_type: str,
1045
+ input_payload: str,
1046
+ extra: dict[str, Any] | None = None,
1047
+ ) -> str:
1048
+ """Render a prompt using XML template if enabled.
1049
+
1050
+ Args:
1051
+ role: The role for the AI (e.g., "security analyst").
1052
+ goal: The primary objective.
1053
+ instructions: Step-by-step instructions.
1054
+ constraints: Rules and guidelines.
1055
+ input_type: Type of input ("code", "diff", "document").
1056
+ input_payload: The content to process.
1057
+ extra: Additional context data.
1058
+
1059
+ Returns:
1060
+ Rendered prompt string (XML if enabled, plain text otherwise).
1061
+
1062
+ """
1063
+ from empathy_os.prompts import PromptContext, XmlPromptTemplate, get_template
1064
+
1065
+ config = self._get_xml_config()
1066
+
1067
+ if not config.get("enabled", False):
1068
+ # Fall back to plain text
1069
+ return self._render_plain_prompt(
1070
+ role,
1071
+ goal,
1072
+ instructions,
1073
+ constraints,
1074
+ input_type,
1075
+ input_payload,
1076
+ )
1077
+
1078
+ # Create context
1079
+ context = PromptContext(
1080
+ role=role,
1081
+ goal=goal,
1082
+ instructions=instructions,
1083
+ constraints=constraints,
1084
+ input_type=input_type,
1085
+ input_payload=input_payload,
1086
+ extra=extra or {},
1087
+ )
1088
+
1089
+ # Get template
1090
+ template_name = config.get("template_name", self.name)
1091
+ template = get_template(template_name)
1092
+
1093
+ if template is None:
1094
+ # Create a basic XML template if no built-in found
1095
+ template = XmlPromptTemplate(
1096
+ name=self.name,
1097
+ schema_version=config.get("schema_version", "1.0"),
1098
+ )
1099
+
1100
+ return template.render(context)
1101
+
1102
+ def _render_plain_prompt(
1103
+ self,
1104
+ role: str,
1105
+ goal: str,
1106
+ instructions: list[str],
1107
+ constraints: list[str],
1108
+ input_type: str,
1109
+ input_payload: str,
1110
+ ) -> str:
1111
+ """Render a plain text prompt (fallback when XML is disabled)."""
1112
+ parts = [f"You are a {role}.", "", f"Goal: {goal}", ""]
1113
+
1114
+ if instructions:
1115
+ parts.append("Instructions:")
1116
+ for i, inst in enumerate(instructions, 1):
1117
+ parts.append(f"{i}. {inst}")
1118
+ parts.append("")
1119
+
1120
+ if constraints:
1121
+ parts.append("Guidelines:")
1122
+ for constraint in constraints:
1123
+ parts.append(f"- {constraint}")
1124
+ parts.append("")
1125
+
1126
+ if input_payload:
1127
+ parts.append(f"Input ({input_type}):")
1128
+ parts.append(input_payload)
1129
+
1130
+ return "\n".join(parts)
1131
+
1132
+ def _parse_xml_response(self, response: str) -> dict[str, Any]:
1133
+ """Parse an XML response if XML enforcement is enabled.
1134
+
1135
+ Args:
1136
+ response: The LLM response text.
1137
+
1138
+ Returns:
1139
+ Dictionary with parsed fields or raw response data.
1140
+
1141
+ """
1142
+ from empathy_os.prompts import XmlResponseParser
1143
+
1144
+ config = self._get_xml_config()
1145
+
1146
+ if not config.get("enforce_response_xml", False):
1147
+ # No parsing needed, return as-is
1148
+ return {
1149
+ "_parsed_response": None,
1150
+ "_raw": response,
1151
+ }
1152
+
1153
+ fallback = config.get("fallback_on_parse_error", True)
1154
+ parser = XmlResponseParser(fallback_on_error=fallback)
1155
+ parsed = parser.parse(response)
1156
+
1157
+ return {
1158
+ "_parsed_response": parsed,
1159
+ "_raw": response,
1160
+ "summary": parsed.summary,
1161
+ "findings": [f.to_dict() for f in parsed.findings],
1162
+ "checklist": parsed.checklist,
1163
+ "xml_parsed": parsed.success,
1164
+ "parse_errors": parsed.errors,
1165
+ }
1166
+
1167
+ def _extract_findings_from_response(
1168
+ self,
1169
+ response: str,
1170
+ files_changed: list[str],
1171
+ code_context: str = "",
1172
+ ) -> list[dict[str, Any]]:
1173
+ """Extract structured findings from LLM response.
1174
+
1175
+ Tries multiple strategies in order:
1176
+ 1. XML parsing (if XML tags present)
1177
+ 2. Regex-based extraction for file:line patterns
1178
+ 3. Returns empty list if no findings extractable
1179
+
1180
+ Args:
1181
+ response: Raw LLM response text
1182
+ files_changed: List of files being analyzed (for context)
1183
+ code_context: Original code being reviewed (optional)
1184
+
1185
+ Returns:
1186
+ List of findings matching WorkflowFinding schema:
1187
+ [
1188
+ {
1189
+ "id": "unique-id",
1190
+ "file": "relative/path.py",
1191
+ "line": 42,
1192
+ "column": 10,
1193
+ "severity": "high",
1194
+ "category": "security",
1195
+ "message": "Brief message",
1196
+ "details": "Extended explanation",
1197
+ "recommendation": "Fix suggestion"
1198
+ }
1199
+ ]
1200
+
1201
+ """
1202
+ import re
1203
+ import uuid
1204
+
1205
+ findings: list[dict[str, Any]] = []
1206
+
1207
+ # Strategy 1: Try XML parsing first
1208
+ response_lower = response.lower()
1209
+ if (
1210
+ "<finding>" in response_lower
1211
+ or "<issue>" in response_lower
1212
+ or "<findings>" in response_lower
1213
+ ):
1214
+ # Parse XML directly (bypass config checks)
1215
+ from empathy_os.prompts import XmlResponseParser
1216
+
1217
+ parser = XmlResponseParser(fallback_on_error=True)
1218
+ parsed = parser.parse(response)
1219
+
1220
+ if parsed.success and parsed.findings:
1221
+ for raw_finding in parsed.findings:
1222
+ enriched = self._enrich_finding_with_location(
1223
+ raw_finding.to_dict(),
1224
+ files_changed,
1225
+ )
1226
+ findings.append(enriched)
1227
+ return findings
1228
+
1229
+ # Strategy 2: Regex-based extraction for common patterns
1230
+ # Match patterns like:
1231
+ # - "src/auth.py:42: SQL injection found"
1232
+ # - "In file src/auth.py line 42"
1233
+ # - "auth.py (line 42, column 10)"
1234
+ patterns = [
1235
+ # Pattern 1: file.py:line:column: message
1236
+ r"([^\s:]+\.(?:py|ts|tsx|js|jsx|java|go|rb|php)):(\d+):(\d+):\s*(.+)",
1237
+ # Pattern 2: file.py:line: message
1238
+ r"([^\s:]+\.(?:py|ts|tsx|js|jsx|java|go|rb|php)):(\d+):\s*(.+)",
1239
+ # Pattern 3: in file X line Y
1240
+ r"(?:in file|file)\s+([^\s]+\.(?:py|ts|tsx|js|jsx|java|go|rb|php))\s+line\s+(\d+)",
1241
+ # Pattern 4: file.py (line X)
1242
+ r"([^\s]+\.(?:py|ts|tsx|js|jsx|java|go|rb|php))\s*\(line\s+(\d+)(?:,\s*col(?:umn)?\s+(\d+))?\)",
1243
+ ]
1244
+
1245
+ for pattern in patterns:
1246
+ matches = re.findall(pattern, response, re.IGNORECASE)
1247
+ for match in matches:
1248
+ if len(match) >= 2:
1249
+ file_path = match[0]
1250
+ line = int(match[1])
1251
+
1252
+ # Handle different pattern formats
1253
+ if len(match) == 4 and match[2].isdigit():
1254
+ # Pattern 1: file:line:col:message
1255
+ column = int(match[2])
1256
+ message = match[3]
1257
+ elif len(match) == 3 and match[2] and not match[2].isdigit():
1258
+ # Pattern 2: file:line:message
1259
+ column = 1
1260
+ message = match[2]
1261
+ elif len(match) == 3 and match[2].isdigit():
1262
+ # Pattern 4: file (line col)
1263
+ column = int(match[2])
1264
+ message = ""
1265
+ else:
1266
+ # Pattern 3: in file X line Y (no message)
1267
+ column = 1
1268
+ message = ""
1269
+
1270
+ # Determine severity from keywords in message
1271
+ severity = self._infer_severity(message)
1272
+ category = self._infer_category(message)
1273
+
1274
+ findings.append(
1275
+ {
1276
+ "id": str(uuid.uuid4())[:8],
1277
+ "file": file_path,
1278
+ "line": line,
1279
+ "column": column,
1280
+ "severity": severity,
1281
+ "category": category,
1282
+ "message": message.strip() if message else "",
1283
+ "details": "",
1284
+ "recommendation": "",
1285
+ },
1286
+ )
1287
+
1288
+ # Deduplicate by file:line
1289
+ seen = set()
1290
+ unique_findings = []
1291
+ for finding in findings:
1292
+ key = (finding["file"], finding["line"])
1293
+ if key not in seen:
1294
+ seen.add(key)
1295
+ unique_findings.append(finding)
1296
+
1297
+ return unique_findings
1298
+
1299
+ def _enrich_finding_with_location(
1300
+ self,
1301
+ raw_finding: dict[str, Any],
1302
+ files_changed: list[str],
1303
+ ) -> dict[str, Any]:
1304
+ """Enrich a finding from XML parser with file/line/column fields.
1305
+
1306
+ Args:
1307
+ raw_finding: Finding dict from XML parser (has 'location' string field)
1308
+ files_changed: List of files being analyzed
1309
+
1310
+ Returns:
1311
+ Enriched finding dict with file, line, column fields
1312
+
1313
+ """
1314
+ import uuid
1315
+
1316
+ location_str = raw_finding.get("location", "")
1317
+ file_path, line, column = self._parse_location_string(location_str, files_changed)
1318
+
1319
+ # Map category from severity or title keywords
1320
+ category = self._infer_category(
1321
+ raw_finding.get("title", "") + " " + raw_finding.get("details", ""),
1322
+ )
1323
+
1324
+ return {
1325
+ "id": str(uuid.uuid4())[:8],
1326
+ "file": file_path,
1327
+ "line": line,
1328
+ "column": column,
1329
+ "severity": raw_finding.get("severity", "medium"),
1330
+ "category": category,
1331
+ "message": raw_finding.get("title", ""),
1332
+ "details": raw_finding.get("details", ""),
1333
+ "recommendation": raw_finding.get("fix", ""),
1334
+ }
1335
+
1336
+ def _parse_location_string(
1337
+ self,
1338
+ location: str,
1339
+ files_changed: list[str],
1340
+ ) -> tuple[str, int, int]:
1341
+ """Parse a location string to extract file, line, column.
1342
+
1343
+ Handles formats like:
1344
+ - "src/auth.py:42:10"
1345
+ - "src/auth.py:42"
1346
+ - "auth.py line 42"
1347
+ - "line 42 in auth.py"
1348
+
1349
+ Args:
1350
+ location: Location string from finding
1351
+ files_changed: List of files being analyzed (for fallback)
1352
+
1353
+ Returns:
1354
+ Tuple of (file_path, line_number, column_number)
1355
+ Defaults: ("", 1, 1) if parsing fails
1356
+
1357
+ """
1358
+ import re
1359
+
1360
+ if not location:
1361
+ # Fallback: use first file if available
1362
+ return (files_changed[0] if files_changed else "", 1, 1)
1363
+
1364
+ # Try colon-separated format: file.py:line:col
1365
+ match = re.search(
1366
+ r"([^\s:]+\.(?:py|ts|tsx|js|jsx|java|go|rb|php)):(\d+)(?::(\d+))?",
1367
+ location,
1368
+ )
1369
+ if match:
1370
+ file_path = match.group(1)
1371
+ line = int(match.group(2))
1372
+ column = int(match.group(3)) if match.group(3) else 1
1373
+ return (file_path, line, column)
1374
+
1375
+ # Try "line X in file.py" format
1376
+ match = re.search(
1377
+ r"line\s+(\d+)\s+(?:in|of)\s+([^\s]+\.(?:py|ts|tsx|js|jsx|java|go|rb|php))",
1378
+ location,
1379
+ re.IGNORECASE,
1380
+ )
1381
+ if match:
1382
+ line = int(match.group(1))
1383
+ file_path = match.group(2)
1384
+ return (file_path, line, 1)
1385
+
1386
+ # Try "file.py line X" format
1387
+ match = re.search(
1388
+ r"([^\s]+\.(?:py|ts|tsx|js|jsx|java|go|rb|php))\s+line\s+(\d+)",
1389
+ location,
1390
+ re.IGNORECASE,
1391
+ )
1392
+ if match:
1393
+ file_path = match.group(1)
1394
+ line = int(match.group(2))
1395
+ return (file_path, line, 1)
1396
+
1397
+ # Extract just line number if present
1398
+ match = re.search(r"line\s+(\d+)", location, re.IGNORECASE)
1399
+ if match:
1400
+ line = int(match.group(1))
1401
+ # Use first file from files_changed as fallback
1402
+ file_path = files_changed[0] if files_changed else ""
1403
+ return (file_path, line, 1)
1404
+
1405
+ # Couldn't parse - return defaults
1406
+ return (files_changed[0] if files_changed else "", 1, 1)
1407
+
1408
+ def _infer_severity(self, text: str) -> str:
1409
+ """Infer severity from keywords in text.
1410
+
1411
+ Args:
1412
+ text: Message or title text
1413
+
1414
+ Returns:
1415
+ Severity level: critical, high, medium, low, or info
1416
+
1417
+ """
1418
+ text_lower = text.lower()
1419
+
1420
+ if any(
1421
+ word in text_lower
1422
+ for word in [
1423
+ "critical",
1424
+ "severe",
1425
+ "exploit",
1426
+ "vulnerability",
1427
+ "injection",
1428
+ "remote code execution",
1429
+ "rce",
1430
+ ]
1431
+ ):
1432
+ return "critical"
1433
+
1434
+ if any(
1435
+ word in text_lower
1436
+ for word in [
1437
+ "high",
1438
+ "security",
1439
+ "unsafe",
1440
+ "dangerous",
1441
+ "xss",
1442
+ "csrf",
1443
+ "auth",
1444
+ "password",
1445
+ "secret",
1446
+ ]
1447
+ ):
1448
+ return "high"
1449
+
1450
+ if any(
1451
+ word in text_lower
1452
+ for word in [
1453
+ "warning",
1454
+ "issue",
1455
+ "problem",
1456
+ "bug",
1457
+ "error",
1458
+ "deprecated",
1459
+ "leak",
1460
+ ]
1461
+ ):
1462
+ return "medium"
1463
+
1464
+ if any(word in text_lower for word in ["low", "minor", "style", "format", "typo"]):
1465
+ return "low"
1466
+
1467
+ return "info"
1468
+
1469
+ def _infer_category(self, text: str) -> str:
1470
+ """Infer finding category from keywords.
1471
+
1472
+ Args:
1473
+ text: Message or title text
1474
+
1475
+ Returns:
1476
+ Category: security, performance, maintainability, style, or correctness
1477
+
1478
+ """
1479
+ text_lower = text.lower()
1480
+
1481
+ if any(
1482
+ word in text_lower
1483
+ for word in [
1484
+ "security",
1485
+ "vulnerability",
1486
+ "injection",
1487
+ "xss",
1488
+ "csrf",
1489
+ "auth",
1490
+ "encrypt",
1491
+ "password",
1492
+ "secret",
1493
+ "unsafe",
1494
+ ]
1495
+ ):
1496
+ return "security"
1497
+
1498
+ if any(
1499
+ word in text_lower
1500
+ for word in [
1501
+ "performance",
1502
+ "slow",
1503
+ "memory",
1504
+ "leak",
1505
+ "inefficient",
1506
+ "optimization",
1507
+ "cache",
1508
+ ]
1509
+ ):
1510
+ return "performance"
1511
+
1512
+ if any(
1513
+ word in text_lower
1514
+ for word in [
1515
+ "complex",
1516
+ "refactor",
1517
+ "duplicate",
1518
+ "maintainability",
1519
+ "readability",
1520
+ "documentation",
1521
+ ]
1522
+ ):
1523
+ return "maintainability"
1524
+
1525
+ if any(
1526
+ word in text_lower for word in ["style", "format", "lint", "convention", "whitespace"]
1527
+ ):
1528
+ return "style"
1529
+
1530
+ return "correctness"