truthound-dashboard 1.3.1__py3-none-any.whl → 1.4.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 (169) hide show
  1. truthound_dashboard/api/alerts.py +258 -0
  2. truthound_dashboard/api/anomaly.py +1302 -0
  3. truthound_dashboard/api/cross_alerts.py +352 -0
  4. truthound_dashboard/api/deps.py +143 -0
  5. truthound_dashboard/api/drift_monitor.py +540 -0
  6. truthound_dashboard/api/lineage.py +1151 -0
  7. truthound_dashboard/api/maintenance.py +363 -0
  8. truthound_dashboard/api/middleware.py +373 -1
  9. truthound_dashboard/api/model_monitoring.py +805 -0
  10. truthound_dashboard/api/notifications_advanced.py +2452 -0
  11. truthound_dashboard/api/plugins.py +2096 -0
  12. truthound_dashboard/api/profile.py +211 -14
  13. truthound_dashboard/api/reports.py +853 -0
  14. truthound_dashboard/api/router.py +147 -0
  15. truthound_dashboard/api/rule_suggestions.py +310 -0
  16. truthound_dashboard/api/schema_evolution.py +231 -0
  17. truthound_dashboard/api/sources.py +47 -3
  18. truthound_dashboard/api/triggers.py +190 -0
  19. truthound_dashboard/api/validations.py +13 -0
  20. truthound_dashboard/api/validators.py +333 -4
  21. truthound_dashboard/api/versioning.py +309 -0
  22. truthound_dashboard/api/websocket.py +301 -0
  23. truthound_dashboard/core/__init__.py +27 -0
  24. truthound_dashboard/core/anomaly.py +1395 -0
  25. truthound_dashboard/core/anomaly_explainer.py +633 -0
  26. truthound_dashboard/core/cache.py +206 -0
  27. truthound_dashboard/core/cached_services.py +422 -0
  28. truthound_dashboard/core/charts.py +352 -0
  29. truthound_dashboard/core/connections.py +1069 -42
  30. truthound_dashboard/core/cross_alerts.py +837 -0
  31. truthound_dashboard/core/drift_monitor.py +1477 -0
  32. truthound_dashboard/core/drift_sampling.py +669 -0
  33. truthound_dashboard/core/i18n/__init__.py +42 -0
  34. truthound_dashboard/core/i18n/detector.py +173 -0
  35. truthound_dashboard/core/i18n/messages.py +564 -0
  36. truthound_dashboard/core/lineage.py +971 -0
  37. truthound_dashboard/core/maintenance.py +443 -5
  38. truthound_dashboard/core/model_monitoring.py +1043 -0
  39. truthound_dashboard/core/notifications/channels.py +1020 -1
  40. truthound_dashboard/core/notifications/deduplication/__init__.py +143 -0
  41. truthound_dashboard/core/notifications/deduplication/policies.py +274 -0
  42. truthound_dashboard/core/notifications/deduplication/service.py +400 -0
  43. truthound_dashboard/core/notifications/deduplication/stores.py +2365 -0
  44. truthound_dashboard/core/notifications/deduplication/strategies.py +422 -0
  45. truthound_dashboard/core/notifications/dispatcher.py +43 -0
  46. truthound_dashboard/core/notifications/escalation/__init__.py +149 -0
  47. truthound_dashboard/core/notifications/escalation/backends.py +1384 -0
  48. truthound_dashboard/core/notifications/escalation/engine.py +429 -0
  49. truthound_dashboard/core/notifications/escalation/models.py +336 -0
  50. truthound_dashboard/core/notifications/escalation/scheduler.py +1187 -0
  51. truthound_dashboard/core/notifications/escalation/state_machine.py +330 -0
  52. truthound_dashboard/core/notifications/escalation/stores.py +2896 -0
  53. truthound_dashboard/core/notifications/events.py +49 -0
  54. truthound_dashboard/core/notifications/metrics/__init__.py +115 -0
  55. truthound_dashboard/core/notifications/metrics/base.py +528 -0
  56. truthound_dashboard/core/notifications/metrics/collectors.py +583 -0
  57. truthound_dashboard/core/notifications/routing/__init__.py +169 -0
  58. truthound_dashboard/core/notifications/routing/combinators.py +184 -0
  59. truthound_dashboard/core/notifications/routing/config.py +375 -0
  60. truthound_dashboard/core/notifications/routing/config_parser.py +867 -0
  61. truthound_dashboard/core/notifications/routing/engine.py +382 -0
  62. truthound_dashboard/core/notifications/routing/expression_engine.py +1269 -0
  63. truthound_dashboard/core/notifications/routing/jinja2_engine.py +774 -0
  64. truthound_dashboard/core/notifications/routing/rules.py +625 -0
  65. truthound_dashboard/core/notifications/routing/validator.py +678 -0
  66. truthound_dashboard/core/notifications/service.py +2 -0
  67. truthound_dashboard/core/notifications/stats_aggregator.py +850 -0
  68. truthound_dashboard/core/notifications/throttling/__init__.py +83 -0
  69. truthound_dashboard/core/notifications/throttling/builder.py +311 -0
  70. truthound_dashboard/core/notifications/throttling/stores.py +1859 -0
  71. truthound_dashboard/core/notifications/throttling/throttlers.py +633 -0
  72. truthound_dashboard/core/openlineage.py +1028 -0
  73. truthound_dashboard/core/plugins/__init__.py +39 -0
  74. truthound_dashboard/core/plugins/docs/__init__.py +39 -0
  75. truthound_dashboard/core/plugins/docs/extractor.py +703 -0
  76. truthound_dashboard/core/plugins/docs/renderers.py +804 -0
  77. truthound_dashboard/core/plugins/hooks/__init__.py +63 -0
  78. truthound_dashboard/core/plugins/hooks/decorators.py +367 -0
  79. truthound_dashboard/core/plugins/hooks/manager.py +403 -0
  80. truthound_dashboard/core/plugins/hooks/protocols.py +265 -0
  81. truthound_dashboard/core/plugins/lifecycle/__init__.py +41 -0
  82. truthound_dashboard/core/plugins/lifecycle/hot_reload.py +584 -0
  83. truthound_dashboard/core/plugins/lifecycle/machine.py +419 -0
  84. truthound_dashboard/core/plugins/lifecycle/states.py +266 -0
  85. truthound_dashboard/core/plugins/loader.py +504 -0
  86. truthound_dashboard/core/plugins/registry.py +810 -0
  87. truthound_dashboard/core/plugins/reporter_executor.py +588 -0
  88. truthound_dashboard/core/plugins/sandbox/__init__.py +59 -0
  89. truthound_dashboard/core/plugins/sandbox/code_validator.py +243 -0
  90. truthound_dashboard/core/plugins/sandbox/engines.py +770 -0
  91. truthound_dashboard/core/plugins/sandbox/protocols.py +194 -0
  92. truthound_dashboard/core/plugins/sandbox.py +617 -0
  93. truthound_dashboard/core/plugins/security/__init__.py +68 -0
  94. truthound_dashboard/core/plugins/security/analyzer.py +535 -0
  95. truthound_dashboard/core/plugins/security/policies.py +311 -0
  96. truthound_dashboard/core/plugins/security/protocols.py +296 -0
  97. truthound_dashboard/core/plugins/security/signing.py +842 -0
  98. truthound_dashboard/core/plugins/security.py +446 -0
  99. truthound_dashboard/core/plugins/validator_executor.py +401 -0
  100. truthound_dashboard/core/plugins/versioning/__init__.py +51 -0
  101. truthound_dashboard/core/plugins/versioning/constraints.py +377 -0
  102. truthound_dashboard/core/plugins/versioning/dependencies.py +541 -0
  103. truthound_dashboard/core/plugins/versioning/semver.py +266 -0
  104. truthound_dashboard/core/profile_comparison.py +601 -0
  105. truthound_dashboard/core/report_history.py +570 -0
  106. truthound_dashboard/core/reporters/__init__.py +57 -0
  107. truthound_dashboard/core/reporters/base.py +296 -0
  108. truthound_dashboard/core/reporters/csv_reporter.py +155 -0
  109. truthound_dashboard/core/reporters/html_reporter.py +598 -0
  110. truthound_dashboard/core/reporters/i18n/__init__.py +65 -0
  111. truthound_dashboard/core/reporters/i18n/base.py +494 -0
  112. truthound_dashboard/core/reporters/i18n/catalogs.py +930 -0
  113. truthound_dashboard/core/reporters/json_reporter.py +160 -0
  114. truthound_dashboard/core/reporters/junit_reporter.py +233 -0
  115. truthound_dashboard/core/reporters/markdown_reporter.py +207 -0
  116. truthound_dashboard/core/reporters/pdf_reporter.py +209 -0
  117. truthound_dashboard/core/reporters/registry.py +272 -0
  118. truthound_dashboard/core/rule_generator.py +2088 -0
  119. truthound_dashboard/core/scheduler.py +822 -12
  120. truthound_dashboard/core/schema_evolution.py +858 -0
  121. truthound_dashboard/core/services.py +152 -9
  122. truthound_dashboard/core/statistics.py +718 -0
  123. truthound_dashboard/core/streaming_anomaly.py +883 -0
  124. truthound_dashboard/core/triggers/__init__.py +45 -0
  125. truthound_dashboard/core/triggers/base.py +226 -0
  126. truthound_dashboard/core/triggers/evaluators.py +609 -0
  127. truthound_dashboard/core/triggers/factory.py +363 -0
  128. truthound_dashboard/core/unified_alerts.py +870 -0
  129. truthound_dashboard/core/validation_limits.py +509 -0
  130. truthound_dashboard/core/versioning.py +709 -0
  131. truthound_dashboard/core/websocket/__init__.py +59 -0
  132. truthound_dashboard/core/websocket/manager.py +512 -0
  133. truthound_dashboard/core/websocket/messages.py +130 -0
  134. truthound_dashboard/db/__init__.py +30 -0
  135. truthound_dashboard/db/models.py +3375 -3
  136. truthound_dashboard/main.py +22 -0
  137. truthound_dashboard/schemas/__init__.py +396 -1
  138. truthound_dashboard/schemas/anomaly.py +1258 -0
  139. truthound_dashboard/schemas/base.py +4 -0
  140. truthound_dashboard/schemas/cross_alerts.py +334 -0
  141. truthound_dashboard/schemas/drift_monitor.py +890 -0
  142. truthound_dashboard/schemas/lineage.py +428 -0
  143. truthound_dashboard/schemas/maintenance.py +154 -0
  144. truthound_dashboard/schemas/model_monitoring.py +374 -0
  145. truthound_dashboard/schemas/notifications_advanced.py +1363 -0
  146. truthound_dashboard/schemas/openlineage.py +704 -0
  147. truthound_dashboard/schemas/plugins.py +1293 -0
  148. truthound_dashboard/schemas/profile.py +420 -34
  149. truthound_dashboard/schemas/profile_comparison.py +242 -0
  150. truthound_dashboard/schemas/reports.py +285 -0
  151. truthound_dashboard/schemas/rule_suggestion.py +434 -0
  152. truthound_dashboard/schemas/schema_evolution.py +164 -0
  153. truthound_dashboard/schemas/source.py +117 -2
  154. truthound_dashboard/schemas/triggers.py +511 -0
  155. truthound_dashboard/schemas/unified_alerts.py +223 -0
  156. truthound_dashboard/schemas/validation.py +25 -1
  157. truthound_dashboard/schemas/validators/__init__.py +11 -0
  158. truthound_dashboard/schemas/validators/base.py +151 -0
  159. truthound_dashboard/schemas/versioning.py +152 -0
  160. truthound_dashboard/static/index.html +2 -2
  161. {truthound_dashboard-1.3.1.dist-info → truthound_dashboard-1.4.1.dist-info}/METADATA +147 -23
  162. truthound_dashboard-1.4.1.dist-info/RECORD +239 -0
  163. truthound_dashboard/static/assets/index-BZG20KuF.js +0 -586
  164. truthound_dashboard/static/assets/index-D_HyZ3pb.css +0 -1
  165. truthound_dashboard/static/assets/unmerged_dictionaries-CtpqQBm0.js +0 -1
  166. truthound_dashboard-1.3.1.dist-info/RECORD +0 -110
  167. {truthound_dashboard-1.3.1.dist-info → truthound_dashboard-1.4.1.dist-info}/WHEEL +0 -0
  168. {truthound_dashboard-1.3.1.dist-info → truthound_dashboard-1.4.1.dist-info}/entry_points.txt +0 -0
  169. {truthound_dashboard-1.3.1.dist-info → truthound_dashboard-1.4.1.dist-info}/licenses/LICENSE +0 -0
