truthound-dashboard 1.4.4__py3-none-any.whl → 1.5.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 (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 +645 -23
  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 +15 -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.1.dist-info/METADATA +312 -0
  146. {truthound_dashboard-1.4.4.dist-info → truthound_dashboard-1.5.1.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.1.dist-info}/WHEEL +0 -0
  204. {truthound_dashboard-1.4.4.dist-info → truthound_dashboard-1.5.1.dist-info}/entry_points.txt +0 -0
  205. {truthound_dashboard-1.4.4.dist-info → truthound_dashboard-1.5.1.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,910 @@
1
+ """Notification action implementations.
2
+
3
+ Provides actions for sending notifications to various platforms:
4
+ - Slack (via webhook or Bot API)
5
+ - Email (via SMTP or API services)
6
+ - Microsoft Teams
7
+ - Discord
8
+ - Telegram
9
+ - PagerDuty
10
+
11
+ These actions are loosely coupled from truthound and can be used
12
+ independently of the checkpoint system.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import logging
19
+ from dataclasses import dataclass, field
20
+ from datetime import datetime
21
+ from typing import Any
22
+
23
+ from truthound_dashboard.core.interfaces.actions import (
24
+ ActionConfig,
25
+ ActionContext,
26
+ ActionResult,
27
+ ActionStatus,
28
+ BaseAction,
29
+ NotifyCondition,
30
+ register_action,
31
+ )
32
+
33
+ logger = logging.getLogger(__name__)
34
+
35
+
36
+ # =============================================================================
37
+ # Slack Notification
38
+ # =============================================================================
39
+
40
+
41
+ @dataclass
42
+ class SlackNotificationConfig(ActionConfig):
43
+ """Configuration for Slack notifications.
44
+
45
+ Attributes:
46
+ webhook_url: Slack webhook URL.
47
+ channel: Override channel (optional).
48
+ username: Bot username.
49
+ icon_emoji: Bot icon emoji.
50
+ mention_users: Users to mention on failure.
51
+ mention_groups: Groups to mention on failure.
52
+ include_summary: Include issue summary.
53
+ include_details: Include detailed issues.
54
+ max_issues_shown: Max issues to show in message.
55
+ """
56
+
57
+ webhook_url: str = ""
58
+ channel: str | None = None
59
+ username: str = "Truthound Bot"
60
+ icon_emoji: str = ":bar_chart:"
61
+ mention_users: list[str] = field(default_factory=list)
62
+ mention_groups: list[str] = field(default_factory=list)
63
+ include_summary: bool = True
64
+ include_details: bool = True
65
+ max_issues_shown: int = 5
66
+
67
+ def __post_init__(self):
68
+ self.name = self.name or "slack"
69
+
70
+
71
+ @register_action("slack")
72
+ class SlackNotificationAction(BaseAction):
73
+ """Slack notification action via webhook.
74
+
75
+ Sends formatted messages to Slack when validation completes.
76
+ Supports mentions, custom formatting, and rich block layouts.
77
+
78
+ Example:
79
+ action = SlackNotificationAction(
80
+ webhook_url="https://hooks.slack.com/services/...",
81
+ notify_on=NotifyCondition.FAILURE,
82
+ )
83
+ """
84
+
85
+ def __init__(
86
+ self,
87
+ webhook_url: str = "",
88
+ channel: str | None = None,
89
+ username: str = "Truthound Bot",
90
+ notify_on: NotifyCondition = NotifyCondition.FAILURE,
91
+ config: SlackNotificationConfig | dict[str, Any] | None = None,
92
+ **kwargs: Any,
93
+ ) -> None:
94
+ """Initialize Slack action.
95
+
96
+ Args:
97
+ webhook_url: Slack webhook URL.
98
+ channel: Override channel.
99
+ username: Bot username.
100
+ notify_on: When to send notifications.
101
+ config: Full configuration object.
102
+ **kwargs: Additional configuration.
103
+ """
104
+ if config is None:
105
+ config = SlackNotificationConfig(
106
+ webhook_url=webhook_url,
107
+ channel=channel,
108
+ username=username,
109
+ notify_on=notify_on,
110
+ **kwargs,
111
+ )
112
+ elif isinstance(config, dict):
113
+ config = SlackNotificationConfig(**config)
114
+
115
+ super().__init__(config)
116
+ self._slack_config: SlackNotificationConfig = config
117
+
118
+ @property
119
+ def action_type(self) -> str:
120
+ return "notification"
121
+
122
+ def _do_execute(self, context: ActionContext) -> ActionResult:
123
+ """Send Slack notification."""
124
+ import httpx
125
+
126
+ result = context.checkpoint_result
127
+ payload = self._build_payload(context)
128
+
129
+ try:
130
+ with httpx.Client(timeout=self._config.timeout_seconds) as client:
131
+ response = client.post(
132
+ self._slack_config.webhook_url,
133
+ json=payload,
134
+ )
135
+ response.raise_for_status()
136
+
137
+ return ActionResult(
138
+ action_name=self.name,
139
+ action_type=self.action_type,
140
+ status=ActionStatus.SUCCESS,
141
+ message=f"Slack notification sent for {result.checkpoint_name}",
142
+ details={"channel": self._slack_config.channel},
143
+ )
144
+ except httpx.HTTPError as e:
145
+ return ActionResult(
146
+ action_name=self.name,
147
+ action_type=self.action_type,
148
+ status=ActionStatus.FAILURE,
149
+ message=f"Failed to send Slack notification: {str(e)}",
150
+ error=str(e),
151
+ )
152
+
153
+ def _build_payload(self, context: ActionContext) -> dict[str, Any]:
154
+ """Build Slack webhook payload with blocks."""
155
+ result = context.checkpoint_result
156
+ status = result.status.value
157
+
158
+ # Determine color and emoji based on status
159
+ if status == "success":
160
+ color = "#36a64f"
161
+ emoji = ":white_check_mark:"
162
+ elif status in ("failure", "error"):
163
+ color = "#dc3545"
164
+ emoji = ":x:"
165
+ elif status == "warning":
166
+ color = "#ffc107"
167
+ emoji = ":warning:"
168
+ else:
169
+ color = "#6c757d"
170
+ emoji = ":grey_question:"
171
+
172
+ # Build blocks
173
+ blocks = [
174
+ {
175
+ "type": "header",
176
+ "text": {
177
+ "type": "plain_text",
178
+ "text": f"{emoji} Validation {status.title()}: {result.checkpoint_name}",
179
+ },
180
+ },
181
+ {
182
+ "type": "section",
183
+ "fields": [
184
+ {"type": "mrkdwn", "text": f"*Source:*\n{result.source_name}"},
185
+ {"type": "mrkdwn", "text": f"*Status:*\n{status.title()}"},
186
+ {"type": "mrkdwn", "text": f"*Rows:*\n{result.row_count:,}"},
187
+ {"type": "mrkdwn", "text": f"*Issues:*\n{result.issue_count:,}"},
188
+ ],
189
+ },
190
+ ]
191
+
192
+ # Add summary if enabled
193
+ if self._slack_config.include_summary and result.issue_count > 0:
194
+ summary_text = (
195
+ f":red_circle: Critical: {result.critical_count} "
196
+ f":orange_circle: High: {result.high_count} "
197
+ f":yellow_circle: Medium: {result.medium_count} "
198
+ f":white_circle: Low: {result.low_count}"
199
+ )
200
+ blocks.append({
201
+ "type": "section",
202
+ "text": {"type": "mrkdwn", "text": summary_text},
203
+ })
204
+
205
+ # Add issue details if enabled
206
+ if (
207
+ self._slack_config.include_details
208
+ and result.issues
209
+ and result.issue_count > 0
210
+ ):
211
+ issues_text = "*Top Issues:*\n"
212
+ for issue in result.issues[: self._slack_config.max_issues_shown]:
213
+ col = issue.get("column", "N/A")
214
+ issue_type = issue.get("issue_type", "unknown")
215
+ count = issue.get("count", 0)
216
+ issues_text += f"• `{col}`: {issue_type} ({count:,} rows)\n"
217
+
218
+ if result.issue_count > self._slack_config.max_issues_shown:
219
+ issues_text += f"_...and {result.issue_count - self._slack_config.max_issues_shown} more_"
220
+
221
+ blocks.append({
222
+ "type": "section",
223
+ "text": {"type": "mrkdwn", "text": issues_text},
224
+ })
225
+
226
+ # Add mentions for failures
227
+ if status in ("failure", "error") and (
228
+ self._slack_config.mention_users or self._slack_config.mention_groups
229
+ ):
230
+ mentions = []
231
+ for user in self._slack_config.mention_users:
232
+ mentions.append(f"<@{user}>")
233
+ for group in self._slack_config.mention_groups:
234
+ mentions.append(f"<!subteam^{group}>")
235
+
236
+ blocks.append({
237
+ "type": "section",
238
+ "text": {"type": "mrkdwn", "text": " ".join(mentions)},
239
+ })
240
+
241
+ # Add timestamp
242
+ blocks.append({
243
+ "type": "context",
244
+ "elements": [
245
+ {
246
+ "type": "mrkdwn",
247
+ "text": f"Run ID: `{result.run_id}` | {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}",
248
+ }
249
+ ],
250
+ })
251
+
252
+ payload = {
253
+ "username": self._slack_config.username,
254
+ "icon_emoji": self._slack_config.icon_emoji,
255
+ "attachments": [{"color": color, "blocks": blocks}],
256
+ }
257
+
258
+ if self._slack_config.channel:
259
+ payload["channel"] = self._slack_config.channel
260
+
261
+ return payload
262
+
263
+
264
+ # =============================================================================
265
+ # Email Notification
266
+ # =============================================================================
267
+
268
+
269
+ @dataclass
270
+ class EmailNotificationConfig(ActionConfig):
271
+ """Configuration for email notifications.
272
+
273
+ Attributes:
274
+ smtp_host: SMTP server host.
275
+ smtp_port: SMTP server port.
276
+ smtp_username: SMTP username.
277
+ smtp_password: SMTP password.
278
+ smtp_use_tls: Use TLS encryption.
279
+ from_email: Sender email address.
280
+ to_emails: Recipient email addresses.
281
+ cc_emails: CC email addresses.
282
+ subject_template: Email subject template.
283
+ body_template: Email body template (HTML).
284
+ include_attachment: Attach detailed report.
285
+ """
286
+
287
+ smtp_host: str = ""
288
+ smtp_port: int = 587
289
+ smtp_username: str = ""
290
+ smtp_password: str = ""
291
+ smtp_use_tls: bool = True
292
+ from_email: str = ""
293
+ to_emails: list[str] = field(default_factory=list)
294
+ cc_emails: list[str] = field(default_factory=list)
295
+ subject_template: str = "[{status}] Validation: {checkpoint_name}"
296
+ body_template: str = ""
297
+ include_attachment: bool = False
298
+
299
+ def __post_init__(self):
300
+ self.name = self.name or "email"
301
+
302
+
303
+ @register_action("email")
304
+ class EmailNotificationAction(BaseAction):
305
+ """Email notification action via SMTP.
306
+
307
+ Sends formatted HTML emails when validation completes.
308
+ Supports templates, attachments, and CC recipients.
309
+ """
310
+
311
+ def __init__(
312
+ self,
313
+ smtp_host: str = "",
314
+ smtp_port: int = 587,
315
+ from_email: str = "",
316
+ to_emails: list[str] | None = None,
317
+ notify_on: NotifyCondition = NotifyCondition.FAILURE,
318
+ config: EmailNotificationConfig | dict[str, Any] | None = None,
319
+ **kwargs: Any,
320
+ ) -> None:
321
+ """Initialize email action."""
322
+ if config is None:
323
+ config = EmailNotificationConfig(
324
+ smtp_host=smtp_host,
325
+ smtp_port=smtp_port,
326
+ from_email=from_email,
327
+ to_emails=to_emails or [],
328
+ notify_on=notify_on,
329
+ **kwargs,
330
+ )
331
+ elif isinstance(config, dict):
332
+ config = EmailNotificationConfig(**config)
333
+
334
+ super().__init__(config)
335
+ self._email_config: EmailNotificationConfig = config
336
+
337
+ @property
338
+ def action_type(self) -> str:
339
+ return "notification"
340
+
341
+ def _do_execute(self, context: ActionContext) -> ActionResult:
342
+ """Send email notification."""
343
+ import smtplib
344
+ from email.mime.multipart import MIMEMultipart
345
+ from email.mime.text import MIMEText
346
+
347
+ result = context.checkpoint_result
348
+
349
+ # Build subject
350
+ subject = self._email_config.subject_template.format(
351
+ status=result.status.value.upper(),
352
+ checkpoint_name=result.checkpoint_name,
353
+ source_name=result.source_name,
354
+ )
355
+
356
+ # Build body
357
+ body = self._build_html_body(context)
358
+
359
+ try:
360
+ msg = MIMEMultipart("alternative")
361
+ msg["Subject"] = subject
362
+ msg["From"] = self._email_config.from_email
363
+ msg["To"] = ", ".join(self._email_config.to_emails)
364
+ if self._email_config.cc_emails:
365
+ msg["Cc"] = ", ".join(self._email_config.cc_emails)
366
+
367
+ msg.attach(MIMEText(body, "html"))
368
+
369
+ # Connect and send
370
+ with smtplib.SMTP(
371
+ self._email_config.smtp_host,
372
+ self._email_config.smtp_port,
373
+ ) as server:
374
+ if self._email_config.smtp_use_tls:
375
+ server.starttls()
376
+ if self._email_config.smtp_username:
377
+ server.login(
378
+ self._email_config.smtp_username,
379
+ self._email_config.smtp_password,
380
+ )
381
+
382
+ recipients = (
383
+ self._email_config.to_emails + self._email_config.cc_emails
384
+ )
385
+ server.sendmail(
386
+ self._email_config.from_email,
387
+ recipients,
388
+ msg.as_string(),
389
+ )
390
+
391
+ return ActionResult(
392
+ action_name=self.name,
393
+ action_type=self.action_type,
394
+ status=ActionStatus.SUCCESS,
395
+ message=f"Email sent to {len(self._email_config.to_emails)} recipients",
396
+ details={"recipients": self._email_config.to_emails},
397
+ )
398
+ except Exception as e:
399
+ return ActionResult(
400
+ action_name=self.name,
401
+ action_type=self.action_type,
402
+ status=ActionStatus.FAILURE,
403
+ message=f"Failed to send email: {str(e)}",
404
+ error=str(e),
405
+ )
406
+
407
+ def _build_html_body(self, context: ActionContext) -> str:
408
+ """Build HTML email body."""
409
+ result = context.checkpoint_result
410
+ status = result.status.value
411
+
412
+ # Determine color
413
+ if status == "success":
414
+ status_color = "#28a745"
415
+ elif status in ("failure", "error"):
416
+ status_color = "#dc3545"
417
+ else:
418
+ status_color = "#ffc107"
419
+
420
+ html = f"""
421
+ <!DOCTYPE html>
422
+ <html>
423
+ <head>
424
+ <style>
425
+ body {{ font-family: Arial, sans-serif; line-height: 1.6; }}
426
+ .header {{ background-color: {status_color}; color: white; padding: 20px; }}
427
+ .content {{ padding: 20px; }}
428
+ .summary {{ background-color: #f8f9fa; padding: 15px; margin: 15px 0; }}
429
+ table {{ border-collapse: collapse; width: 100%; }}
430
+ th, td {{ border: 1px solid #ddd; padding: 8px; text-align: left; }}
431
+ th {{ background-color: #f2f2f2; }}
432
+ </style>
433
+ </head>
434
+ <body>
435
+ <div class="header">
436
+ <h1>Validation {status.title()}</h1>
437
+ <p>{result.checkpoint_name}</p>
438
+ </div>
439
+ <div class="content">
440
+ <div class="summary">
441
+ <h3>Summary</h3>
442
+ <p><strong>Source:</strong> {result.source_name}</p>
443
+ <p><strong>Rows:</strong> {result.row_count:,}</p>
444
+ <p><strong>Columns:</strong> {result.column_count}</p>
445
+ <p><strong>Total Issues:</strong> {result.issue_count:,}</p>
446
+ </div>
447
+
448
+ <h3>Issue Breakdown</h3>
449
+ <table>
450
+ <tr>
451
+ <th>Severity</th>
452
+ <th>Count</th>
453
+ </tr>
454
+ <tr><td>Critical</td><td>{result.critical_count}</td></tr>
455
+ <tr><td>High</td><td>{result.high_count}</td></tr>
456
+ <tr><td>Medium</td><td>{result.medium_count}</td></tr>
457
+ <tr><td>Low</td><td>{result.low_count}</td></tr>
458
+ </table>
459
+
460
+ <p style="margin-top: 20px; color: #6c757d;">
461
+ Run ID: {result.run_id}<br>
462
+ Generated at: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}
463
+ </p>
464
+ </div>
465
+ </body>
466
+ </html>
467
+ """
468
+ return html
469
+
470
+
471
+ # =============================================================================
472
+ # Microsoft Teams Notification
473
+ # =============================================================================
474
+
475
+
476
+ @dataclass
477
+ class TeamsNotificationConfig(ActionConfig):
478
+ """Configuration for Microsoft Teams notifications."""
479
+
480
+ webhook_url: str = ""
481
+ mention_users: list[str] = field(default_factory=list)
482
+ include_summary: bool = True
483
+
484
+ def __post_init__(self):
485
+ self.name = self.name or "teams"
486
+
487
+
488
+ @register_action("teams")
489
+ class TeamsNotificationAction(BaseAction):
490
+ """Microsoft Teams notification action via webhook."""
491
+
492
+ def __init__(
493
+ self,
494
+ webhook_url: str = "",
495
+ notify_on: NotifyCondition = NotifyCondition.FAILURE,
496
+ config: TeamsNotificationConfig | dict[str, Any] | None = None,
497
+ **kwargs: Any,
498
+ ) -> None:
499
+ if config is None:
500
+ config = TeamsNotificationConfig(
501
+ webhook_url=webhook_url,
502
+ notify_on=notify_on,
503
+ **kwargs,
504
+ )
505
+ elif isinstance(config, dict):
506
+ config = TeamsNotificationConfig(**config)
507
+
508
+ super().__init__(config)
509
+ self._teams_config: TeamsNotificationConfig = config
510
+
511
+ @property
512
+ def action_type(self) -> str:
513
+ return "notification"
514
+
515
+ def _do_execute(self, context: ActionContext) -> ActionResult:
516
+ """Send Teams notification."""
517
+ import httpx
518
+
519
+ result = context.checkpoint_result
520
+ payload = self._build_adaptive_card(context)
521
+
522
+ try:
523
+ with httpx.Client(timeout=self._config.timeout_seconds) as client:
524
+ response = client.post(
525
+ self._teams_config.webhook_url,
526
+ json=payload,
527
+ )
528
+ response.raise_for_status()
529
+
530
+ return ActionResult(
531
+ action_name=self.name,
532
+ action_type=self.action_type,
533
+ status=ActionStatus.SUCCESS,
534
+ message=f"Teams notification sent for {result.checkpoint_name}",
535
+ )
536
+ except httpx.HTTPError as e:
537
+ return ActionResult(
538
+ action_name=self.name,
539
+ action_type=self.action_type,
540
+ status=ActionStatus.FAILURE,
541
+ message=f"Failed to send Teams notification: {str(e)}",
542
+ error=str(e),
543
+ )
544
+
545
+ def _build_adaptive_card(self, context: ActionContext) -> dict[str, Any]:
546
+ """Build Teams Adaptive Card payload."""
547
+ result = context.checkpoint_result
548
+ status = result.status.value
549
+
550
+ if status == "success":
551
+ theme_color = "00FF00"
552
+ elif status in ("failure", "error"):
553
+ theme_color = "FF0000"
554
+ else:
555
+ theme_color = "FFA500"
556
+
557
+ return {
558
+ "@type": "MessageCard",
559
+ "@context": "http://schema.org/extensions",
560
+ "themeColor": theme_color,
561
+ "summary": f"Validation {status}: {result.checkpoint_name}",
562
+ "sections": [
563
+ {
564
+ "activityTitle": f"Validation {status.title()}: {result.checkpoint_name}",
565
+ "facts": [
566
+ {"name": "Source", "value": result.source_name},
567
+ {"name": "Status", "value": status.title()},
568
+ {"name": "Rows", "value": f"{result.row_count:,}"},
569
+ {"name": "Issues", "value": f"{result.issue_count:,}"},
570
+ {"name": "Critical", "value": str(result.critical_count)},
571
+ {"name": "High", "value": str(result.high_count)},
572
+ ],
573
+ }
574
+ ],
575
+ }
576
+
577
+
578
+ # =============================================================================
579
+ # Discord Notification
580
+ # =============================================================================
581
+
582
+
583
+ @dataclass
584
+ class DiscordNotificationConfig(ActionConfig):
585
+ """Configuration for Discord notifications."""
586
+
587
+ webhook_url: str = ""
588
+ username: str = "Truthound Bot"
589
+ avatar_url: str = ""
590
+ mention_roles: list[str] = field(default_factory=list)
591
+
592
+ def __post_init__(self):
593
+ self.name = self.name or "discord"
594
+
595
+
596
+ @register_action("discord")
597
+ class DiscordNotificationAction(BaseAction):
598
+ """Discord notification action via webhook."""
599
+
600
+ def __init__(
601
+ self,
602
+ webhook_url: str = "",
603
+ notify_on: NotifyCondition = NotifyCondition.FAILURE,
604
+ config: DiscordNotificationConfig | dict[str, Any] | None = None,
605
+ **kwargs: Any,
606
+ ) -> None:
607
+ if config is None:
608
+ config = DiscordNotificationConfig(
609
+ webhook_url=webhook_url,
610
+ notify_on=notify_on,
611
+ **kwargs,
612
+ )
613
+ elif isinstance(config, dict):
614
+ config = DiscordNotificationConfig(**config)
615
+
616
+ super().__init__(config)
617
+ self._discord_config: DiscordNotificationConfig = config
618
+
619
+ @property
620
+ def action_type(self) -> str:
621
+ return "notification"
622
+
623
+ def _do_execute(self, context: ActionContext) -> ActionResult:
624
+ """Send Discord notification."""
625
+ import httpx
626
+
627
+ result = context.checkpoint_result
628
+ payload = self._build_embed(context)
629
+
630
+ try:
631
+ with httpx.Client(timeout=self._config.timeout_seconds) as client:
632
+ response = client.post(
633
+ self._discord_config.webhook_url,
634
+ json=payload,
635
+ )
636
+ response.raise_for_status()
637
+
638
+ return ActionResult(
639
+ action_name=self.name,
640
+ action_type=self.action_type,
641
+ status=ActionStatus.SUCCESS,
642
+ message=f"Discord notification sent for {result.checkpoint_name}",
643
+ )
644
+ except httpx.HTTPError as e:
645
+ return ActionResult(
646
+ action_name=self.name,
647
+ action_type=self.action_type,
648
+ status=ActionStatus.FAILURE,
649
+ message=f"Failed to send Discord notification: {str(e)}",
650
+ error=str(e),
651
+ )
652
+
653
+ def _build_embed(self, context: ActionContext) -> dict[str, Any]:
654
+ """Build Discord embed payload."""
655
+ result = context.checkpoint_result
656
+ status = result.status.value
657
+
658
+ if status == "success":
659
+ color = 0x28A745
660
+ elif status in ("failure", "error"):
661
+ color = 0xDC3545
662
+ else:
663
+ color = 0xFFC107
664
+
665
+ embed = {
666
+ "title": f"Validation {status.title()}: {result.checkpoint_name}",
667
+ "color": color,
668
+ "fields": [
669
+ {"name": "Source", "value": result.source_name, "inline": True},
670
+ {"name": "Status", "value": status.title(), "inline": True},
671
+ {"name": "Rows", "value": f"{result.row_count:,}", "inline": True},
672
+ {"name": "Issues", "value": f"{result.issue_count:,}", "inline": True},
673
+ {"name": "Critical", "value": str(result.critical_count), "inline": True},
674
+ {"name": "High", "value": str(result.high_count), "inline": True},
675
+ ],
676
+ "footer": {"text": f"Run ID: {result.run_id}"},
677
+ "timestamp": datetime.now().isoformat(),
678
+ }
679
+
680
+ payload = {
681
+ "username": self._discord_config.username,
682
+ "embeds": [embed],
683
+ }
684
+
685
+ if self._discord_config.avatar_url:
686
+ payload["avatar_url"] = self._discord_config.avatar_url
687
+
688
+ # Add role mentions
689
+ if self._discord_config.mention_roles and status in ("failure", "error"):
690
+ mentions = " ".join(f"<@&{role}>" for role in self._discord_config.mention_roles)
691
+ payload["content"] = mentions
692
+
693
+ return payload
694
+
695
+
696
+ # =============================================================================
697
+ # Telegram Notification
698
+ # =============================================================================
699
+
700
+
701
+ @dataclass
702
+ class TelegramNotificationConfig(ActionConfig):
703
+ """Configuration for Telegram notifications."""
704
+
705
+ bot_token: str = ""
706
+ chat_id: str = ""
707
+ parse_mode: str = "HTML"
708
+
709
+ def __post_init__(self):
710
+ self.name = self.name or "telegram"
711
+
712
+
713
+ @register_action("telegram")
714
+ class TelegramNotificationAction(BaseAction):
715
+ """Telegram notification action via Bot API."""
716
+
717
+ def __init__(
718
+ self,
719
+ bot_token: str = "",
720
+ chat_id: str = "",
721
+ notify_on: NotifyCondition = NotifyCondition.FAILURE,
722
+ config: TelegramNotificationConfig | dict[str, Any] | None = None,
723
+ **kwargs: Any,
724
+ ) -> None:
725
+ if config is None:
726
+ config = TelegramNotificationConfig(
727
+ bot_token=bot_token,
728
+ chat_id=chat_id,
729
+ notify_on=notify_on,
730
+ **kwargs,
731
+ )
732
+ elif isinstance(config, dict):
733
+ config = TelegramNotificationConfig(**config)
734
+
735
+ super().__init__(config)
736
+ self._telegram_config: TelegramNotificationConfig = config
737
+
738
+ @property
739
+ def action_type(self) -> str:
740
+ return "notification"
741
+
742
+ def _do_execute(self, context: ActionContext) -> ActionResult:
743
+ """Send Telegram notification."""
744
+ import httpx
745
+
746
+ result = context.checkpoint_result
747
+ message = self._build_message(context)
748
+
749
+ url = f"https://api.telegram.org/bot{self._telegram_config.bot_token}/sendMessage"
750
+ payload = {
751
+ "chat_id": self._telegram_config.chat_id,
752
+ "text": message,
753
+ "parse_mode": self._telegram_config.parse_mode,
754
+ }
755
+
756
+ try:
757
+ with httpx.Client(timeout=self._config.timeout_seconds) as client:
758
+ response = client.post(url, json=payload)
759
+ response.raise_for_status()
760
+
761
+ return ActionResult(
762
+ action_name=self.name,
763
+ action_type=self.action_type,
764
+ status=ActionStatus.SUCCESS,
765
+ message=f"Telegram notification sent for {result.checkpoint_name}",
766
+ )
767
+ except httpx.HTTPError as e:
768
+ return ActionResult(
769
+ action_name=self.name,
770
+ action_type=self.action_type,
771
+ status=ActionStatus.FAILURE,
772
+ message=f"Failed to send Telegram notification: {str(e)}",
773
+ error=str(e),
774
+ )
775
+
776
+ def _build_message(self, context: ActionContext) -> str:
777
+ """Build Telegram message."""
778
+ result = context.checkpoint_result
779
+ status = result.status.value
780
+
781
+ if status == "success":
782
+ emoji = "✅"
783
+ elif status in ("failure", "error"):
784
+ emoji = "❌"
785
+ else:
786
+ emoji = "⚠️"
787
+
788
+ return f"""
789
+ {emoji} <b>Validation {status.title()}</b>
790
+
791
+ <b>Checkpoint:</b> {result.checkpoint_name}
792
+ <b>Source:</b> {result.source_name}
793
+ <b>Status:</b> {status}
794
+
795
+ <b>Summary:</b>
796
+ • Rows: {result.row_count:,}
797
+ • Issues: {result.issue_count:,}
798
+ - Critical: {result.critical_count}
799
+ - High: {result.high_count}
800
+ - Medium: {result.medium_count}
801
+ - Low: {result.low_count}
802
+
803
+ <code>Run ID: {result.run_id}</code>
804
+ """.strip()
805
+
806
+
807
+ # =============================================================================
808
+ # PagerDuty Notification
809
+ # =============================================================================
810
+
811
+
812
+ @dataclass
813
+ class PagerDutyNotificationConfig(ActionConfig):
814
+ """Configuration for PagerDuty notifications."""
815
+
816
+ routing_key: str = "" # Events API v2 routing key
817
+ severity: str = "critical" # critical, error, warning, info
818
+ dedup_key_prefix: str = "truthound"
819
+
820
+ def __post_init__(self):
821
+ self.name = self.name or "pagerduty"
822
+
823
+
824
+ @register_action("pagerduty")
825
+ class PagerDutyNotificationAction(BaseAction):
826
+ """PagerDuty notification action via Events API v2.
827
+
828
+ Creates incidents in PagerDuty for validation failures.
829
+ """
830
+
831
+ def __init__(
832
+ self,
833
+ routing_key: str = "",
834
+ severity: str = "critical",
835
+ notify_on: NotifyCondition = NotifyCondition.FAILURE,
836
+ config: PagerDutyNotificationConfig | dict[str, Any] | None = None,
837
+ **kwargs: Any,
838
+ ) -> None:
839
+ if config is None:
840
+ config = PagerDutyNotificationConfig(
841
+ routing_key=routing_key,
842
+ severity=severity,
843
+ notify_on=notify_on,
844
+ **kwargs,
845
+ )
846
+ elif isinstance(config, dict):
847
+ config = PagerDutyNotificationConfig(**config)
848
+
849
+ super().__init__(config)
850
+ self._pagerduty_config: PagerDutyNotificationConfig = config
851
+
852
+ @property
853
+ def action_type(self) -> str:
854
+ return "notification"
855
+
856
+ def _do_execute(self, context: ActionContext) -> ActionResult:
857
+ """Send PagerDuty alert."""
858
+ import httpx
859
+
860
+ result = context.checkpoint_result
861
+ payload = self._build_event(context)
862
+
863
+ try:
864
+ with httpx.Client(timeout=self._config.timeout_seconds) as client:
865
+ response = client.post(
866
+ "https://events.pagerduty.com/v2/enqueue",
867
+ json=payload,
868
+ )
869
+ response.raise_for_status()
870
+
871
+ return ActionResult(
872
+ action_name=self.name,
873
+ action_type=self.action_type,
874
+ status=ActionStatus.SUCCESS,
875
+ message=f"PagerDuty alert created for {result.checkpoint_name}",
876
+ )
877
+ except httpx.HTTPError as e:
878
+ return ActionResult(
879
+ action_name=self.name,
880
+ action_type=self.action_type,
881
+ status=ActionStatus.FAILURE,
882
+ message=f"Failed to create PagerDuty alert: {str(e)}",
883
+ error=str(e),
884
+ )
885
+
886
+ def _build_event(self, context: ActionContext) -> dict[str, Any]:
887
+ """Build PagerDuty Events API v2 payload."""
888
+ result = context.checkpoint_result
889
+
890
+ return {
891
+ "routing_key": self._pagerduty_config.routing_key,
892
+ "event_action": "trigger",
893
+ "dedup_key": f"{self._pagerduty_config.dedup_key_prefix}-{result.checkpoint_name}-{result.source_name}",
894
+ "payload": {
895
+ "summary": f"Data validation failed: {result.checkpoint_name}",
896
+ "severity": self._pagerduty_config.severity,
897
+ "source": result.source_name,
898
+ "timestamp": datetime.now().isoformat(),
899
+ "custom_details": {
900
+ "checkpoint": result.checkpoint_name,
901
+ "source": result.source_name,
902
+ "status": result.status.value,
903
+ "rows": result.row_count,
904
+ "total_issues": result.issue_count,
905
+ "critical_issues": result.critical_count,
906
+ "high_issues": result.high_count,
907
+ "run_id": result.run_id,
908
+ },
909
+ },
910
+ }