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,842 @@
1
+ """Truthound library integration adapter for notifications.
2
+
3
+ This module provides an adapter that bridges the dashboard's notification
4
+ system with the truthound library's checkpoint features:
5
+ - Routing (ActionRouter)
6
+ - Deduplication (NotificationDeduplicator)
7
+ - Throttling (NotificationThrottler)
8
+ - Escalation (EscalationEngine)
9
+
10
+ The adapter loads configurations from the database and constructs
11
+ truthound library objects to process notifications.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import logging
17
+ from concurrent.futures import ThreadPoolExecutor
18
+ from dataclasses import dataclass, field
19
+ from datetime import datetime
20
+ from functools import lru_cache
21
+ from typing import TYPE_CHECKING, Any
22
+
23
+ from sqlalchemy import select
24
+ from sqlalchemy.ext.asyncio import AsyncSession
25
+
26
+ # Truthound library imports
27
+ from truthound.checkpoint.deduplication import (
28
+ DeduplicationConfig as TruthoundDeduplicationConfig,
29
+ DeduplicationPolicy,
30
+ InMemoryDeduplicationStore,
31
+ NotificationDeduplicator,
32
+ NotificationFingerprint,
33
+ TimeWindow,
34
+ )
35
+ from truthound.checkpoint.escalation import (
36
+ EscalationEngine,
37
+ EscalationEngineConfig,
38
+ EscalationLevel,
39
+ EscalationPolicy,
40
+ EscalationTarget,
41
+ EscalationTrigger,
42
+ TargetType,
43
+ )
44
+ from truthound.checkpoint.routing import ActionRouter, AllOf, AnyOf, NotRule, Route
45
+ from truthound.checkpoint.routing.base import RouteContext, RouteMode, RoutePriority
46
+ from truthound.checkpoint.routing.rules import (
47
+ AlwaysRule,
48
+ DataAssetRule,
49
+ ErrorRule,
50
+ IssueCountRule,
51
+ MetadataRule,
52
+ NeverRule,
53
+ PassRateRule,
54
+ SeverityRule,
55
+ StatusRule,
56
+ TagRule,
57
+ TimeWindowRule,
58
+ )
59
+ from truthound.checkpoint.throttling import (
60
+ NotificationThrottler,
61
+ RateLimit,
62
+ RateLimitScope,
63
+ ThrottlerBuilder,
64
+ ThrottlingConfig as TruthoundThrottlingConfig,
65
+ )
66
+
67
+ if TYPE_CHECKING:
68
+ from truthound_dashboard.db.models import (
69
+ DeduplicationConfig,
70
+ EscalationIncidentModel,
71
+ EscalationPolicyModel,
72
+ RoutingRuleModel,
73
+ ThrottlingConfig,
74
+ )
75
+
76
+ from .base import NotificationEvent
77
+ from .events import ValidationFailedEvent, ScheduleFailedEvent, DriftDetectedEvent
78
+
79
+ logger = logging.getLogger(__name__)
80
+
81
+ # Thread pool for running synchronous truthound operations
82
+ _executor = ThreadPoolExecutor(max_workers=4, thread_name_prefix="truthound_notif_")
83
+
84
+
85
+ @dataclass
86
+ class TruthoundStats:
87
+ """Aggregated stats from truthound library components."""
88
+
89
+ routing: dict[str, Any] = field(default_factory=dict)
90
+ deduplication: dict[str, Any] = field(default_factory=dict)
91
+ throttling: dict[str, Any] = field(default_factory=dict)
92
+ escalation: dict[str, Any] = field(default_factory=dict)
93
+
94
+
95
+ class TruthoundNotificationAdapter:
96
+ """Adapter for integrating truthound library notification features.
97
+
98
+ This adapter:
99
+ 1. Loads routing rules from DB and constructs truthound's ActionRouter
100
+ 2. Loads deduplication config from DB and constructs NotificationDeduplicator
101
+ 3. Loads throttling config from DB and constructs NotificationThrottler
102
+ 4. Loads escalation policies from DB and constructs EscalationEngine
103
+
104
+ Usage:
105
+ adapter = TruthoundNotificationAdapter(session)
106
+ await adapter.initialize()
107
+
108
+ # Check if notification should be sent
109
+ if await adapter.should_send_notification(event, channel_id):
110
+ # Send notification
111
+ await adapter.mark_notification_sent(event, channel_id)
112
+ """
113
+
114
+ def __init__(self, session: AsyncSession) -> None:
115
+ """Initialize the adapter.
116
+
117
+ Args:
118
+ session: Database session for loading configurations.
119
+ """
120
+ self.session = session
121
+ self._router: ActionRouter | None = None
122
+ self._deduplicator: NotificationDeduplicator | None = None
123
+ self._throttler: NotificationThrottler | None = None
124
+ self._escalation_engine: EscalationEngine | None = None
125
+ self._initialized = False
126
+
127
+ async def initialize(self) -> None:
128
+ """Initialize all truthound components from database configs."""
129
+ if self._initialized:
130
+ return
131
+
132
+ try:
133
+ await self._build_router()
134
+ await self._build_deduplicator()
135
+ await self._build_throttler()
136
+ await self._build_escalation_engine()
137
+ self._initialized = True
138
+ logger.info("TruthoundNotificationAdapter initialized successfully")
139
+ except Exception as e:
140
+ logger.error(f"Failed to initialize TruthoundNotificationAdapter: {e}")
141
+ raise
142
+
143
+ # =========================================================================
144
+ # Router (Routing Rules)
145
+ # =========================================================================
146
+
147
+ async def _build_router(self) -> None:
148
+ """Build ActionRouter from database routing rules."""
149
+ from truthound_dashboard.db.models import RoutingRuleModel
150
+
151
+ result = await self.session.execute(
152
+ select(RoutingRuleModel)
153
+ .where(RoutingRuleModel.is_active == True)
154
+ .order_by(RoutingRuleModel.priority.desc())
155
+ )
156
+ rules = result.scalars().all()
157
+
158
+ self._router = ActionRouter(mode=RouteMode.ALL_MATCHES)
159
+
160
+ for rule_model in rules:
161
+ try:
162
+ truthound_rule = self._build_rule_from_config(rule_model.rule_config)
163
+ route = Route(
164
+ name=rule_model.name,
165
+ rule=truthound_rule,
166
+ actions=rule_model.actions, # Channel IDs
167
+ priority=rule_model.priority,
168
+ )
169
+ self._router.add_route(route)
170
+ logger.debug(f"Added route: {rule_model.name}")
171
+ except Exception as e:
172
+ logger.warning(f"Failed to build route '{rule_model.name}': {e}")
173
+
174
+ logger.info(f"Built router with {len(rules)} routes")
175
+
176
+ def _build_rule_from_config(self, config: dict[str, Any]) -> Any:
177
+ """Convert rule_config JSON to truthound Rule object.
178
+
179
+ Args:
180
+ config: Rule configuration dictionary.
181
+
182
+ Returns:
183
+ Truthound Rule object.
184
+
185
+ Raises:
186
+ ValueError: If rule type is unknown.
187
+ """
188
+ rule_type = config.get("type", "").lower()
189
+
190
+ # Basic rules
191
+ if rule_type == "always":
192
+ return AlwaysRule()
193
+ elif rule_type == "never":
194
+ return NeverRule()
195
+
196
+ # Severity rule
197
+ elif rule_type == "severity":
198
+ return SeverityRule(
199
+ min_severity=config.get("min_severity", "low"),
200
+ max_severity=config.get("max_severity"),
201
+ min_count=config.get("min_count"),
202
+ exact_count=config.get("exact_count"),
203
+ )
204
+
205
+ # Issue count rule
206
+ elif rule_type == "issue_count":
207
+ return IssueCountRule(
208
+ min_issues=config.get("min_issues"),
209
+ max_issues=config.get("max_issues"),
210
+ count_type=config.get("count_type", "total"),
211
+ )
212
+
213
+ # Status rule
214
+ elif rule_type == "status":
215
+ return StatusRule(
216
+ statuses=config.get("statuses", []),
217
+ negate=config.get("negate", False),
218
+ )
219
+
220
+ # Tag rule
221
+ elif rule_type == "tag":
222
+ return TagRule(
223
+ tags=config.get("tags", {}),
224
+ match_all=config.get("match_all", True),
225
+ negate=config.get("negate", False),
226
+ )
227
+
228
+ # Data asset rule
229
+ elif rule_type == "data_asset":
230
+ return DataAssetRule(
231
+ pattern=config.get("pattern", "*"),
232
+ is_regex=config.get("is_regex", False),
233
+ case_sensitive=config.get("case_sensitive", True),
234
+ )
235
+
236
+ # Metadata rule
237
+ elif rule_type == "metadata":
238
+ return MetadataRule(
239
+ key_path=config.get("key_path", ""),
240
+ expected_value=config.get("expected_value"),
241
+ comparator=config.get("comparator", "eq"),
242
+ )
243
+
244
+ # Time window rule
245
+ elif rule_type == "time_window":
246
+ return TimeWindowRule(
247
+ start_time=config.get("start_time"),
248
+ end_time=config.get("end_time"),
249
+ days_of_week=config.get("days_of_week"),
250
+ timezone=config.get("timezone", "UTC"),
251
+ )
252
+
253
+ # Pass rate rule
254
+ elif rule_type == "pass_rate":
255
+ return PassRateRule(
256
+ min_rate=config.get("min_rate"),
257
+ max_rate=config.get("max_rate"),
258
+ )
259
+
260
+ # Error rule
261
+ elif rule_type == "error":
262
+ return ErrorRule(
263
+ pattern=config.get("pattern"),
264
+ negate=config.get("negate", False),
265
+ )
266
+
267
+ # Combinators
268
+ elif rule_type == "all_of":
269
+ sub_rules = [
270
+ self._build_rule_from_config(r)
271
+ for r in config.get("rules", [])
272
+ ]
273
+ return AllOf(sub_rules)
274
+
275
+ elif rule_type == "any_of":
276
+ sub_rules = [
277
+ self._build_rule_from_config(r)
278
+ for r in config.get("rules", [])
279
+ ]
280
+ return AnyOf(sub_rules)
281
+
282
+ elif rule_type == "not":
283
+ inner_rule = self._build_rule_from_config(config.get("rule", {}))
284
+ return NotRule(inner_rule)
285
+
286
+ else:
287
+ raise ValueError(f"Unknown rule type: {rule_type}")
288
+
289
+ async def match_routes(self, event: NotificationEvent) -> list[str]:
290
+ """Match event against routing rules and return channel IDs.
291
+
292
+ Args:
293
+ event: Notification event to match.
294
+
295
+ Returns:
296
+ List of channel IDs from matching routes.
297
+ """
298
+ if not self._router:
299
+ await self._build_router()
300
+
301
+ # Build RouteContext from event
302
+ context = self._build_route_context(event)
303
+
304
+ # Match routes
305
+ matched_channels: set[str] = set()
306
+ for route in self._router.routes:
307
+ try:
308
+ if route.rule.matches(context):
309
+ matched_channels.update(route.actions)
310
+ logger.debug(f"Route '{route.name}' matched, channels: {route.actions}")
311
+ except Exception as e:
312
+ logger.warning(f"Error matching route '{route.name}': {e}")
313
+
314
+ return list(matched_channels)
315
+
316
+ def _build_route_context(self, event: NotificationEvent) -> RouteContext:
317
+ """Build RouteContext from NotificationEvent."""
318
+ # Extract data from different event types
319
+ total_issues = 0
320
+ critical_issues = 0
321
+ high_issues = 0
322
+ medium_issues = 0
323
+ low_issues = 0
324
+ info_issues = 0
325
+ status = "unknown"
326
+ data_asset = ""
327
+ tags: dict[str, str] = {}
328
+ metadata: dict[str, Any] = {}
329
+
330
+ if isinstance(event, (ValidationFailedEvent, ScheduleFailedEvent)):
331
+ total_issues = getattr(event, "total_issues", 0)
332
+ if getattr(event, "has_critical", False):
333
+ critical_issues = 1
334
+ if getattr(event, "has_high", False):
335
+ high_issues = 1
336
+ status = "failure"
337
+ data_asset = getattr(event, "source_name", "")
338
+
339
+ elif isinstance(event, DriftDetectedEvent):
340
+ status = "drift_detected"
341
+ data_asset = getattr(event, "source_name", "")
342
+ metadata["drift_percentage"] = getattr(event, "drift_percentage", 0)
343
+
344
+ return RouteContext(
345
+ checkpoint_name=event.event_type,
346
+ run_id=getattr(event, "validation_id", str(id(event))),
347
+ status=status,
348
+ data_asset=data_asset,
349
+ run_time=event.timestamp,
350
+ total_issues=total_issues,
351
+ critical_issues=critical_issues,
352
+ high_issues=high_issues,
353
+ medium_issues=medium_issues,
354
+ low_issues=low_issues,
355
+ info_issues=info_issues,
356
+ tags=tags,
357
+ metadata=metadata,
358
+ )
359
+
360
+ # =========================================================================
361
+ # Deduplication
362
+ # =========================================================================
363
+
364
+ async def _build_deduplicator(self) -> None:
365
+ """Build NotificationDeduplicator from database config."""
366
+ from truthound_dashboard.db.models import DeduplicationConfig
367
+
368
+ result = await self.session.execute(
369
+ select(DeduplicationConfig).where(DeduplicationConfig.is_active == True)
370
+ )
371
+ config = result.scalar_one_or_none()
372
+
373
+ if config:
374
+ # Map dashboard policy to truthound policy
375
+ policy_map = {
376
+ "basic": DeduplicationPolicy.BASIC,
377
+ "severity": DeduplicationPolicy.SEVERITY,
378
+ "issue_based": DeduplicationPolicy.ISSUE_BASED,
379
+ "strict": DeduplicationPolicy.STRICT,
380
+ "none": DeduplicationPolicy.NONE,
381
+ }
382
+ policy = policy_map.get(config.policy, DeduplicationPolicy.BASIC)
383
+
384
+ truthound_config = TruthoundDeduplicationConfig(
385
+ enabled=True,
386
+ policy=policy,
387
+ default_window=TimeWindow(seconds=config.window_seconds),
388
+ )
389
+
390
+ self._deduplicator = NotificationDeduplicator(
391
+ store=InMemoryDeduplicationStore(),
392
+ config=truthound_config,
393
+ )
394
+ logger.info(
395
+ f"Built deduplicator with policy={config.policy}, "
396
+ f"window={config.window_seconds}s"
397
+ )
398
+ else:
399
+ # Default deduplicator
400
+ self._deduplicator = NotificationDeduplicator(
401
+ store=InMemoryDeduplicationStore(),
402
+ config=TruthoundDeduplicationConfig(
403
+ enabled=True,
404
+ policy=DeduplicationPolicy.BASIC,
405
+ default_window=TimeWindow(minutes=5),
406
+ ),
407
+ )
408
+ logger.info("Built default deduplicator (no active config found)")
409
+
410
+ async def is_duplicate(
411
+ self,
412
+ event: NotificationEvent,
413
+ channel_id: str,
414
+ ) -> bool:
415
+ """Check if notification is a duplicate.
416
+
417
+ Args:
418
+ event: Notification event.
419
+ channel_id: Target channel ID.
420
+
421
+ Returns:
422
+ True if duplicate (should be suppressed), False otherwise.
423
+ """
424
+ if not self._deduplicator:
425
+ await self._build_deduplicator()
426
+
427
+ # Generate fingerprint
428
+ fingerprint = NotificationFingerprint.generate(
429
+ checkpoint_name=event.event_type,
430
+ action_type=channel_id,
431
+ severity=self._get_event_severity(event),
432
+ data_asset=getattr(event, "source_name", ""),
433
+ )
434
+
435
+ # Check using the deduplicator's is_duplicate method
436
+ return self._deduplicator.is_duplicate_fingerprint(fingerprint)
437
+
438
+ async def mark_notification_sent(
439
+ self,
440
+ event: NotificationEvent,
441
+ channel_id: str,
442
+ ) -> None:
443
+ """Mark notification as sent for deduplication tracking.
444
+
445
+ Args:
446
+ event: Notification event.
447
+ channel_id: Target channel ID.
448
+ """
449
+ if not self._deduplicator:
450
+ return
451
+
452
+ fingerprint = NotificationFingerprint.generate(
453
+ checkpoint_name=event.event_type,
454
+ action_type=channel_id,
455
+ severity=self._get_event_severity(event),
456
+ data_asset=getattr(event, "source_name", ""),
457
+ )
458
+ self._deduplicator.mark_sent(fingerprint)
459
+
460
+ def _get_event_severity(self, event: NotificationEvent) -> str:
461
+ """Extract severity from event."""
462
+ if hasattr(event, "has_critical") and event.has_critical:
463
+ return "critical"
464
+ elif hasattr(event, "has_high") and event.has_high:
465
+ return "high"
466
+ return "medium"
467
+
468
+ # =========================================================================
469
+ # Throttling
470
+ # =========================================================================
471
+
472
+ async def _build_throttler(self) -> None:
473
+ """Build NotificationThrottler from database config."""
474
+ from truthound_dashboard.db.models import ThrottlingConfig
475
+
476
+ result = await self.session.execute(
477
+ select(ThrottlingConfig).where(ThrottlingConfig.is_active == True)
478
+ )
479
+ configs = result.scalars().all()
480
+
481
+ builder = ThrottlerBuilder()
482
+
483
+ # Apply first active global config (channel_id is None)
484
+ global_config = next((c for c in configs if c.channel_id is None), None)
485
+ if global_config:
486
+ if global_config.per_minute:
487
+ builder.with_per_minute_limit(global_config.per_minute)
488
+ if global_config.per_hour:
489
+ builder.with_per_hour_limit(global_config.per_hour)
490
+ if global_config.per_day:
491
+ builder.with_per_day_limit(global_config.per_day)
492
+ builder.with_burst_allowance(global_config.burst_allowance)
493
+ logger.info(
494
+ f"Built throttler with per_minute={global_config.per_minute}, "
495
+ f"per_hour={global_config.per_hour}, per_day={global_config.per_day}"
496
+ )
497
+ else:
498
+ # Default throttling
499
+ builder.with_per_minute_limit(10)
500
+ builder.with_per_hour_limit(100)
501
+ builder.with_per_day_limit(500)
502
+ logger.info("Built default throttler (no active config found)")
503
+
504
+ builder.with_algorithm("token_bucket")
505
+ builder.with_scope(RateLimitScope.PER_ACTION)
506
+
507
+ self._throttler = builder.build()
508
+
509
+ async def is_throttled(self, channel_id: str) -> bool:
510
+ """Check if channel is throttled.
511
+
512
+ Args:
513
+ channel_id: Target channel ID.
514
+
515
+ Returns:
516
+ True if throttled (should not send), False otherwise.
517
+ """
518
+ if not self._throttler:
519
+ await self._build_throttler()
520
+
521
+ result = self._throttler.check(
522
+ action_type=channel_id,
523
+ checkpoint_name="notification",
524
+ )
525
+ return not result.allowed
526
+
527
+ async def acquire_throttle_permit(self, channel_id: str) -> bool:
528
+ """Acquire a throttle permit (check and consume).
529
+
530
+ Args:
531
+ channel_id: Target channel ID.
532
+
533
+ Returns:
534
+ True if permit acquired, False if throttled.
535
+ """
536
+ if not self._throttler:
537
+ await self._build_throttler()
538
+
539
+ result = self._throttler.acquire(
540
+ action_type=channel_id,
541
+ checkpoint_name="notification",
542
+ )
543
+ return result.allowed
544
+
545
+ # =========================================================================
546
+ # Escalation
547
+ # =========================================================================
548
+
549
+ async def _build_escalation_engine(self) -> None:
550
+ """Build EscalationEngine from database policies."""
551
+ from truthound_dashboard.db.models import EscalationPolicyModel
552
+
553
+ result = await self.session.execute(
554
+ select(EscalationPolicyModel).where(EscalationPolicyModel.is_active == True)
555
+ )
556
+ policies = result.scalars().all()
557
+
558
+ config = EscalationEngineConfig(
559
+ store_type="memory",
560
+ metrics_enabled=True,
561
+ )
562
+ self._escalation_engine = EscalationEngine(config)
563
+
564
+ for policy_model in policies:
565
+ try:
566
+ truthound_policy = self._build_escalation_policy(policy_model)
567
+ self._escalation_engine.register_policy(truthound_policy)
568
+ logger.debug(f"Registered escalation policy: {policy_model.name}")
569
+ except Exception as e:
570
+ logger.warning(
571
+ f"Failed to build escalation policy '{policy_model.name}': {e}"
572
+ )
573
+
574
+ # Set notification handler (we'll just log for now, actual sending
575
+ # is handled by dispatcher)
576
+ async def notification_handler(record, level, targets):
577
+ logger.info(
578
+ f"Escalation notification: record={record.id}, "
579
+ f"level={level.level}, targets={[t.name for t in targets]}"
580
+ )
581
+ return True
582
+
583
+ self._escalation_engine.set_notification_handler(notification_handler)
584
+ logger.info(f"Built escalation engine with {len(policies)} policies")
585
+
586
+ def _build_escalation_policy(
587
+ self, model: "EscalationPolicyModel"
588
+ ) -> EscalationPolicy:
589
+ """Build truthound EscalationPolicy from database model."""
590
+ levels = []
591
+ for level_config in model.levels:
592
+ targets = []
593
+ for target_config in level_config.get("targets", []):
594
+ target_type = TargetType(target_config.get("type", "user"))
595
+ targets.append(
596
+ EscalationTarget(
597
+ type=target_type,
598
+ identifier=target_config.get("identifier", ""),
599
+ name=target_config.get("name", ""),
600
+ metadata=target_config.get("metadata", {}),
601
+ )
602
+ )
603
+
604
+ levels.append(
605
+ EscalationLevel(
606
+ level=level_config.get("level", 1),
607
+ delay_minutes=level_config.get("delay_minutes", 0),
608
+ targets=targets,
609
+ repeat_count=level_config.get("repeat_count", 0),
610
+ repeat_interval_minutes=level_config.get(
611
+ "repeat_interval_minutes", 5
612
+ ),
613
+ require_ack=level_config.get("require_ack", True),
614
+ )
615
+ )
616
+
617
+ return EscalationPolicy(
618
+ name=model.name,
619
+ description=model.description or "",
620
+ levels=levels,
621
+ enabled=True,
622
+ triggers=[EscalationTrigger.UNACKNOWLEDGED],
623
+ max_escalations=model.max_escalations,
624
+ )
625
+
626
+ async def trigger_escalation(
627
+ self,
628
+ event: NotificationEvent,
629
+ policy_name: str | None = None,
630
+ ) -> str | None:
631
+ """Trigger escalation for an event.
632
+
633
+ Args:
634
+ event: Notification event.
635
+ policy_name: Optional specific policy name, or auto-select.
636
+
637
+ Returns:
638
+ Escalation record ID if triggered, None otherwise.
639
+ """
640
+ if not self._escalation_engine:
641
+ await self._build_escalation_engine()
642
+
643
+ # Determine severity to select policy
644
+ severity = self._get_event_severity(event)
645
+ if not policy_name:
646
+ # Auto-select policy based on severity
647
+ policy_name = f"{severity}_alerts"
648
+
649
+ incident_id = f"{event.event_type}-{getattr(event, 'validation_id', id(event))}"
650
+
651
+ try:
652
+ result = await self._escalation_engine.trigger(
653
+ incident_id=incident_id,
654
+ context={
655
+ "event_type": event.event_type,
656
+ "severity": severity,
657
+ "source_name": getattr(event, "source_name", ""),
658
+ "timestamp": event.timestamp.isoformat(),
659
+ },
660
+ policy_name=policy_name,
661
+ )
662
+ if result.success:
663
+ return result.record.id
664
+ except Exception as e:
665
+ logger.warning(f"Failed to trigger escalation: {e}")
666
+
667
+ return None
668
+
669
+ async def acknowledge_escalation(
670
+ self, record_id: str, actor: str
671
+ ) -> bool:
672
+ """Acknowledge an escalation.
673
+
674
+ Args:
675
+ record_id: Escalation record ID.
676
+ actor: Who is acknowledging.
677
+
678
+ Returns:
679
+ True if successful.
680
+ """
681
+ if not self._escalation_engine:
682
+ return False
683
+
684
+ try:
685
+ result = await self._escalation_engine.acknowledge(
686
+ record_id=record_id,
687
+ acknowledged_by=actor,
688
+ )
689
+ return result.success
690
+ except Exception as e:
691
+ logger.error(f"Failed to acknowledge escalation: {e}")
692
+ return False
693
+
694
+ async def resolve_escalation(
695
+ self, record_id: str, actor: str
696
+ ) -> bool:
697
+ """Resolve an escalation.
698
+
699
+ Args:
700
+ record_id: Escalation record ID.
701
+ actor: Who is resolving.
702
+
703
+ Returns:
704
+ True if successful.
705
+ """
706
+ if not self._escalation_engine:
707
+ return False
708
+
709
+ try:
710
+ result = await self._escalation_engine.resolve(
711
+ record_id=record_id,
712
+ resolved_by=actor,
713
+ )
714
+ return result.success
715
+ except Exception as e:
716
+ logger.error(f"Failed to resolve escalation: {e}")
717
+ return False
718
+
719
+ # =========================================================================
720
+ # Stats
721
+ # =========================================================================
722
+
723
+ def get_stats(self) -> TruthoundStats:
724
+ """Get aggregated stats from all truthound components.
725
+
726
+ Returns:
727
+ TruthoundStats with stats from each component.
728
+ """
729
+ stats = TruthoundStats()
730
+
731
+ # Router stats
732
+ if self._router:
733
+ stats.routing = {
734
+ "total_routes": len(self._router.routes),
735
+ "mode": self._router.mode.value if hasattr(self._router, "mode") else "unknown",
736
+ }
737
+
738
+ # Deduplication stats
739
+ if self._deduplicator:
740
+ try:
741
+ dedup_stats = self._deduplicator.get_stats()
742
+ stats.deduplication = {
743
+ "total_evaluated": getattr(dedup_stats, "total_evaluated", 0),
744
+ "suppressed": getattr(dedup_stats, "suppressed", 0),
745
+ "suppression_ratio": getattr(dedup_stats, "suppression_ratio", 0.0),
746
+ "active_fingerprints": getattr(dedup_stats, "active_fingerprints", 0),
747
+ }
748
+ except Exception as e:
749
+ logger.warning(f"Failed to get deduplication stats: {e}")
750
+
751
+ # Throttling stats
752
+ if self._throttler:
753
+ try:
754
+ throttle_stats = self._throttler.get_stats()
755
+ stats.throttling = {
756
+ "total_checked": getattr(throttle_stats, "total_checked", 0),
757
+ "total_allowed": getattr(throttle_stats, "total_allowed", 0),
758
+ "total_throttled": getattr(throttle_stats, "total_throttled", 0),
759
+ "throttle_rate": getattr(throttle_stats, "throttle_rate", 0.0),
760
+ "allow_rate": getattr(throttle_stats, "allow_rate", 0.0),
761
+ }
762
+ except Exception as e:
763
+ logger.warning(f"Failed to get throttling stats: {e}")
764
+
765
+ # Escalation stats
766
+ if self._escalation_engine:
767
+ try:
768
+ esc_stats = self._escalation_engine.get_stats()
769
+ stats.escalation = {
770
+ "total_escalations": getattr(esc_stats, "total_escalations", 0),
771
+ "active_escalations": getattr(esc_stats, "active_escalations", 0),
772
+ "acknowledged_count": getattr(esc_stats, "acknowledged_count", 0),
773
+ "resolved_count": getattr(esc_stats, "resolved_count", 0),
774
+ "acknowledgment_rate": getattr(esc_stats, "acknowledgment_rate", 0.0),
775
+ "avg_time_to_acknowledge": getattr(
776
+ esc_stats, "avg_time_to_acknowledge_seconds", 0
777
+ ),
778
+ }
779
+ except Exception as e:
780
+ logger.warning(f"Failed to get escalation stats: {e}")
781
+
782
+ return stats
783
+
784
+ # =========================================================================
785
+ # Combined Check
786
+ # =========================================================================
787
+
788
+ async def should_send_notification(
789
+ self,
790
+ event: NotificationEvent,
791
+ channel_id: str,
792
+ ) -> bool:
793
+ """Check if notification should be sent (dedup + throttle).
794
+
795
+ This is a convenience method that checks both deduplication
796
+ and throttling in one call.
797
+
798
+ Args:
799
+ event: Notification event.
800
+ channel_id: Target channel ID.
801
+
802
+ Returns:
803
+ True if notification should be sent.
804
+ """
805
+ # Check deduplication
806
+ if await self.is_duplicate(event, channel_id):
807
+ logger.debug(f"Notification suppressed (duplicate): {event.event_type}")
808
+ return False
809
+
810
+ # Check throttling
811
+ if not await self.acquire_throttle_permit(channel_id):
812
+ logger.debug(f"Notification suppressed (throttled): {channel_id}")
813
+ return False
814
+
815
+ return True
816
+
817
+ async def reload_config(self) -> None:
818
+ """Reload all configurations from database.
819
+
820
+ Call this after configuration changes to rebuild all components.
821
+ """
822
+ self._initialized = False
823
+ self._router = None
824
+ self._deduplicator = None
825
+ self._throttler = None
826
+ # Note: We don't reset escalation engine as it may have active incidents
827
+ await self.initialize()
828
+
829
+
830
+ # Factory function
831
+ async def get_truthound_adapter(session: AsyncSession) -> TruthoundNotificationAdapter:
832
+ """Get an initialized TruthoundNotificationAdapter.
833
+
834
+ Args:
835
+ session: Database session.
836
+
837
+ Returns:
838
+ Initialized adapter.
839
+ """
840
+ adapter = TruthoundNotificationAdapter(session)
841
+ await adapter.initialize()
842
+ return adapter