truthound-dashboard 1.4.4__py3-none-any.whl → 1.5.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (205) hide show
  1. truthound_dashboard/api/alerts.py +75 -86
  2. truthound_dashboard/api/anomaly.py +7 -13
  3. truthound_dashboard/api/cross_alerts.py +38 -52
  4. truthound_dashboard/api/drift.py +49 -59
  5. truthound_dashboard/api/drift_monitor.py +234 -79
  6. truthound_dashboard/api/enterprise_sampling.py +498 -0
  7. truthound_dashboard/api/history.py +57 -5
  8. truthound_dashboard/api/lineage.py +3 -48
  9. truthound_dashboard/api/maintenance.py +104 -49
  10. truthound_dashboard/api/mask.py +1 -2
  11. truthound_dashboard/api/middleware.py +2 -1
  12. truthound_dashboard/api/model_monitoring.py +435 -311
  13. truthound_dashboard/api/notifications.py +227 -191
  14. truthound_dashboard/api/notifications_advanced.py +21 -20
  15. truthound_dashboard/api/observability.py +586 -0
  16. truthound_dashboard/api/plugins.py +2 -433
  17. truthound_dashboard/api/profile.py +199 -37
  18. truthound_dashboard/api/quality_reporter.py +701 -0
  19. truthound_dashboard/api/reports.py +7 -16
  20. truthound_dashboard/api/router.py +66 -0
  21. truthound_dashboard/api/rule_suggestions.py +5 -5
  22. truthound_dashboard/api/scan.py +17 -19
  23. truthound_dashboard/api/schedules.py +85 -50
  24. truthound_dashboard/api/schema_evolution.py +6 -6
  25. truthound_dashboard/api/schema_watcher.py +667 -0
  26. truthound_dashboard/api/sources.py +98 -27
  27. truthound_dashboard/api/tiering.py +1323 -0
  28. truthound_dashboard/api/triggers.py +14 -11
  29. truthound_dashboard/api/validations.py +12 -11
  30. truthound_dashboard/api/versioning.py +1 -6
  31. truthound_dashboard/core/__init__.py +129 -3
  32. truthound_dashboard/core/actions/__init__.py +62 -0
  33. truthound_dashboard/core/actions/custom.py +426 -0
  34. truthound_dashboard/core/actions/notifications.py +910 -0
  35. truthound_dashboard/core/actions/storage.py +472 -0
  36. truthound_dashboard/core/actions/webhook.py +281 -0
  37. truthound_dashboard/core/anomaly.py +262 -67
  38. truthound_dashboard/core/anomaly_explainer.py +4 -3
  39. truthound_dashboard/core/backends/__init__.py +67 -0
  40. truthound_dashboard/core/backends/base.py +299 -0
  41. truthound_dashboard/core/backends/errors.py +191 -0
  42. truthound_dashboard/core/backends/factory.py +423 -0
  43. truthound_dashboard/core/backends/mock_backend.py +451 -0
  44. truthound_dashboard/core/backends/truthound_backend.py +718 -0
  45. truthound_dashboard/core/checkpoint/__init__.py +87 -0
  46. truthound_dashboard/core/checkpoint/adapters.py +814 -0
  47. truthound_dashboard/core/checkpoint/checkpoint.py +491 -0
  48. truthound_dashboard/core/checkpoint/runner.py +270 -0
  49. truthound_dashboard/core/connections.py +437 -10
  50. truthound_dashboard/core/converters/__init__.py +14 -0
  51. truthound_dashboard/core/converters/truthound.py +620 -0
  52. truthound_dashboard/core/cross_alerts.py +540 -320
  53. truthound_dashboard/core/datasource_factory.py +1672 -0
  54. truthound_dashboard/core/drift_monitor.py +216 -20
  55. truthound_dashboard/core/enterprise_sampling.py +1291 -0
  56. truthound_dashboard/core/interfaces/__init__.py +225 -0
  57. truthound_dashboard/core/interfaces/actions.py +652 -0
  58. truthound_dashboard/core/interfaces/base.py +247 -0
  59. truthound_dashboard/core/interfaces/checkpoint.py +676 -0
  60. truthound_dashboard/core/interfaces/protocols.py +664 -0
  61. truthound_dashboard/core/interfaces/reporters.py +650 -0
  62. truthound_dashboard/core/interfaces/routing.py +646 -0
  63. truthound_dashboard/core/interfaces/triggers.py +619 -0
  64. truthound_dashboard/core/lineage.py +407 -71
  65. truthound_dashboard/core/model_monitoring.py +431 -3
  66. truthound_dashboard/core/notifications/base.py +4 -0
  67. truthound_dashboard/core/notifications/channels.py +501 -1203
  68. truthound_dashboard/core/notifications/deduplication/__init__.py +81 -115
  69. truthound_dashboard/core/notifications/deduplication/service.py +131 -348
  70. truthound_dashboard/core/notifications/dispatcher.py +202 -11
  71. truthound_dashboard/core/notifications/escalation/__init__.py +119 -106
  72. truthound_dashboard/core/notifications/escalation/engine.py +168 -358
  73. truthound_dashboard/core/notifications/routing/__init__.py +88 -128
  74. truthound_dashboard/core/notifications/routing/engine.py +90 -317
  75. truthound_dashboard/core/notifications/stats_aggregator.py +246 -1
  76. truthound_dashboard/core/notifications/throttling/__init__.py +67 -50
  77. truthound_dashboard/core/notifications/throttling/builder.py +117 -255
  78. truthound_dashboard/core/notifications/truthound_adapter.py +842 -0
  79. truthound_dashboard/core/phase5/collaboration.py +1 -1
  80. truthound_dashboard/core/plugins/lifecycle/__init__.py +0 -13
  81. truthound_dashboard/core/quality_reporter.py +1359 -0
  82. truthound_dashboard/core/report_history.py +0 -6
  83. truthound_dashboard/core/reporters/__init__.py +175 -14
  84. truthound_dashboard/core/reporters/adapters.py +943 -0
  85. truthound_dashboard/core/reporters/base.py +0 -3
  86. truthound_dashboard/core/reporters/builtin/__init__.py +18 -0
  87. truthound_dashboard/core/reporters/builtin/csv_reporter.py +111 -0
  88. truthound_dashboard/core/reporters/builtin/html_reporter.py +270 -0
  89. truthound_dashboard/core/reporters/builtin/json_reporter.py +127 -0
  90. truthound_dashboard/core/reporters/compat.py +266 -0
  91. truthound_dashboard/core/reporters/csv_reporter.py +2 -35
  92. truthound_dashboard/core/reporters/factory.py +526 -0
  93. truthound_dashboard/core/reporters/interfaces.py +745 -0
  94. truthound_dashboard/core/reporters/registry.py +1 -10
  95. truthound_dashboard/core/scheduler.py +165 -0
  96. truthound_dashboard/core/schema_evolution.py +3 -3
  97. truthound_dashboard/core/schema_watcher.py +1528 -0
  98. truthound_dashboard/core/services.py +595 -76
  99. truthound_dashboard/core/store_manager.py +810 -0
  100. truthound_dashboard/core/streaming_anomaly.py +169 -4
  101. truthound_dashboard/core/tiering.py +1309 -0
  102. truthound_dashboard/core/triggers/evaluators.py +178 -8
  103. truthound_dashboard/core/truthound_adapter.py +2620 -197
  104. truthound_dashboard/core/unified_alerts.py +23 -20
  105. truthound_dashboard/db/__init__.py +8 -0
  106. truthound_dashboard/db/database.py +8 -2
  107. truthound_dashboard/db/models.py +944 -25
  108. truthound_dashboard/db/repository.py +2 -0
  109. truthound_dashboard/main.py +11 -0
  110. truthound_dashboard/schemas/__init__.py +177 -16
  111. truthound_dashboard/schemas/base.py +44 -23
  112. truthound_dashboard/schemas/collaboration.py +19 -6
  113. truthound_dashboard/schemas/cross_alerts.py +19 -3
  114. truthound_dashboard/schemas/drift.py +61 -55
  115. truthound_dashboard/schemas/drift_monitor.py +67 -23
  116. truthound_dashboard/schemas/enterprise_sampling.py +653 -0
  117. truthound_dashboard/schemas/lineage.py +0 -33
  118. truthound_dashboard/schemas/mask.py +10 -8
  119. truthound_dashboard/schemas/model_monitoring.py +89 -10
  120. truthound_dashboard/schemas/notifications_advanced.py +13 -0
  121. truthound_dashboard/schemas/observability.py +453 -0
  122. truthound_dashboard/schemas/plugins.py +0 -280
  123. truthound_dashboard/schemas/profile.py +154 -247
  124. truthound_dashboard/schemas/quality_reporter.py +403 -0
  125. truthound_dashboard/schemas/reports.py +2 -2
  126. truthound_dashboard/schemas/rule_suggestion.py +8 -1
  127. truthound_dashboard/schemas/scan.py +4 -24
  128. truthound_dashboard/schemas/schedule.py +11 -3
  129. truthound_dashboard/schemas/schema_watcher.py +727 -0
  130. truthound_dashboard/schemas/source.py +17 -2
  131. truthound_dashboard/schemas/tiering.py +822 -0
  132. truthound_dashboard/schemas/triggers.py +16 -0
  133. truthound_dashboard/schemas/unified_alerts.py +7 -0
  134. truthound_dashboard/schemas/validation.py +0 -13
  135. truthound_dashboard/schemas/validators/base.py +41 -21
  136. truthound_dashboard/schemas/validators/business_rule_validators.py +244 -0
  137. truthound_dashboard/schemas/validators/localization_validators.py +273 -0
  138. truthound_dashboard/schemas/validators/ml_feature_validators.py +308 -0
  139. truthound_dashboard/schemas/validators/profiling_validators.py +275 -0
  140. truthound_dashboard/schemas/validators/referential_validators.py +312 -0
  141. truthound_dashboard/schemas/validators/registry.py +93 -8
  142. truthound_dashboard/schemas/validators/timeseries_validators.py +389 -0
  143. truthound_dashboard/schemas/versioning.py +1 -6
  144. truthound_dashboard/static/index.html +2 -2
  145. truthound_dashboard-1.5.0.dist-info/METADATA +309 -0
  146. {truthound_dashboard-1.4.4.dist-info → truthound_dashboard-1.5.0.dist-info}/RECORD +149 -148
  147. truthound_dashboard/core/plugins/hooks/__init__.py +0 -63
  148. truthound_dashboard/core/plugins/hooks/decorators.py +0 -367
  149. truthound_dashboard/core/plugins/hooks/manager.py +0 -403
  150. truthound_dashboard/core/plugins/hooks/protocols.py +0 -265
  151. truthound_dashboard/core/plugins/lifecycle/hot_reload.py +0 -584
  152. truthound_dashboard/core/reporters/junit_reporter.py +0 -233
  153. truthound_dashboard/core/reporters/markdown_reporter.py +0 -207
  154. truthound_dashboard/core/reporters/pdf_reporter.py +0 -209
  155. truthound_dashboard/static/assets/_baseUniq-BcrSP13d.js +0 -1
  156. truthound_dashboard/static/assets/arc-DlYjKwIL.js +0 -1
  157. truthound_dashboard/static/assets/architectureDiagram-VXUJARFQ-Bb2drbQM.js +0 -36
  158. truthound_dashboard/static/assets/blockDiagram-VD42YOAC-BlsPG1CH.js +0 -122
  159. truthound_dashboard/static/assets/c4Diagram-YG6GDRKO-B9JdUoaC.js +0 -10
  160. truthound_dashboard/static/assets/channel-Q6mHF1Hd.js +0 -1
  161. truthound_dashboard/static/assets/chunk-4BX2VUAB-DmyoPVuJ.js +0 -1
  162. truthound_dashboard/static/assets/chunk-55IACEB6-Bcz6Siv8.js +0 -1
  163. truthound_dashboard/static/assets/chunk-B4BG7PRW-Br3G5Rum.js +0 -165
  164. truthound_dashboard/static/assets/chunk-DI55MBZ5-DuM9c23u.js +0 -220
  165. truthound_dashboard/static/assets/chunk-FMBD7UC4-DNU-5mvT.js +0 -15
  166. truthound_dashboard/static/assets/chunk-QN33PNHL-Im2yNcmS.js +0 -1
  167. truthound_dashboard/static/assets/chunk-QZHKN3VN-kZr8XFm1.js +0 -1
  168. truthound_dashboard/static/assets/chunk-TZMSLE5B-Q__360q_.js +0 -1
  169. truthound_dashboard/static/assets/classDiagram-2ON5EDUG-vtixxUyK.js +0 -1
  170. truthound_dashboard/static/assets/classDiagram-v2-WZHVMYZB-vtixxUyK.js +0 -1
  171. truthound_dashboard/static/assets/clone-BOt2LwD0.js +0 -1
  172. truthound_dashboard/static/assets/cose-bilkent-S5V4N54A-CBDw6iac.js +0 -1
  173. truthound_dashboard/static/assets/dagre-6UL2VRFP-XdKqmmY9.js +0 -4
  174. truthound_dashboard/static/assets/diagram-PSM6KHXK-DAZ8nx9V.js +0 -24
  175. truthound_dashboard/static/assets/diagram-QEK2KX5R-BRvDTbGD.js +0 -43
  176. truthound_dashboard/static/assets/diagram-S2PKOQOG-bQcczUkl.js +0 -24
  177. truthound_dashboard/static/assets/erDiagram-Q2GNP2WA-DPje7VMN.js +0 -60
  178. truthound_dashboard/static/assets/flowDiagram-NV44I4VS-B7BVtFVS.js +0 -162
  179. truthound_dashboard/static/assets/ganttDiagram-JELNMOA3-D6WKSS7U.js +0 -267
  180. truthound_dashboard/static/assets/gitGraphDiagram-NY62KEGX-D3vtVd3y.js +0 -65
  181. truthound_dashboard/static/assets/graph-BKgNKZVp.js +0 -1
  182. truthound_dashboard/static/assets/index-C6JSrkHo.css +0 -1
  183. truthound_dashboard/static/assets/index-DkU82VsU.js +0 -1800
  184. truthound_dashboard/static/assets/infoDiagram-WHAUD3N6-DnNCT429.js +0 -2
  185. truthound_dashboard/static/assets/journeyDiagram-XKPGCS4Q-DGiMozqS.js +0 -139
  186. truthound_dashboard/static/assets/kanban-definition-3W4ZIXB7-BV2gUgli.js +0 -89
  187. truthound_dashboard/static/assets/katex-Cu_Erd72.js +0 -261
  188. truthound_dashboard/static/assets/layout-DI2MfQ5G.js +0 -1
  189. truthound_dashboard/static/assets/min-DYdgXVcT.js +0 -1
  190. truthound_dashboard/static/assets/mindmap-definition-VGOIOE7T-C7x4ruxz.js +0 -68
  191. truthound_dashboard/static/assets/pieDiagram-ADFJNKIX-CAJaAB9f.js +0 -30
  192. truthound_dashboard/static/assets/quadrantDiagram-AYHSOK5B-DeqwDI46.js +0 -7
  193. truthound_dashboard/static/assets/requirementDiagram-UZGBJVZJ-e3XDpZIM.js +0 -64
  194. truthound_dashboard/static/assets/sankeyDiagram-TZEHDZUN-CNnAv5Ux.js +0 -10
  195. truthound_dashboard/static/assets/sequenceDiagram-WL72ISMW-Dsne-Of3.js +0 -145
  196. truthound_dashboard/static/assets/stateDiagram-FKZM4ZOC-Ee0sQXyb.js +0 -1
  197. truthound_dashboard/static/assets/stateDiagram-v2-4FDKWEC3-B26KqW_W.js +0 -1
  198. truthound_dashboard/static/assets/timeline-definition-IT6M3QCI-DZYi2yl3.js +0 -61
  199. truthound_dashboard/static/assets/treemap-KMMF4GRG-CY3f8In2.js +0 -128
  200. truthound_dashboard/static/assets/unmerged_dictionaries-Dd7xcPWG.js +0 -1
  201. truthound_dashboard/static/assets/xychartDiagram-PRI3JC2R-CS7fydZZ.js +0 -7
  202. truthound_dashboard-1.4.4.dist-info/METADATA +0 -507
  203. {truthound_dashboard-1.4.4.dist-info → truthound_dashboard-1.5.0.dist-info}/WHEEL +0 -0
  204. {truthound_dashboard-1.4.4.dist-info → truthound_dashboard-1.5.0.dist-info}/entry_points.txt +0 -0
  205. {truthound_dashboard-1.4.4.dist-info → truthound_dashboard-1.5.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,646 @@
1
+ """Routing interfaces for checkpoint-based validation pipelines.
2
+
3
+ Routing rules determine which actions to execute based on validation
4
+ results. They enable complex conditional logic for notifications and
5
+ post-validation processing.
6
+
7
+ This module defines abstract interfaces for routing that are loosely
8
+ coupled from truthound's checkpoint.routing module.
9
+
10
+ Routing features:
11
+ - Jinja2-based rule expressions
12
+ - Compound rules (AllOf, AnyOf, Not)
13
+ - Priority-based routing
14
+ - Action fanout (parallel execution)
15
+ - Context-based routing
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from abc import ABC, abstractmethod
21
+ from dataclasses import dataclass, field
22
+ from enum import Enum
23
+ from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable
24
+
25
+ if TYPE_CHECKING:
26
+ from truthound_dashboard.core.interfaces.actions import (
27
+ ActionContext,
28
+ ActionProtocol,
29
+ ActionResult,
30
+ )
31
+ from truthound_dashboard.core.interfaces.checkpoint import CheckpointResult
32
+
33
+
34
+ class RouteMode(str, Enum):
35
+ """How routes are evaluated."""
36
+
37
+ FIRST_MATCH = "first_match" # Stop at first matching route
38
+ ALL_MATCHES = "all_matches" # Execute all matching routes
39
+ PRIORITY = "priority" # Execute in priority order
40
+
41
+
42
+ class RoutePriority(int, Enum):
43
+ """Priority levels for routes."""
44
+
45
+ CRITICAL = 100
46
+ HIGH = 75
47
+ MEDIUM = 50
48
+ LOW = 25
49
+ DEFAULT = 0
50
+
51
+
52
+ @dataclass
53
+ class RouteContext:
54
+ """Context passed to routing rule evaluation.
55
+
56
+ Attributes:
57
+ checkpoint_result: The validation result.
58
+ run_id: Unique run identifier.
59
+ checkpoint_name: Name of the checkpoint.
60
+ tags: Tags from the checkpoint.
61
+ metadata: Additional metadata.
62
+ variables: Custom variables for rule evaluation.
63
+ """
64
+
65
+ checkpoint_result: "CheckpointResult"
66
+ run_id: str
67
+ checkpoint_name: str
68
+ tags: dict[str, str] = field(default_factory=dict)
69
+ metadata: dict[str, Any] = field(default_factory=dict)
70
+ variables: dict[str, Any] = field(default_factory=dict)
71
+
72
+ def to_template_context(self) -> dict[str, Any]:
73
+ """Convert to Jinja2 template context.
74
+
75
+ Returns:
76
+ Dictionary suitable for Jinja2 rendering.
77
+ """
78
+ result = self.checkpoint_result
79
+ return {
80
+ # Result properties
81
+ "status": result.status.value,
82
+ "passed": result.status.value == "success",
83
+ "failed": result.status.value == "failure",
84
+ "has_critical": getattr(result, "has_critical", False),
85
+ "has_high": getattr(result, "has_high", False),
86
+ "issue_count": getattr(result, "issue_count", 0),
87
+ "critical_count": getattr(result, "critical_count", 0),
88
+ "high_count": getattr(result, "high_count", 0),
89
+ "medium_count": getattr(result, "medium_count", 0),
90
+ "low_count": getattr(result, "low_count", 0),
91
+ "row_count": getattr(result, "row_count", 0),
92
+ "column_count": getattr(result, "column_count", 0),
93
+ # Context properties
94
+ "run_id": self.run_id,
95
+ "checkpoint_name": self.checkpoint_name,
96
+ "tags": self.tags,
97
+ "metadata": self.metadata,
98
+ # Custom variables
99
+ **self.variables,
100
+ }
101
+
102
+
103
+ @runtime_checkable
104
+ class RoutingRuleProtocol(Protocol):
105
+ """Protocol for routing rule implementations.
106
+
107
+ Routing rules evaluate checkpoint results and return True
108
+ if the associated actions should be executed.
109
+
110
+ Example:
111
+ class SeverityRule:
112
+ def __init__(self, min_severity: str):
113
+ self.min_severity = min_severity
114
+
115
+ def evaluate(self, context: RouteContext) -> bool:
116
+ return context.checkpoint_result.has_severity(self.min_severity)
117
+ """
118
+
119
+ @property
120
+ def name(self) -> str:
121
+ """Get rule name."""
122
+ ...
123
+
124
+ @property
125
+ def expression(self) -> str:
126
+ """Get the rule expression (for Jinja2 rules)."""
127
+ ...
128
+
129
+ def evaluate(self, context: RouteContext) -> bool:
130
+ """Evaluate the rule against the context.
131
+
132
+ Args:
133
+ context: Routing context with checkpoint result.
134
+
135
+ Returns:
136
+ True if rule matches (actions should execute).
137
+ """
138
+ ...
139
+
140
+
141
+ class BaseRoutingRule(ABC):
142
+ """Abstract base class for routing rules.
143
+
144
+ Provides common functionality for all routing rules.
145
+ Subclasses must implement the evaluate method.
146
+ """
147
+
148
+ def __init__(
149
+ self,
150
+ name: str = "",
151
+ description: str = "",
152
+ ) -> None:
153
+ """Initialize rule.
154
+
155
+ Args:
156
+ name: Rule name.
157
+ description: Rule description.
158
+ """
159
+ self._name = name or self.__class__.__name__
160
+ self._description = description
161
+
162
+ @property
163
+ def name(self) -> str:
164
+ """Get rule name."""
165
+ return self._name
166
+
167
+ @property
168
+ def expression(self) -> str:
169
+ """Get the rule expression."""
170
+ return ""
171
+
172
+ @property
173
+ def description(self) -> str:
174
+ """Get rule description."""
175
+ return self._description
176
+
177
+ @abstractmethod
178
+ def evaluate(self, context: RouteContext) -> bool:
179
+ """Evaluate the rule."""
180
+ ...
181
+
182
+
183
+ class Jinja2Rule(BaseRoutingRule):
184
+ """Jinja2 expression-based routing rule.
185
+
186
+ Evaluates a Jinja2 expression against the routing context.
187
+ The expression should evaluate to a boolean.
188
+
189
+ Example:
190
+ rule = Jinja2Rule("critical_alert", "has_critical or critical_count > 0")
191
+ if rule.evaluate(context):
192
+ # Send critical alert
193
+ """
194
+
195
+ def __init__(
196
+ self,
197
+ name: str,
198
+ expression: str,
199
+ description: str = "",
200
+ ) -> None:
201
+ """Initialize Jinja2 rule.
202
+
203
+ Args:
204
+ name: Rule name.
205
+ expression: Jinja2 expression that evaluates to boolean.
206
+ description: Rule description.
207
+ """
208
+ super().__init__(name=name, description=description)
209
+ self._expression = expression
210
+ self._compiled: Any | None = None
211
+
212
+ @property
213
+ def expression(self) -> str:
214
+ """Get the Jinja2 expression."""
215
+ return self._expression
216
+
217
+ def evaluate(self, context: RouteContext) -> bool:
218
+ """Evaluate the Jinja2 expression.
219
+
220
+ Args:
221
+ context: Routing context.
222
+
223
+ Returns:
224
+ True if expression evaluates to truthy.
225
+ """
226
+ try:
227
+ from jinja2 import Environment
228
+ except ImportError:
229
+ # Fallback to simple eval for basic expressions
230
+ return self._fallback_evaluate(context)
231
+
232
+ env = Environment()
233
+ template_str = "{{ " + self._expression + " }}"
234
+ template = env.from_string(template_str)
235
+ result = template.render(**context.to_template_context())
236
+ return result.lower() in ("true", "1", "yes")
237
+
238
+ def _fallback_evaluate(self, context: RouteContext) -> bool:
239
+ """Fallback evaluation without Jinja2."""
240
+ ctx = context.to_template_context()
241
+
242
+ # Handle simple expressions
243
+ expr = self._expression.strip()
244
+
245
+ # Direct variable lookup
246
+ if expr in ctx:
247
+ return bool(ctx[expr])
248
+
249
+ # Simple comparisons
250
+ for op in [" > ", " >= ", " < ", " <= ", " == ", " != "]:
251
+ if op in expr:
252
+ left, right = expr.split(op, 1)
253
+ left_val = ctx.get(left.strip(), left.strip())
254
+ right_val = ctx.get(right.strip(), right.strip())
255
+
256
+ try:
257
+ left_val = float(left_val) if not isinstance(left_val, bool) else left_val
258
+ right_val = float(right_val) if not isinstance(right_val, bool) else right_val
259
+ except (ValueError, TypeError):
260
+ pass
261
+
262
+ if op == " > ":
263
+ return left_val > right_val
264
+ elif op == " >= ":
265
+ return left_val >= right_val
266
+ elif op == " < ":
267
+ return left_val < right_val
268
+ elif op == " <= ":
269
+ return left_val <= right_val
270
+ elif op == " == ":
271
+ return left_val == right_val
272
+ elif op == " != ":
273
+ return left_val != right_val
274
+
275
+ # Boolean operations
276
+ if " or " in expr:
277
+ parts = expr.split(" or ")
278
+ return any(bool(ctx.get(p.strip(), False)) for p in parts)
279
+
280
+ if " and " in expr:
281
+ parts = expr.split(" and ")
282
+ return all(bool(ctx.get(p.strip(), False)) for p in parts)
283
+
284
+ return False
285
+
286
+
287
+ class AllOf(BaseRoutingRule):
288
+ """Compound rule that matches when ALL child rules match.
289
+
290
+ Example:
291
+ rule = AllOf([
292
+ Jinja2Rule("critical", "has_critical"),
293
+ Jinja2Rule("high_count", "high_count > 10"),
294
+ ])
295
+ """
296
+
297
+ def __init__(
298
+ self,
299
+ rules: list[BaseRoutingRule],
300
+ name: str = "",
301
+ description: str = "",
302
+ ) -> None:
303
+ """Initialize AllOf rule.
304
+
305
+ Args:
306
+ rules: Child rules that must all match.
307
+ name: Rule name.
308
+ description: Rule description.
309
+ """
310
+ super().__init__(name=name or "AllOf", description=description)
311
+ self._rules = rules
312
+
313
+ @property
314
+ def rules(self) -> list[BaseRoutingRule]:
315
+ """Get child rules."""
316
+ return self._rules
317
+
318
+ @property
319
+ def expression(self) -> str:
320
+ """Get combined expression."""
321
+ exprs = [r.expression for r in self._rules if r.expression]
322
+ return " and ".join(f"({e})" for e in exprs)
323
+
324
+ def evaluate(self, context: RouteContext) -> bool:
325
+ """Evaluate all child rules.
326
+
327
+ Returns True only if all rules match.
328
+ """
329
+ return all(rule.evaluate(context) for rule in self._rules)
330
+
331
+
332
+ class AnyOf(BaseRoutingRule):
333
+ """Compound rule that matches when ANY child rule matches.
334
+
335
+ Example:
336
+ rule = AnyOf([
337
+ Jinja2Rule("critical", "has_critical"),
338
+ Jinja2Rule("error", "status == 'error'"),
339
+ ])
340
+ """
341
+
342
+ def __init__(
343
+ self,
344
+ rules: list[BaseRoutingRule],
345
+ name: str = "",
346
+ description: str = "",
347
+ ) -> None:
348
+ """Initialize AnyOf rule.
349
+
350
+ Args:
351
+ rules: Child rules (any one can match).
352
+ name: Rule name.
353
+ description: Rule description.
354
+ """
355
+ super().__init__(name=name or "AnyOf", description=description)
356
+ self._rules = rules
357
+
358
+ @property
359
+ def rules(self) -> list[BaseRoutingRule]:
360
+ """Get child rules."""
361
+ return self._rules
362
+
363
+ @property
364
+ def expression(self) -> str:
365
+ """Get combined expression."""
366
+ exprs = [r.expression for r in self._rules if r.expression]
367
+ return " or ".join(f"({e})" for e in exprs)
368
+
369
+ def evaluate(self, context: RouteContext) -> bool:
370
+ """Evaluate all child rules.
371
+
372
+ Returns True if any rule matches.
373
+ """
374
+ return any(rule.evaluate(context) for rule in self._rules)
375
+
376
+
377
+ class NotRule(BaseRoutingRule):
378
+ """Compound rule that inverts another rule.
379
+
380
+ Example:
381
+ rule = NotRule(Jinja2Rule("success", "passed"))
382
+ # Matches when validation did NOT pass
383
+ """
384
+
385
+ def __init__(
386
+ self,
387
+ rule: BaseRoutingRule,
388
+ name: str = "",
389
+ description: str = "",
390
+ ) -> None:
391
+ """Initialize Not rule.
392
+
393
+ Args:
394
+ rule: Rule to invert.
395
+ name: Rule name.
396
+ description: Rule description.
397
+ """
398
+ super().__init__(name=name or "Not", description=description)
399
+ self._rule = rule
400
+
401
+ @property
402
+ def rule(self) -> BaseRoutingRule:
403
+ """Get the inverted rule."""
404
+ return self._rule
405
+
406
+ @property
407
+ def expression(self) -> str:
408
+ """Get negated expression."""
409
+ return f"not ({self._rule.expression})"
410
+
411
+ def evaluate(self, context: RouteContext) -> bool:
412
+ """Evaluate the inverted rule.
413
+
414
+ Returns True if the child rule does NOT match.
415
+ """
416
+ return not self._rule.evaluate(context)
417
+
418
+
419
+ class AlwaysRule(BaseRoutingRule):
420
+ """Rule that always matches."""
421
+
422
+ def __init__(self, name: str = "always") -> None:
423
+ super().__init__(name=name, description="Always matches")
424
+
425
+ @property
426
+ def expression(self) -> str:
427
+ return "True"
428
+
429
+ def evaluate(self, context: RouteContext) -> bool:
430
+ return True
431
+
432
+
433
+ class NeverRule(BaseRoutingRule):
434
+ """Rule that never matches."""
435
+
436
+ def __init__(self, name: str = "never") -> None:
437
+ super().__init__(name=name, description="Never matches")
438
+
439
+ @property
440
+ def expression(self) -> str:
441
+ return "False"
442
+
443
+ def evaluate(self, context: RouteContext) -> bool:
444
+ return False
445
+
446
+
447
+ # =============================================================================
448
+ # Route Definition
449
+ # =============================================================================
450
+
451
+
452
+ @dataclass
453
+ class Route:
454
+ """A route defines a rule and its associated actions.
455
+
456
+ When the rule matches, the actions are executed.
457
+
458
+ Attributes:
459
+ name: Route name for identification.
460
+ rule: Routing rule to evaluate.
461
+ actions: Actions to execute when rule matches.
462
+ priority: Priority for route ordering.
463
+ enabled: Whether this route is enabled.
464
+ metadata: Additional metadata.
465
+ stop_on_match: Stop evaluating other routes after this one matches.
466
+ """
467
+
468
+ name: str
469
+ rule: BaseRoutingRule
470
+ actions: list[str] # Action names
471
+ priority: RoutePriority = RoutePriority.DEFAULT
472
+ enabled: bool = True
473
+ metadata: dict[str, Any] = field(default_factory=dict)
474
+ stop_on_match: bool = False
475
+
476
+ def evaluate(self, context: RouteContext) -> bool:
477
+ """Evaluate the route's rule.
478
+
479
+ Args:
480
+ context: Routing context.
481
+
482
+ Returns:
483
+ True if route matches.
484
+ """
485
+ if not self.enabled:
486
+ return False
487
+ return self.rule.evaluate(context)
488
+
489
+ def to_dict(self) -> dict[str, Any]:
490
+ """Convert to dictionary."""
491
+ return {
492
+ "name": self.name,
493
+ "rule_expression": self.rule.expression,
494
+ "actions": self.actions,
495
+ "priority": self.priority.value,
496
+ "enabled": self.enabled,
497
+ "metadata": self.metadata,
498
+ "stop_on_match": self.stop_on_match,
499
+ }
500
+
501
+
502
+ # =============================================================================
503
+ # Router Protocol
504
+ # =============================================================================
505
+
506
+
507
+ @runtime_checkable
508
+ class RouterProtocol(Protocol):
509
+ """Protocol for router implementations.
510
+
511
+ Routers evaluate routes and determine which actions to execute.
512
+
513
+ Example:
514
+ router = Router(mode=RouteMode.FIRST_MATCH)
515
+ router.add_route(Route(
516
+ name="critical_alert",
517
+ rule=Jinja2Rule("critical", "has_critical"),
518
+ actions=["pagerduty", "slack_critical"],
519
+ ))
520
+
521
+ matched_actions = router.route(context)
522
+ """
523
+
524
+ @property
525
+ def mode(self) -> RouteMode:
526
+ """Get routing mode."""
527
+ ...
528
+
529
+ @property
530
+ def routes(self) -> list[Route]:
531
+ """Get all routes."""
532
+ ...
533
+
534
+ def add_route(self, route: Route) -> None:
535
+ """Add a route to the router.
536
+
537
+ Args:
538
+ route: Route to add.
539
+ """
540
+ ...
541
+
542
+ def remove_route(self, name: str) -> bool:
543
+ """Remove a route by name.
544
+
545
+ Args:
546
+ name: Route name.
547
+
548
+ Returns:
549
+ True if route was removed.
550
+ """
551
+ ...
552
+
553
+ def route(self, context: RouteContext) -> list[str]:
554
+ """Evaluate routes and return matching action names.
555
+
556
+ Args:
557
+ context: Routing context.
558
+
559
+ Returns:
560
+ List of action names to execute.
561
+ """
562
+ ...
563
+
564
+
565
+ class Router:
566
+ """Default router implementation.
567
+
568
+ Evaluates routes based on the configured mode and returns
569
+ the actions that should be executed.
570
+ """
571
+
572
+ def __init__(
573
+ self,
574
+ mode: RouteMode = RouteMode.ALL_MATCHES,
575
+ routes: list[Route] | None = None,
576
+ ) -> None:
577
+ """Initialize router.
578
+
579
+ Args:
580
+ mode: Routing mode.
581
+ routes: Initial routes.
582
+ """
583
+ self._mode = mode
584
+ self._routes: list[Route] = routes or []
585
+
586
+ @property
587
+ def mode(self) -> RouteMode:
588
+ """Get routing mode."""
589
+ return self._mode
590
+
591
+ @property
592
+ def routes(self) -> list[Route]:
593
+ """Get all routes."""
594
+ return self._routes.copy()
595
+
596
+ def add_route(self, route: Route) -> None:
597
+ """Add a route."""
598
+ self._routes.append(route)
599
+ # Re-sort by priority if in priority mode
600
+ if self._mode == RouteMode.PRIORITY:
601
+ self._routes.sort(key=lambda r: r.priority.value, reverse=True)
602
+
603
+ def remove_route(self, name: str) -> bool:
604
+ """Remove a route by name."""
605
+ for i, route in enumerate(self._routes):
606
+ if route.name == name:
607
+ del self._routes[i]
608
+ return True
609
+ return False
610
+
611
+ def route(self, context: RouteContext) -> list[str]:
612
+ """Evaluate routes and return matching action names.
613
+
614
+ Args:
615
+ context: Routing context.
616
+
617
+ Returns:
618
+ List of unique action names to execute.
619
+ """
620
+ actions: list[str] = []
621
+ seen: set[str] = set()
622
+
623
+ # Sort routes if in priority mode
624
+ routes = self._routes
625
+ if self._mode == RouteMode.PRIORITY:
626
+ routes = sorted(routes, key=lambda r: r.priority.value, reverse=True)
627
+
628
+ for route in routes:
629
+ if route.evaluate(context):
630
+ for action in route.actions:
631
+ if action not in seen:
632
+ actions.append(action)
633
+ seen.add(action)
634
+
635
+ # Stop if configured or first match mode
636
+ if route.stop_on_match or self._mode == RouteMode.FIRST_MATCH:
637
+ break
638
+
639
+ return actions
640
+
641
+ def to_dict(self) -> dict[str, Any]:
642
+ """Convert to dictionary."""
643
+ return {
644
+ "mode": self._mode.value,
645
+ "routes": [r.to_dict() for r in self._routes],
646
+ }