@@ -1,7 +1,17 @@
1
1
  """Notification channel implementations.
2
2
 
3
3
  This module provides concrete implementations of notification channels
4
- for Slack, Email, and Webhook notifications.
4
+ for various notification services:
5
+
6
+ - Slack: Incoming webhooks
7
+ - Email: SMTP
8
+ - Webhook: Generic HTTP endpoints
9
+ - Discord: Discord webhooks
10
+ - Telegram: Telegram Bot API
11
+ - PagerDuty: Events API v2
12
+ - OpsGenie: Alert API
13
+ - Teams: Microsoft Teams webhooks
14
+ - GitHub: Issues/Discussions API
5
15
 
6
16
  Each channel is registered with the ChannelRegistry and can be
7
17
  instantiated dynamically based on channel type.
@@ -22,6 +32,7 @@ from .base import (
22
32
  )
23
33
  from .events import (
24
34
  DriftDetectedEvent,
35
+ SchemaChangedEvent,
25
36
  ScheduleFailedEvent,
26
37
  TestNotificationEvent,
27
38
  ValidationFailedEvent,
@@ -555,3 +566,1011 @@ class WebhookChannel(BaseNotificationChannel):
555
566
  return f"Test notification from truthound-dashboard"
556
567
 
557
568
  return self._default_format(event)
569
+
570
+
571
+ @ChannelRegistry.register("discord")
572
+ class DiscordChannel(BaseNotificationChannel):
573
+ """Discord notification channel using webhooks.
574
+
575
+ Configuration:
576
+ webhook_url: Discord webhook URL
577
+ username: Optional bot username override
578
+ avatar_url: Optional avatar URL
579
+
580
+ Example config:
581
+ {
582
+ "webhook_url": "https://discord.com/api/webhooks/...",
583
+ "username": "Truthound Bot",
584
+ "avatar_url": "https://example.com/avatar.png"
585
+ }
586
+ """
587
+
588
+ channel_type = "discord"
589
+
590
+ @classmethod
591
+ def get_config_schema(cls) -> dict[str, Any]:
592
+ """Get Discord channel configuration schema."""
593
+ return {
594
+ "webhook_url": {
595
+ "type": "string",
596
+ "required": True,
597
+ "description": "Discord webhook URL",
598
+ },
599
+ "username": {
600
+ "type": "string",
601
+ "required": False,
602
+ "description": "Bot username override",
603
+ },
604
+ "avatar_url": {
605
+ "type": "string",
606
+ "required": False,
607
+ "description": "Bot avatar URL",
608
+ },
609
+ }
610
+
611
+ async def send(
612
+ self,
613
+ message: str,
614
+ event: NotificationEvent | None = None,
615
+ **kwargs: Any,
616
+ ) -> bool:
617
+ """Send notification to Discord.
618
+
619
+ Args:
620
+ message: Message text (supports Discord markdown).
621
+ event: Optional triggering event.
622
+ **kwargs: Additional Discord message options.
623
+
624
+ Returns:
625
+ True if message was sent successfully.
626
+ """
627
+ webhook_url = self.config["webhook_url"]
628
+
629
+ # Build payload with embeds for rich formatting
630
+ payload: dict[str, Any] = {"content": message}
631
+
632
+ # Add embeds for specific events
633
+ embeds = self._build_embeds(event)
634
+ if embeds:
635
+ payload["embeds"] = embeds
636
+
637
+ # Add optional overrides
638
+ if self.config.get("username"):
639
+ payload["username"] = self.config["username"]
640
+ if self.config.get("avatar_url"):
641
+ payload["avatar_url"] = self.config["avatar_url"]
642
+
643
+ payload.update(kwargs)
644
+
645
+ async with httpx.AsyncClient(timeout=30.0) as client:
646
+ response = await client.post(webhook_url, json=payload)
647
+ # Discord returns 204 on success
648
+ return response.status_code in (200, 204)
649
+
650
+ def _build_embeds(
651
+ self,
652
+ event: NotificationEvent | None,
653
+ ) -> list[dict[str, Any]]:
654
+ """Build Discord embeds for rich message formatting."""
655
+ if event is None:
656
+ return []
657
+
658
+ embed: dict[str, Any] = {
659
+ "timestamp": event.timestamp.isoformat(),
660
+ "footer": {"text": "Truthound Dashboard"},
661
+ }
662
+
663
+ if isinstance(event, ValidationFailedEvent):
664
+ embed["title"] = "🚨 Validation Failed"
665
+ embed["color"] = 0xFF0000 if event.has_critical else 0xFFA500
666
+ embed["fields"] = [
667
+ {"name": "Source", "value": event.source_name or "Unknown", "inline": True},
668
+ {"name": "Severity", "value": event.severity, "inline": True},
669
+ {"name": "Issues", "value": str(event.total_issues), "inline": True},
670
+ ]
671
+ elif isinstance(event, DriftDetectedEvent):
672
+ embed["title"] = "📊 Drift Detected"
673
+ embed["color"] = 0xFFA500
674
+ embed["fields"] = [
675
+ {"name": "Baseline", "value": event.baseline_source_name, "inline": True},
676
+ {"name": "Current", "value": event.current_source_name, "inline": True},
677
+ {"name": "Drift", "value": f"{event.drift_percentage:.1f}%", "inline": True},
678
+ ]
679
+ elif isinstance(event, ScheduleFailedEvent):
680
+ embed["title"] = "⏰ Schedule Failed"
681
+ embed["color"] = 0xFF6B6B
682
+ embed["fields"] = [
683
+ {"name": "Schedule", "value": event.schedule_name, "inline": True},
684
+ {"name": "Source", "value": event.source_name or "Unknown", "inline": True},
685
+ ]
686
+ if event.error_message:
687
+ embed["fields"].append({"name": "Error", "value": event.error_message[:1024], "inline": False})
688
+ elif isinstance(event, TestNotificationEvent):
689
+ embed["title"] = "✅ Test Notification"
690
+ embed["color"] = 0x00FF00
691
+ embed["description"] = f"Channel: {event.channel_name}"
692
+ else:
693
+ return []
694
+
695
+ return [embed]
696
+
697
+ def format_message(self, event: NotificationEvent) -> str:
698
+ """Format message for Discord."""
699
+ if isinstance(event, ValidationFailedEvent):
700
+ emoji = "🚨" if event.has_critical else "⚠️"
701
+ return f"{emoji} **Validation Failed** for `{event.source_name or 'Unknown'}`"
702
+
703
+ elif isinstance(event, ScheduleFailedEvent):
704
+ return f"⏰ **Schedule Failed**: `{event.schedule_name}`"
705
+
706
+ elif isinstance(event, DriftDetectedEvent):
707
+ return f"📊 **Drift Detected**: {event.drift_percentage:.1f}% drift"
708
+
709
+ elif isinstance(event, TestNotificationEvent):
710
+ return f"✅ **Test Notification** from truthound-dashboard"
711
+
712
+ return self._default_format(event)
713
+
714
+
715
+ @ChannelRegistry.register("telegram")
716
+ class TelegramChannel(BaseNotificationChannel):
717
+ """Telegram notification channel using Bot API.
718
+
719
+ Configuration:
720
+ bot_token: Telegram Bot API token
721
+ chat_id: Target chat/group/channel ID
722
+ parse_mode: Message parse mode (HTML or MarkdownV2)
723
+ disable_notification: Send silently (default: False)
724
+
725
+ Example config:
726
+ {
727
+ "bot_token": "123456789:ABCdefGHIjklMNOpqrsTUVwxyz",
728
+ "chat_id": "-1001234567890",
729
+ "parse_mode": "HTML"
730
+ }
731
+ """
732
+
733
+ channel_type = "telegram"
734
+
735
+ @classmethod
736
+ def get_config_schema(cls) -> dict[str, Any]:
737
+ """Get Telegram channel configuration schema."""
738
+ return {
739
+ "bot_token": {
740
+ "type": "string",
741
+ "required": True,
742
+ "secret": True,
743
+ "description": "Telegram Bot API token",
744
+ },
745
+ "chat_id": {
746
+ "type": "string",
747
+ "required": True,
748
+ "description": "Target chat/group/channel ID",
749
+ },
750
+ "parse_mode": {
751
+ "type": "string",
752
+ "required": False,
753
+ "default": "HTML",
754
+ "description": "Message parse mode (HTML or MarkdownV2)",
755
+ },
756
+ "disable_notification": {
757
+ "type": "boolean",
758
+ "required": False,
759
+ "default": False,
760
+ "description": "Send message silently",
761
+ },
762
+ }
763
+
764
+ async def send(
765
+ self,
766
+ message: str,
767
+ event: NotificationEvent | None = None,
768
+ **kwargs: Any,
769
+ ) -> bool:
770
+ """Send notification via Telegram Bot API.
771
+
772
+ Args:
773
+ message: Message text (supports HTML or Markdown).
774
+ event: Optional triggering event.
775
+ **kwargs: Additional Telegram API options.
776
+
777
+ Returns:
778
+ True if message was sent successfully.
779
+ """
780
+ bot_token = self.config["bot_token"]
781
+ chat_id = self.config["chat_id"]
782
+ api_url = f"https://api.telegram.org/bot{bot_token}/sendMessage"
783
+
784
+ payload: dict[str, Any] = {
785
+ "chat_id": chat_id,
786
+ "text": message,
787
+ "parse_mode": self.config.get("parse_mode", "HTML"),
788
+ }
789
+
790
+ if self.config.get("disable_notification"):
791
+ payload["disable_notification"] = True
792
+
793
+ payload.update(kwargs)
794
+
795
+ async with httpx.AsyncClient(timeout=30.0) as client:
796
+ response = await client.post(api_url, json=payload)
797
+ result = response.json()
798
+ return result.get("ok", False)
799
+
800
+ def format_message(self, event: NotificationEvent) -> str:
801
+ """Format message for Telegram with HTML."""
802
+ if isinstance(event, ValidationFailedEvent):
803
+ emoji = "🚨" if event.has_critical else "⚠️"
804
+ return (
805
+ f"{emoji} <b>Validation Failed</b>\n\n"
806
+ f"<b>Source:</b> {event.source_name or 'Unknown'}\n"
807
+ f"<b>Severity:</b> {event.severity}\n"
808
+ f"<b>Issues:</b> {event.total_issues}"
809
+ )
810
+
811
+ elif isinstance(event, ScheduleFailedEvent):
812
+ return (
813
+ f"⏰ <b>Schedule Failed</b>\n\n"
814
+ f"<b>Schedule:</b> {event.schedule_name}\n"
815
+ f"<b>Source:</b> {event.source_name or 'Unknown'}\n"
816
+ f"<b>Error:</b> {event.error_message or 'Validation failed'}"
817
+ )
818
+
819
+ elif isinstance(event, DriftDetectedEvent):
820
+ return (
821
+ f"📊 <b>Drift Detected</b>\n\n"
822
+ f"<b>Baseline:</b> {event.baseline_source_name}\n"
823
+ f"<b>Current:</b> {event.current_source_name}\n"
824
+ f"<b>Drift:</b> {event.drifted_columns}/{event.total_columns} columns "
825
+ f"({event.drift_percentage:.1f}%)"
826
+ )
827
+
828
+ elif isinstance(event, TestNotificationEvent):
829
+ return (
830
+ f"✅ <b>Test Notification</b>\n\n"
831
+ f"This is a test from truthound-dashboard.\n"
832
+ f"<b>Channel:</b> {event.channel_name}"
833
+ )
834
+
835
+ return self._default_format(event)
836
+
837
+
838
+ @ChannelRegistry.register("pagerduty")
839
+ class PagerDutyChannel(BaseNotificationChannel):
840
+ """PagerDuty notification channel using Events API v2.
841
+
842
+ Configuration:
843
+ routing_key: PagerDuty Events API v2 routing/integration key
844
+ severity: Default severity (critical, error, warning, info)
845
+ component: Optional component name
846
+ group: Optional logical grouping
847
+ class_type: Optional class/type of event
848
+
849
+ Example config:
850
+ {
851
+ "routing_key": "your-32-char-routing-key",
852
+ "severity": "error",
853
+ "component": "data-quality"
854
+ }
855
+ """
856
+
857
+ channel_type = "pagerduty"
858
+
859
+ EVENTS_API_URL = "https://events.pagerduty.com/v2/enqueue"
860
+
861
+ @classmethod
862
+ def get_config_schema(cls) -> dict[str, Any]:
863
+ """Get PagerDuty channel configuration schema."""
864
+ return {
865
+ "routing_key": {
866
+ "type": "string",
867
+ "required": True,
868
+ "secret": True,
869
+ "description": "PagerDuty Events API v2 routing key",
870
+ },
871
+ "severity": {
872
+ "type": "string",
873
+ "required": False,
874
+ "default": "error",
875
+ "description": "Default severity (critical, error, warning, info)",
876
+ },
877
+ "component": {
878
+ "type": "string",
879
+ "required": False,
880
+ "description": "Component name",
881
+ },
882
+ "group": {
883
+ "type": "string",
884
+ "required": False,
885
+ "description": "Logical grouping",
886
+ },
887
+ "class_type": {
888
+ "type": "string",
889
+ "required": False,
890
+ "description": "Event class/type",
891
+ },
892
+ }
893
+
894
+ async def send(
895
+ self,
896
+ message: str,
897
+ event: NotificationEvent | None = None,
898
+ action: str = "trigger",
899
+ dedup_key: str | None = None,
900
+ **kwargs: Any,
901
+ ) -> bool:
902
+ """Send event to PagerDuty.
903
+
904
+ Args:
905
+ message: Alert summary.
906
+ event: Optional triggering event.
907
+ action: Event action (trigger, acknowledge, resolve).
908
+ dedup_key: Optional deduplication key.
909
+ **kwargs: Additional PagerDuty event options.
910
+
911
+ Returns:
912
+ True if event was accepted.
913
+ """
914
+ routing_key = self.config["routing_key"]
915
+
916
+ # Determine severity from event
917
+ severity = self._determine_severity(event)
918
+
919
+ # Build payload
920
+ payload: dict[str, Any] = {
921
+ "routing_key": routing_key,
922
+ "event_action": action,
923
+ "payload": {
924
+ "summary": message[:1024], # PagerDuty limit
925
+ "severity": severity,
926
+ "source": "truthound-dashboard",
927
+ "timestamp": event.timestamp.isoformat() if event else None,
928
+ },
929
+ }
930
+
931
+ # Add optional fields
932
+ if dedup_key:
933
+ payload["dedup_key"] = dedup_key
934
+ elif event:
935
+ # Generate dedup key from event
936
+ payload["dedup_key"] = f"truthound-{event.event_type}-{event.source_id or 'global'}"
937
+
938
+ if self.config.get("component"):
939
+ payload["payload"]["component"] = self.config["component"]
940
+ if self.config.get("group"):
941
+ payload["payload"]["group"] = self.config["group"]
942
+ if self.config.get("class_type"):
943
+ payload["payload"]["class"] = self.config["class_type"]
944
+
945
+ # Add custom details
946
+ if event:
947
+ payload["payload"]["custom_details"] = event.to_dict()
948
+
949
+ async with httpx.AsyncClient(timeout=30.0) as client:
950
+ response = await client.post(self.EVENTS_API_URL, json=payload)
951
+ return response.status_code == 202
952
+
953
+ def _determine_severity(self, event: NotificationEvent | None) -> str:
954
+ """Determine PagerDuty severity from event."""
955
+ default_severity = self.config.get("severity", "error")
956
+
957
+ if event is None:
958
+ return default_severity
959
+
960
+ if isinstance(event, ValidationFailedEvent):
961
+ if event.has_critical:
962
+ return "critical"
963
+ elif event.has_high:
964
+ return "error"
965
+ return "warning"
966
+
967
+ elif isinstance(event, ScheduleFailedEvent):
968
+ return "error"
969
+
970
+ elif isinstance(event, DriftDetectedEvent):
971
+ return "warning" if event.has_high_drift else "info"
972
+
973
+ elif isinstance(event, TestNotificationEvent):
974
+ return "info"
975
+
976
+ return default_severity
977
+
978
+ def format_message(self, event: NotificationEvent) -> str:
979
+ """Format message for PagerDuty alert summary."""
980
+ if isinstance(event, ValidationFailedEvent):
981
+ return (
982
+ f"Validation failed for {event.source_name or 'Unknown'}: "
983
+ f"{event.total_issues} {event.severity} issues"
984
+ )
985
+
986
+ elif isinstance(event, ScheduleFailedEvent):
987
+ return f"Scheduled validation failed: {event.schedule_name}"
988
+
989
+ elif isinstance(event, DriftDetectedEvent):
990
+ return (
991
+ f"Data drift detected: {event.drifted_columns}/{event.total_columns} columns "
992
+ f"({event.drift_percentage:.1f}%)"
993
+ )
994
+
995
+ elif isinstance(event, TestNotificationEvent):
996
+ return f"Test alert from truthound-dashboard ({event.channel_name})"
997
+
998
+ return self._default_format(event)
999
+
1000
+
1001
+ @ChannelRegistry.register("opsgenie")
1002
+ class OpsGenieChannel(BaseNotificationChannel):
1003
+ """OpsGenie notification channel using Alert API.
1004
+
1005
+ Configuration:
1006
+ api_key: OpsGenie API key
1007
+ priority: Default priority (P1-P5)
1008
+ tags: Optional list of tags
1009
+ team: Optional team name
1010
+ responders: Optional list of responders
1011
+
1012
+ Example config:
1013
+ {
1014
+ "api_key": "your-opsgenie-api-key",
1015
+ "priority": "P3",
1016
+ "tags": ["data-quality", "automated"],
1017
+ "team": "data-platform"
1018
+ }
1019
+ """
1020
+
1021
+ channel_type = "opsgenie"
1022
+
1023
+ API_URL = "https://api.opsgenie.com/v2/alerts"
1024
+
1025
+ @classmethod
1026
+ def get_config_schema(cls) -> dict[str, Any]:
1027
+ """Get OpsGenie channel configuration schema."""
1028
+ return {
1029
+ "api_key": {
1030
+ "type": "string",
1031
+ "required": True,
1032
+ "secret": True,
1033
+ "description": "OpsGenie API key",
1034
+ },
1035
+ "priority": {
1036
+ "type": "string",
1037
+ "required": False,
1038
+ "default": "P3",
1039
+ "description": "Default priority (P1-P5)",
1040
+ },
1041
+ "tags": {
1042
+ "type": "array",
1043
+ "required": False,
1044
+ "items": {"type": "string"},
1045
+ "description": "Alert tags",
1046
+ },
1047
+ "team": {
1048
+ "type": "string",
1049
+ "required": False,
1050
+ "description": "Team name",
1051
+ },
1052
+ "responders": {
1053
+ "type": "array",
1054
+ "required": False,
1055
+ "items": {"type": "object"},
1056
+ "description": "List of responders",
1057
+ },
1058
+ }
1059
+
1060
+ async def send(
1061
+ self,
1062
+ message: str,
1063
+ event: NotificationEvent | None = None,
1064
+ alias: str | None = None,
1065
+ **kwargs: Any,
1066
+ ) -> bool:
1067
+ """Send alert to OpsGenie.
1068
+
1069
+ Args:
1070
+ message: Alert message.
1071
+ event: Optional triggering event.
1072
+ alias: Optional unique alert identifier.
1073
+ **kwargs: Additional OpsGenie alert options.
1074
+
1075
+ Returns:
1076
+ True if alert was created.
1077
+ """
1078
+ api_key = self.config["api_key"]
1079
+
1080
+ # Determine priority from event
1081
+ priority = self._determine_priority(event)
1082
+
1083
+ # Build payload
1084
+ payload: dict[str, Any] = {
1085
+ "message": message[:130], # OpsGenie message limit
1086
+ "priority": priority,
1087
+ "source": "truthound-dashboard",
1088
+ }
1089
+
1090
+ # Add alias for deduplication
1091
+ if alias:
1092
+ payload["alias"] = alias
1093
+ elif event:
1094
+ payload["alias"] = f"truthound-{event.event_type}-{event.source_id or 'global'}"
1095
+
1096
+ # Add description with full details
1097
+ if event:
1098
+ payload["description"] = self._build_description(event)
1099
+
1100
+ # Add optional fields
1101
+ tags = list(self.config.get("tags", [])) + ["truthound"]
1102
+ payload["tags"] = tags
1103
+
1104
+ if self.config.get("team"):
1105
+ payload["responders"] = [{"type": "team", "name": self.config["team"]}]
1106
+ elif self.config.get("responders"):
1107
+ payload["responders"] = self.config["responders"]
1108
+
1109
+ # Add details
1110
+ if event:
1111
+ payload["details"] = {
1112
+ "event_type": event.event_type,
1113
+ "source_id": event.source_id,
1114
+ "source_name": event.source_name,
1115
+ "timestamp": event.timestamp.isoformat(),
1116
+ }
1117
+
1118
+ headers = {
1119
+ "Authorization": f"GenieKey {api_key}",
1120
+ "Content-Type": "application/json",
1121
+ }
1122
+
1123
+ async with httpx.AsyncClient(timeout=30.0) as client:
1124
+ response = await client.post(self.API_URL, json=payload, headers=headers)
1125
+ return response.status_code == 202
1126
+
1127
+ def _determine_priority(self, event: NotificationEvent | None) -> str:
1128
+ """Determine OpsGenie priority from event."""
1129
+ default_priority = self.config.get("priority", "P3")
1130
+
1131
+ if event is None:
1132
+ return default_priority
1133
+
1134
+ if isinstance(event, ValidationFailedEvent):
1135
+ if event.has_critical:
1136
+ return "P1"
1137
+ elif event.has_high:
1138
+ return "P2"
1139
+ return "P3"
1140
+
1141
+ elif isinstance(event, ScheduleFailedEvent):
1142
+ return "P2"
1143
+
1144
+ elif isinstance(event, DriftDetectedEvent):
1145
+ return "P3" if event.has_high_drift else "P4"
1146
+
1147
+ elif isinstance(event, TestNotificationEvent):
1148
+ return "P5"
1149
+
1150
+ return default_priority
1151
+
1152
+ def _build_description(self, event: NotificationEvent) -> str:
1153
+ """Build detailed description for OpsGenie alert."""
1154
+ if isinstance(event, ValidationFailedEvent):
1155
+ return (
1156
+ f"Validation failed for source: {event.source_name or 'Unknown'}\n\n"
1157
+ f"Severity: {event.severity}\n"
1158
+ f"Total Issues: {event.total_issues}\n"
1159
+ f"Critical Issues: {event.has_critical}\n"
1160
+ f"High Severity Issues: {event.has_high}\n"
1161
+ f"Validation ID: {event.validation_id}"
1162
+ )
1163
+
1164
+ elif isinstance(event, ScheduleFailedEvent):
1165
+ return (
1166
+ f"Scheduled validation failed\n\n"
1167
+ f"Schedule: {event.schedule_name}\n"
1168
+ f"Source: {event.source_name or 'Unknown'}\n"
1169
+ f"Error: {event.error_message or 'Validation failed'}"
1170
+ )
1171
+
1172
+ elif isinstance(event, DriftDetectedEvent):
1173
+ return (
1174
+ f"Data drift detected between datasets\n\n"
1175
+ f"Baseline: {event.baseline_source_name}\n"
1176
+ f"Current: {event.current_source_name}\n"
1177
+ f"Drifted Columns: {event.drifted_columns}/{event.total_columns}\n"
1178
+ f"Drift Percentage: {event.drift_percentage:.1f}%"
1179
+ )
1180
+
1181
+ return f"Event type: {event.event_type}"
1182
+
1183
+ def format_message(self, event: NotificationEvent) -> str:
1184
+ """Format message for OpsGenie alert (short)."""
1185
+ if isinstance(event, ValidationFailedEvent):
1186
+ return f"Validation failed: {event.source_name or 'Unknown'} ({event.severity})"
1187
+
1188
+ elif isinstance(event, ScheduleFailedEvent):
1189
+ return f"Schedule failed: {event.schedule_name}"
1190
+
1191
+ elif isinstance(event, DriftDetectedEvent):
1192
+ return f"Drift detected: {event.drift_percentage:.1f}%"
1193
+
1194
+ elif isinstance(event, TestNotificationEvent):
1195
+ return f"Test alert: {event.channel_name}"
1196
+
1197
+ return self._default_format(event)
1198
+
1199
+
1200
+ @ChannelRegistry.register("teams")
1201
+ class TeamsChannel(BaseNotificationChannel):
1202
+ """Microsoft Teams notification channel using webhooks.
1203
+
1204
+ Configuration:
1205
+ webhook_url: Teams incoming webhook URL
1206
+ theme_color: Optional accent color (hex without #)
1207
+
1208
+ Example config:
1209
+ {
1210
+ "webhook_url": "https://outlook.office.com/webhook/...",
1211
+ "theme_color": "fd9e4b"
1212
+ }
1213
+ """
1214
+
1215
+ channel_type = "teams"
1216
+
1217
+ @classmethod
1218
+ def get_config_schema(cls) -> dict[str, Any]:
1219
+ """Get Teams channel configuration schema."""
1220
+ return {
1221
+ "webhook_url": {
1222
+ "type": "string",
1223
+ "required": True,
1224
+ "description": "Teams incoming webhook URL",
1225
+ },
1226
+ "theme_color": {
1227
+ "type": "string",
1228
+ "required": False,
1229
+ "default": "fd9e4b",
1230
+ "description": "Accent color (hex without #)",
1231
+ },
1232
+ }
1233
+
1234
+ async def send(
1235
+ self,
1236
+ message: str,
1237
+ event: NotificationEvent | None = None,
1238
+ **kwargs: Any,
1239
+ ) -> bool:
1240
+ """Send notification to Microsoft Teams.
1241
+
1242
+ Args:
1243
+ message: Message text.
1244
+ event: Optional triggering event.
1245
+ **kwargs: Additional Teams message options.
1246
+
1247
+ Returns:
1248
+ True if message was sent successfully.
1249
+ """
1250
+ webhook_url = self.config["webhook_url"]
1251
+
1252
+ # Build Adaptive Card payload
1253
+ payload = self._build_card(message, event)
1254
+
1255
+ async with httpx.AsyncClient(timeout=30.0) as client:
1256
+ response = await client.post(webhook_url, json=payload)
1257
+ return response.status_code == 200
1258
+
1259
+ def _build_card(
1260
+ self,
1261
+ message: str,
1262
+ event: NotificationEvent | None,
1263
+ ) -> dict[str, Any]:
1264
+ """Build Teams Adaptive Card payload."""
1265
+ theme_color = self.config.get("theme_color", "fd9e4b")
1266
+
1267
+ # Build card content
1268
+ card: dict[str, Any] = {
1269
+ "@type": "MessageCard",
1270
+ "@context": "http://schema.org/extensions",
1271
+ "themeColor": theme_color,
1272
+ "summary": message[:150],
1273
+ "sections": [],
1274
+ }
1275
+
1276
+ # Build sections based on event type
1277
+ if isinstance(event, ValidationFailedEvent):
1278
+ card["title"] = "🚨 Validation Failed"
1279
+ card["sections"].append({
1280
+ "activityTitle": event.source_name or "Unknown Source",
1281
+ "activitySubtitle": f"Severity: {event.severity}",
1282
+ "facts": [
1283
+ {"name": "Total Issues", "value": str(event.total_issues)},
1284
+ {"name": "Critical", "value": "Yes" if event.has_critical else "No"},
1285
+ {"name": "High", "value": "Yes" if event.has_high else "No"},
1286
+ {"name": "Validation ID", "value": event.validation_id[:8] + "..."},
1287
+ ],
1288
+ "markdown": True,
1289
+ })
1290
+
1291
+ elif isinstance(event, ScheduleFailedEvent):
1292
+ card["title"] = "⏰ Schedule Failed"
1293
+ card["sections"].append({
1294
+ "activityTitle": event.schedule_name,
1295
+ "activitySubtitle": event.source_name or "Unknown Source",
1296
+ "facts": [
1297
+ {"name": "Error", "value": event.error_message or "Validation failed"},
1298
+ ],
1299
+ "markdown": True,
1300
+ })
1301
+
1302
+ elif isinstance(event, DriftDetectedEvent):
1303
+ card["title"] = "📊 Drift Detected"
1304
+ card["sections"].append({
1305
+ "activityTitle": "Data Drift Analysis",
1306
+ "facts": [
1307
+ {"name": "Baseline", "value": event.baseline_source_name},
1308
+ {"name": "Current", "value": event.current_source_name},
1309
+ {"name": "Drifted Columns", "value": f"{event.drifted_columns}/{event.total_columns}"},
1310
+ {"name": "Drift %", "value": f"{event.drift_percentage:.1f}%"},
1311
+ ],
1312
+ "markdown": True,
1313
+ })
1314
+
1315
+ elif isinstance(event, TestNotificationEvent):
1316
+ card["title"] = "✅ Test Notification"
1317
+ card["sections"].append({
1318
+ "activityTitle": "truthound-dashboard",
1319
+ "activitySubtitle": f"Channel: {event.channel_name}",
1320
+ "text": "This is a test notification.",
1321
+ "markdown": True,
1322
+ })
1323
+
1324
+ else:
1325
+ card["title"] = "Truthound Notification"
1326
+ card["sections"].append({
1327
+ "text": message,
1328
+ "markdown": True,
1329
+ })
1330
+
1331
+ return card
1332
+
1333
+ def format_message(self, event: NotificationEvent) -> str:
1334
+ """Format message for Teams."""
1335
+ if isinstance(event, ValidationFailedEvent):
1336
+ return (
1337
+ f"Validation failed for {event.source_name or 'Unknown'}: "
1338
+ f"{event.total_issues} issues ({event.severity})"
1339
+ )
1340
+
1341
+ elif isinstance(event, ScheduleFailedEvent):
1342
+ return f"Schedule '{event.schedule_name}' failed"
1343
+
1344
+ elif isinstance(event, DriftDetectedEvent):
1345
+ return (
1346
+ f"Drift detected: {event.drifted_columns} columns "
1347
+ f"({event.drift_percentage:.1f}%)"
1348
+ )
1349
+
1350
+ elif isinstance(event, TestNotificationEvent):
1351
+ return "Test notification from truthound-dashboard"
1352
+
1353
+ return self._default_format(event)
1354
+
1355
+
1356
+ @ChannelRegistry.register("github")
1357
+ class GitHubChannel(BaseNotificationChannel):
1358
+ """GitHub notification channel for creating issues.
1359
+
1360
+ Configuration:
1361
+ token: GitHub personal access token
1362
+ owner: Repository owner
1363
+ repo: Repository name
1364
+ labels: Optional list of labels to apply
1365
+ assignees: Optional list of assignees
1366
+
1367
+ Example config:
1368
+ {
1369
+ "token": "ghp_xxxxxxxxxxxxxxxxxxxx",
1370
+ "owner": "myorg",
1371
+ "repo": "data-quality",
1372
+ "labels": ["data-quality", "automated"],
1373
+ "assignees": ["data-team-lead"]
1374
+ }
1375
+ """
1376
+
1377
+ channel_type = "github"
1378
+
1379
+ API_URL = "https://api.github.com"
1380
+
1381
+ @classmethod
1382
+ def get_config_schema(cls) -> dict[str, Any]:
1383
+ """Get GitHub channel configuration schema."""
1384
+ return {
1385
+ "token": {
1386
+ "type": "string",
1387
+ "required": True,
1388
+ "secret": True,
1389
+ "description": "GitHub personal access token",
1390
+ },
1391
+ "owner": {
1392
+ "type": "string",
1393
+ "required": True,
1394
+ "description": "Repository owner",
1395
+ },
1396
+ "repo": {
1397
+ "type": "string",
1398
+ "required": True,
1399
+ "description": "Repository name",
1400
+ },
1401
+ "labels": {
1402
+ "type": "array",
1403
+ "required": False,
1404
+ "items": {"type": "string"},
1405
+ "description": "Labels to apply to issues",
1406
+ },
1407
+ "assignees": {
1408
+ "type": "array",
1409
+ "required": False,
1410
+ "items": {"type": "string"},
1411
+ "description": "Users to assign",
1412
+ },
1413
+ }
1414
+
1415
+ async def send(
1416
+ self,
1417
+ message: str,
1418
+ event: NotificationEvent | None = None,
1419
+ title: str | None = None,
1420
+ **kwargs: Any,
1421
+ ) -> bool:
1422
+ """Create GitHub issue for notification.
1423
+
1424
+ Args:
1425
+ message: Issue body.
1426
+ event: Optional triggering event.
1427
+ title: Optional title override.
1428
+ **kwargs: Additional issue options.
1429
+
1430
+ Returns:
1431
+ True if issue was created.
1432
+ """
1433
+ token = self.config["token"]
1434
+ owner = self.config["owner"]
1435
+ repo = self.config["repo"]
1436
+
1437
+ # Build issue title
1438
+ if title is None:
1439
+ title = self._build_title(event)
1440
+
1441
+ # Build issue body
1442
+ body = self._build_body(message, event)
1443
+
1444
+ # Build payload
1445
+ payload: dict[str, Any] = {
1446
+ "title": title,
1447
+ "body": body,
1448
+ }
1449
+
1450
+ # Add optional fields
1451
+ labels = list(self.config.get("labels", [])) + ["truthound-alert"]
1452
+ payload["labels"] = labels
1453
+
1454
+ if self.config.get("assignees"):
1455
+ payload["assignees"] = self.config["assignees"]
1456
+
1457
+ headers = {
1458
+ "Authorization": f"Bearer {token}",
1459
+ "Accept": "application/vnd.github.v3+json",
1460
+ }
1461
+
1462
+ url = f"{self.API_URL}/repos/{owner}/{repo}/issues"
1463
+
1464
+ async with httpx.AsyncClient(timeout=30.0) as client:
1465
+ response = await client.post(url, json=payload, headers=headers)
1466
+ return response.status_code == 201
1467
+
1468
+ def _build_title(self, event: NotificationEvent | None) -> str:
1469
+ """Build issue title from event."""
1470
+ if event is None:
1471
+ return "[Truthound] Alert"
1472
+
1473
+ if isinstance(event, ValidationFailedEvent):
1474
+ return f"[Truthound] Validation Failed: {event.source_name or 'Unknown'}"
1475
+
1476
+ elif isinstance(event, ScheduleFailedEvent):
1477
+ return f"[Truthound] Schedule Failed: {event.schedule_name}"
1478
+
1479
+ elif isinstance(event, DriftDetectedEvent):
1480
+ return f"[Truthound] Drift Detected: {event.baseline_source_name}"
1481
+
1482
+ elif isinstance(event, TestNotificationEvent):
1483
+ return f"[Truthound] Test Issue: {event.channel_name}"
1484
+
1485
+ return f"[Truthound] {event.event_type}"
1486
+
1487
+ def _build_body(self, message: str, event: NotificationEvent | None) -> str:
1488
+ """Build issue body with markdown formatting."""
1489
+ body_parts = [
1490
+ "## Truthound Dashboard Alert",
1491
+ "",
1492
+ message,
1493
+ "",
1494
+ ]
1495
+
1496
+ if event:
1497
+ body_parts.extend([
1498
+ "## Details",
1499
+ "",
1500
+ f"- **Event Type:** `{event.event_type}`",
1501
+ f"- **Timestamp:** {event.timestamp.isoformat()}",
1502
+ ])
1503
+
1504
+ if event.source_id:
1505
+ body_parts.append(f"- **Source ID:** `{event.source_id}`")
1506
+ if event.source_name:
1507
+ body_parts.append(f"- **Source Name:** {event.source_name}")
1508
+
1509
+ if isinstance(event, ValidationFailedEvent):
1510
+ body_parts.extend([
1511
+ "",
1512
+ "### Validation Summary",
1513
+ "",
1514
+ f"| Metric | Value |",
1515
+ f"|--------|-------|",
1516
+ f"| Severity | {event.severity} |",
1517
+ f"| Total Issues | {event.total_issues} |",
1518
+ f"| Critical | {'Yes' if event.has_critical else 'No'} |",
1519
+ f"| High | {'Yes' if event.has_high else 'No'} |",
1520
+ f"| Validation ID | `{event.validation_id}` |",
1521
+ ])
1522
+
1523
+ elif isinstance(event, DriftDetectedEvent):
1524
+ body_parts.extend([
1525
+ "",
1526
+ "### Drift Summary",
1527
+ "",
1528
+ f"| Metric | Value |",
1529
+ f"|--------|-------|",
1530
+ f"| Baseline | {event.baseline_source_name} |",
1531
+ f"| Current | {event.current_source_name} |",
1532
+ f"| Drifted Columns | {event.drifted_columns}/{event.total_columns} |",
1533
+ f"| Drift % | {event.drift_percentage:.1f}% |",
1534
+ ])
1535
+
1536
+ body_parts.extend([
1537
+ "",
1538
+ "---",
1539
+ "_This issue was automatically created by truthound-dashboard._",
1540
+ ])
1541
+
1542
+ return "\n".join(body_parts)
1543
+
1544
+ def format_message(self, event: NotificationEvent) -> str:
1545
+ """Format message for GitHub issue body."""
1546
+ if isinstance(event, ValidationFailedEvent):
1547
+ return (
1548
+ f"A validation failure has been detected.\n\n"
1549
+ f"**Source:** {event.source_name or 'Unknown'}\n"
1550
+ f"**Severity:** {event.severity}\n"
1551
+ f"**Total Issues:** {event.total_issues}"
1552
+ )
1553
+
1554
+ elif isinstance(event, ScheduleFailedEvent):
1555
+ return (
1556
+ f"A scheduled validation has failed.\n\n"
1557
+ f"**Schedule:** {event.schedule_name}\n"
1558
+ f"**Source:** {event.source_name or 'Unknown'}\n"
1559
+ f"**Error:** {event.error_message or 'Validation failed'}"
1560
+ )
1561
+
1562
+ elif isinstance(event, DriftDetectedEvent):
1563
+ return (
1564
+ f"Data drift has been detected between datasets.\n\n"
1565
+ f"**Baseline:** {event.baseline_source_name}\n"
1566
+ f"**Current:** {event.current_source_name}\n"
1567
+ f"**Drift:** {event.drift_percentage:.1f}%"
1568
+ )
1569
+
1570
+ elif isinstance(event, TestNotificationEvent):
1571
+ return (
1572
+ f"This is a test issue created by truthound-dashboard.\n\n"
1573
+ f"**Channel:** {event.channel_name}"
1574
+ )
1575
+
1576
+ return self._default_format(event)