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
@@ -4,6 +4,9 @@ This module provides services for cross-feature integration between
4
4
  Anomaly Detection and Drift Monitoring alerts.
5
5
 
6
6
  When anomaly rates spike, it automatically checks for drift and vice versa.
7
+
8
+ NOTE: This module has been updated to use persistent DB storage instead of
9
+ in-memory storage for configurations, correlations, and trigger events.
7
10
  """
8
11
 
9
12
  from __future__ import annotations
@@ -13,12 +16,15 @@ import uuid
13
16
  from datetime import datetime, timedelta
14
17
  from typing import TYPE_CHECKING, Any
15
18
 
16
- from sqlalchemy import select, func, and_, or_
19
+ from sqlalchemy import select, func, and_, or_, delete
17
20
  from sqlalchemy.ext.asyncio import AsyncSession
18
21
 
19
22
  if TYPE_CHECKING:
20
23
  from truthound_dashboard.db.models import (
21
24
  AnomalyDetection,
25
+ CrossAlertConfig,
26
+ CrossAlertCorrelation,
27
+ CrossAlertTriggerEvent,
22
28
  DriftAlert,
23
29
  DriftComparison,
24
30
  Source,
@@ -27,28 +33,14 @@ if TYPE_CHECKING:
27
33
  logger = logging.getLogger(__name__)
28
34
 
29
35
 
30
- # In-memory config storage (would be DB in production)
31
- _global_config: dict[str, Any] = {
32
- "enabled": True,
33
- "trigger_drift_on_anomaly": True,
34
- "trigger_anomaly_on_drift": True,
35
- "thresholds": {
36
- "anomaly_rate_threshold": 0.1,
37
- "anomaly_count_threshold": 10,
38
- "drift_percentage_threshold": 10.0,
39
- "drift_columns_threshold": 2,
40
- },
41
- "notify_on_correlation": True,
42
- "notification_channel_ids": None,
43
- "cooldown_seconds": 300,
44
- "last_anomaly_trigger_at": None,
45
- "last_drift_trigger_at": None,
36
+ # Default thresholds for new configs
37
+ DEFAULT_THRESHOLDS = {
38
+ "anomaly_rate_threshold": 0.1,
39
+ "anomaly_count_threshold": 10,
40
+ "drift_percentage_threshold": 10.0,
41
+ "drift_columns_threshold": 2,
46
42
  }
47
43
 
48
- _source_configs: dict[str, dict[str, Any]] = {}
49
- _correlations: list[dict[str, Any]] = []
50
- _auto_trigger_events: list[dict[str, Any]] = []
51
-
52
44
 
53
45
  class CrossAlertService:
54
46
  """Service for cross-alert correlation between anomaly and drift detection.
@@ -58,6 +50,9 @@ class CrossAlertService:
58
50
  - Auto-triggering drift checks when anomalies spike
59
51
  - Auto-triggering anomaly checks when drift is detected
60
52
  - Managing auto-trigger configuration
53
+
54
+ All data is persisted to database using CrossAlertConfig,
55
+ CrossAlertCorrelation, and CrossAlertTriggerEvent models.
61
56
  """
62
57
 
63
58
  def __init__(self, session: AsyncSession) -> None:
@@ -68,6 +63,172 @@ class CrossAlertService:
68
63
  """
69
64
  self.session = session
70
65
 
66
+ # =========================================================================
67
+ # Configuration Management (DB-backed)
68
+ # =========================================================================
69
+
70
+ async def get_config(self, source_id: str | None = None) -> dict[str, Any]:
71
+ """Get auto-trigger configuration from database.
72
+
73
+ Args:
74
+ source_id: Source ID for source-specific config, None for global.
75
+
76
+ Returns:
77
+ Configuration dictionary.
78
+ """
79
+ from truthound_dashboard.db.models import CrossAlertConfig
80
+
81
+ # Try to get source-specific config
82
+ if source_id:
83
+ result = await self.session.execute(
84
+ select(CrossAlertConfig).where(
85
+ CrossAlertConfig.source_id == source_id
86
+ )
87
+ )
88
+ config = result.scalar_one_or_none()
89
+ if config:
90
+ return self._config_to_dict(config)
91
+
92
+ # Fall back to global config (source_id is None)
93
+ result = await self.session.execute(
94
+ select(CrossAlertConfig).where(CrossAlertConfig.source_id.is_(None))
95
+ )
96
+ config = result.scalar_one_or_none()
97
+
98
+ if config:
99
+ return self._config_to_dict(config)
100
+
101
+ # Return defaults if no config exists
102
+ now = datetime.utcnow()
103
+ return {
104
+ "id": str(uuid.uuid4()),
105
+ "source_id": source_id,
106
+ "enabled": True,
107
+ "trigger_drift_on_anomaly": True,
108
+ "trigger_anomaly_on_drift": True,
109
+ "thresholds": DEFAULT_THRESHOLDS.copy(),
110
+ "notify_on_correlation": True,
111
+ "notification_channel_ids": None,
112
+ "cooldown_seconds": 300,
113
+ "last_anomaly_trigger_at": None,
114
+ "last_drift_trigger_at": None,
115
+ "created_at": now,
116
+ "updated_at": now,
117
+ }
118
+
119
+ def _config_to_dict(self, config: "CrossAlertConfig") -> dict[str, Any]:
120
+ """Convert CrossAlertConfig model to dictionary."""
121
+ return {
122
+ "id": config.id,
123
+ "source_id": config.source_id,
124
+ "enabled": config.enabled,
125
+ "trigger_drift_on_anomaly": config.trigger_drift_on_anomaly,
126
+ "trigger_anomaly_on_drift": config.trigger_anomaly_on_drift,
127
+ "thresholds": config.thresholds or DEFAULT_THRESHOLDS.copy(),
128
+ "notify_on_correlation": config.notify_on_correlation,
129
+ "notification_channel_ids": config.notification_channel_ids,
130
+ "cooldown_seconds": config.cooldown_seconds,
131
+ "last_anomaly_trigger_at": (
132
+ config.last_anomaly_trigger_at.isoformat()
133
+ if config.last_anomaly_trigger_at
134
+ else None
135
+ ),
136
+ "last_drift_trigger_at": (
137
+ config.last_drift_trigger_at.isoformat()
138
+ if config.last_drift_trigger_at
139
+ else None
140
+ ),
141
+ "created_at": config.created_at.isoformat() if config.created_at else None,
142
+ "updated_at": config.updated_at.isoformat() if config.updated_at else None,
143
+ }
144
+
145
+ async def update_config(
146
+ self,
147
+ source_id: str | None = None,
148
+ **kwargs,
149
+ ) -> dict[str, Any]:
150
+ """Update auto-trigger configuration in database.
151
+
152
+ Args:
153
+ source_id: Source ID for source-specific config, None for global.
154
+ **kwargs: Configuration fields to update.
155
+
156
+ Returns:
157
+ Updated configuration dictionary.
158
+ """
159
+ from truthound_dashboard.db.models import CrossAlertConfig
160
+
161
+ # Try to find existing config
162
+ if source_id:
163
+ result = await self.session.execute(
164
+ select(CrossAlertConfig).where(
165
+ CrossAlertConfig.source_id == source_id
166
+ )
167
+ )
168
+ else:
169
+ result = await self.session.execute(
170
+ select(CrossAlertConfig).where(CrossAlertConfig.source_id.is_(None))
171
+ )
172
+ config = result.scalar_one_or_none()
173
+
174
+ if config:
175
+ # Update existing config
176
+ for key, value in kwargs.items():
177
+ if value is not None and hasattr(config, key):
178
+ setattr(config, key, value)
179
+ config.updated_at = datetime.utcnow()
180
+ else:
181
+ # Create new config
182
+ config = CrossAlertConfig(
183
+ source_id=source_id,
184
+ enabled=kwargs.get("enabled", True),
185
+ trigger_drift_on_anomaly=kwargs.get("trigger_drift_on_anomaly", True),
186
+ trigger_anomaly_on_drift=kwargs.get("trigger_anomaly_on_drift", True),
187
+ thresholds=kwargs.get("thresholds", DEFAULT_THRESHOLDS.copy()),
188
+ notify_on_correlation=kwargs.get("notify_on_correlation", True),
189
+ notification_channel_ids=kwargs.get("notification_channel_ids"),
190
+ cooldown_seconds=kwargs.get("cooldown_seconds", 300),
191
+ )
192
+ self.session.add(config)
193
+
194
+ await self.session.flush()
195
+ return self._config_to_dict(config)
196
+
197
+ async def _update_last_trigger_time(
198
+ self,
199
+ source_id: str,
200
+ trigger_type: str,
201
+ ) -> None:
202
+ """Update the last trigger time for a source.
203
+
204
+ Args:
205
+ source_id: Source ID.
206
+ trigger_type: Either 'anomaly' or 'drift'.
207
+ """
208
+ from truthound_dashboard.db.models import CrossAlertConfig
209
+
210
+ result = await self.session.execute(
211
+ select(CrossAlertConfig).where(
212
+ CrossAlertConfig.source_id == source_id
213
+ )
214
+ )
215
+ config = result.scalar_one_or_none()
216
+
217
+ if not config:
218
+ # Create source-specific config with just the trigger time
219
+ config = CrossAlertConfig(
220
+ source_id=source_id,
221
+ thresholds=DEFAULT_THRESHOLDS.copy(),
222
+ )
223
+ self.session.add(config)
224
+
225
+ if trigger_type == "anomaly":
226
+ config.last_anomaly_trigger_at = datetime.utcnow()
227
+ else:
228
+ config.last_drift_trigger_at = datetime.utcnow()
229
+
230
+ await self.session.flush()
231
+
71
232
  # =========================================================================
72
233
  # Correlation Analysis
73
234
  # =========================================================================
@@ -78,6 +239,7 @@ class CrossAlertService:
78
239
  *,
79
240
  time_window_hours: int = 24,
80
241
  limit: int = 50,
242
+ save_correlations: bool = True,
81
243
  ) -> list[dict[str, Any]]:
82
244
  """Find correlated anomaly and drift alerts for a source.
83
245
 
@@ -85,12 +247,14 @@ class CrossAlertService:
85
247
  source_id: Data source ID.
86
248
  time_window_hours: Time window to look for correlations.
87
249
  limit: Maximum correlations to return.
250
+ save_correlations: Whether to save found correlations to DB.
88
251
 
89
252
  Returns:
90
253
  List of correlation dictionaries.
91
254
  """
92
255
  from truthound_dashboard.db.models import (
93
256
  AnomalyDetection,
257
+ CrossAlertCorrelation,
94
258
  DriftAlert,
95
259
  DriftComparison,
96
260
  Source,
@@ -121,32 +285,37 @@ class CrossAlertService:
121
285
  )
122
286
  anomaly_detections = list(anomaly_result.scalars().all())
123
287
 
124
- # Get recent drift alerts for this source
125
- drift_result = await self.session.execute(
126
- select(DriftAlert)
127
- .where(
128
- and_(
129
- DriftAlert.created_at >= since,
288
+ # Get recent drift alerts for this source via DriftMonitor
289
+ from truthound_dashboard.db.models import DriftMonitor
290
+
291
+ # Find monitors related to this source
292
+ monitor_result = await self.session.execute(
293
+ select(DriftMonitor).where(
294
+ or_(
295
+ DriftMonitor.baseline_source_id == source_id,
296
+ DriftMonitor.current_source_id == source_id,
130
297
  )
131
298
  )
132
- .order_by(DriftAlert.created_at.desc())
133
- .limit(100)
134
299
  )
135
- drift_alerts = list(drift_result.scalars().all())
136
-
137
- # Filter drift alerts related to this source
138
- related_drift_alerts = []
139
- for alert in drift_alerts:
140
- # Get the comparison to check source IDs
141
- comp_result = await self.session.execute(
142
- select(DriftComparison).where(DriftComparison.id == alert.comparison_id)
300
+ monitors = {m.id: m for m in monitor_result.scalars().all()}
301
+
302
+ related_drift_alerts: list[tuple[Any, Any]] = []
303
+ if monitors:
304
+ drift_result = await self.session.execute(
305
+ select(DriftAlert)
306
+ .where(
307
+ and_(
308
+ DriftAlert.monitor_id.in_(list(monitors.keys())),
309
+ DriftAlert.created_at >= since,
310
+ )
311
+ )
312
+ .order_by(DriftAlert.created_at.desc())
313
+ .limit(100)
143
314
  )
144
- comparison = comp_result.scalar_one_or_none()
145
- if comparison and (
146
- comparison.baseline_source_id == source_id
147
- or comparison.current_source_id == source_id
148
- ):
149
- related_drift_alerts.append((alert, comparison))
315
+ for alert in drift_result.scalars().all():
316
+ monitor = monitors.get(alert.monitor_id)
317
+ if monitor:
318
+ related_drift_alerts.append((alert, monitor))
150
319
 
151
320
  # Find correlations
152
321
  correlations = []
@@ -174,52 +343,76 @@ class CrossAlertService:
174
343
 
175
344
  # Find common columns
176
345
  anomaly_cols = detection.columns_analyzed or []
177
- drift_cols = alert.drifted_columns_json or []
346
+ drift_cols = alert.affected_columns or []
178
347
  common_cols = list(set(anomaly_cols) & set(drift_cols))
179
348
 
349
+ correlation_id = str(uuid.uuid4())
350
+ confidence = self._calculate_confidence(
351
+ detection, alert, common_cols, time_delta
352
+ )
353
+
354
+ anomaly_data = {
355
+ "alert_id": detection.id,
356
+ "alert_type": "anomaly",
357
+ "source_id": source_id,
358
+ "source_name": source_name,
359
+ "severity": self._anomaly_severity(detection.anomaly_rate),
360
+ "message": f"Detected {detection.anomaly_count} anomalies ({detection.anomaly_rate * 100:.1f}% rate)",
361
+ "created_at": detection.created_at.isoformat(),
362
+ "anomaly_rate": detection.anomaly_rate,
363
+ "anomaly_count": detection.anomaly_count,
364
+ }
365
+
366
+ drift_data = {
367
+ "alert_id": alert.id,
368
+ "alert_type": "drift",
369
+ "source_id": source_id,
370
+ "source_name": source_name,
371
+ "severity": alert.severity,
372
+ "message": alert.message,
373
+ "created_at": alert.created_at.isoformat(),
374
+ "drift_percentage": alert.drift_score,
375
+ "drifted_columns": alert.affected_columns or [],
376
+ }
377
+
180
378
  correlation = {
181
- "id": str(uuid.uuid4()),
379
+ "id": correlation_id,
182
380
  "source_id": source_id,
183
381
  "source_name": source_name,
184
382
  "correlation_strength": strength,
185
- "confidence_score": self._calculate_confidence(
186
- detection, alert, common_cols, time_delta
187
- ),
383
+ "confidence_score": confidence,
188
384
  "time_delta_seconds": int(time_delta),
189
- "anomaly_alert": {
190
- "alert_id": detection.id,
191
- "alert_type": "anomaly",
192
- "source_id": source_id,
193
- "source_name": source_name,
194
- "severity": self._anomaly_severity(detection.anomaly_rate),
195
- "message": f"Detected {detection.anomaly_count} anomalies ({detection.anomaly_rate * 100:.1f}% rate)",
196
- "created_at": detection.created_at.isoformat(),
197
- "anomaly_rate": detection.anomaly_rate,
198
- "anomaly_count": detection.anomaly_count,
199
- "drift_percentage": None,
200
- "drifted_columns": None,
201
- },
202
- "drift_alert": {
203
- "alert_id": alert.id,
204
- "alert_type": "drift",
205
- "source_id": source_id,
206
- "source_name": source_name,
207
- "severity": alert.severity,
208
- "message": alert.message,
209
- "created_at": alert.created_at.isoformat(),
210
- "anomaly_rate": None,
211
- "anomaly_count": None,
212
- "drift_percentage": alert.drift_percentage,
213
- "drifted_columns": alert.drifted_columns_json,
214
- },
385
+ "anomaly_alert": anomaly_data,
386
+ "drift_alert": drift_data,
215
387
  "common_columns": common_cols,
216
388
  "suggested_action": self._suggest_action(strength, common_cols),
217
389
  "notes": None,
218
390
  "created_at": datetime.utcnow().isoformat(),
219
391
  "updated_at": datetime.utcnow().isoformat(),
220
392
  }
393
+
394
+ # Save to database
395
+ if save_correlations:
396
+ db_correlation = CrossAlertCorrelation(
397
+ id=correlation_id,
398
+ source_id=source_id,
399
+ correlation_strength=strength,
400
+ confidence_score=confidence,
401
+ time_delta_seconds=int(time_delta),
402
+ anomaly_alert_id=detection.id,
403
+ drift_alert_id=alert.id,
404
+ anomaly_data=anomaly_data,
405
+ drift_data=drift_data,
406
+ common_columns=common_cols,
407
+ suggested_action=self._suggest_action(strength, common_cols),
408
+ )
409
+ self.session.add(db_correlation)
410
+
221
411
  correlations.append(correlation)
222
412
 
413
+ if save_correlations and correlations:
414
+ await self.session.flush()
415
+
223
416
  # Sort by confidence score and limit
224
417
  correlations.sort(key=lambda x: x["confidence_score"], reverse=True)
225
418
  return correlations[:limit]
@@ -230,16 +423,7 @@ class CrossAlertService:
230
423
  alert: "DriftAlert",
231
424
  time_delta: float,
232
425
  ) -> str:
233
- """Calculate correlation strength between anomaly and drift.
234
-
235
- Args:
236
- detection: Anomaly detection result.
237
- alert: Drift alert.
238
- time_delta: Time difference in seconds.
239
-
240
- Returns:
241
- Correlation strength: strong, moderate, weak, or none.
242
- """
426
+ """Calculate correlation strength between anomaly and drift."""
243
427
  score = 0
244
428
 
245
429
  # Time proximity (closer = stronger)
@@ -290,17 +474,7 @@ class CrossAlertService:
290
474
  common_cols: list[str],
291
475
  time_delta: float,
292
476
  ) -> float:
293
- """Calculate confidence score for correlation.
294
-
295
- Args:
296
- detection: Anomaly detection result.
297
- alert: Drift alert.
298
- common_cols: Columns affected by both.
299
- time_delta: Time difference in seconds.
300
-
301
- Returns:
302
- Confidence score between 0 and 1.
303
- """
477
+ """Calculate confidence score for correlation."""
304
478
  confidence = 0.5 # Base confidence
305
479
 
306
480
  # Time proximity bonus
@@ -335,15 +509,7 @@ class CrossAlertService:
335
509
  return "low"
336
510
 
337
511
  def _suggest_action(self, strength: str, common_cols: list[str]) -> str:
338
- """Suggest action based on correlation.
339
-
340
- Args:
341
- strength: Correlation strength.
342
- common_cols: Common affected columns.
343
-
344
- Returns:
345
- Suggested action string.
346
- """
512
+ """Suggest action based on correlation."""
347
513
  if strength == "strong":
348
514
  if common_cols:
349
515
  cols = ", ".join(common_cols[:3])
@@ -362,15 +528,12 @@ class CrossAlertService:
362
528
  self,
363
529
  detection_id: str,
364
530
  ) -> dict[str, Any] | None:
365
- """Auto-trigger drift check when anomaly detection shows high rate.
366
-
367
- Args:
368
- detection_id: Anomaly detection ID.
369
-
370
- Returns:
371
- Trigger event result or None if skipped.
372
- """
373
- from truthound_dashboard.db.models import AnomalyDetection, DriftMonitor
531
+ """Auto-trigger drift check when anomaly detection shows high rate."""
532
+ from truthound_dashboard.db.models import (
533
+ AnomalyDetection,
534
+ CrossAlertTriggerEvent,
535
+ DriftMonitor,
536
+ )
374
537
  from truthound_dashboard.core.drift_monitor import DriftMonitorService
375
538
 
376
539
  # Get detection
@@ -383,9 +546,9 @@ class CrossAlertService:
383
546
  return None
384
547
 
385
548
  # Get config
386
- config = self.get_config(detection.source_id)
549
+ config = await self.get_config(detection.source_id)
387
550
  if not config.get("enabled") or not config.get("trigger_drift_on_anomaly"):
388
- return self._create_skip_event(
551
+ return await self._create_skip_event(
389
552
  detection.source_id,
390
553
  "anomaly_to_drift",
391
554
  detection_id,
@@ -402,7 +565,7 @@ class CrossAlertService:
402
565
  count = detection.anomaly_count or 0
403
566
 
404
567
  if rate < rate_threshold and count < count_threshold:
405
- return self._create_skip_event(
568
+ return await self._create_skip_event(
406
569
  detection.source_id,
407
570
  "anomaly_to_drift",
408
571
  detection_id,
@@ -412,13 +575,12 @@ class CrossAlertService:
412
575
 
413
576
  # Check cooldown
414
577
  cooldown = config.get("cooldown_seconds", 300)
415
- last_trigger = config.get("last_anomaly_trigger_at")
416
- if last_trigger:
417
- if isinstance(last_trigger, str):
418
- last_trigger = datetime.fromisoformat(last_trigger)
578
+ last_trigger_str = config.get("last_anomaly_trigger_at")
579
+ if last_trigger_str:
580
+ last_trigger = datetime.fromisoformat(last_trigger_str)
419
581
  elapsed = (datetime.utcnow() - last_trigger).total_seconds()
420
582
  if elapsed < cooldown:
421
- return self._create_skip_event(
583
+ return await self._create_skip_event(
422
584
  detection.source_id,
423
585
  "anomaly_to_drift",
424
586
  detection_id,
@@ -439,37 +601,35 @@ class CrossAlertService:
439
601
  )
440
602
  monitor = monitor_result.scalar_one_or_none()
441
603
 
442
- event = {
443
- "id": str(uuid.uuid4()),
444
- "source_id": detection.source_id,
445
- "trigger_type": "anomaly_to_drift",
446
- "trigger_alert_id": detection_id,
447
- "trigger_alert_type": "anomaly",
448
- "result_id": None,
449
- "correlation_found": False,
450
- "correlation_id": None,
451
- "status": "pending",
452
- "error_message": None,
453
- "skipped_reason": None,
454
- "created_at": datetime.utcnow().isoformat(),
455
- "updated_at": datetime.utcnow().isoformat(),
456
- }
604
+ # Create event record
605
+ event_id = str(uuid.uuid4())
606
+ event = CrossAlertTriggerEvent(
607
+ id=event_id,
608
+ source_id=detection.source_id,
609
+ trigger_type="anomaly_to_drift",
610
+ trigger_alert_id=detection_id,
611
+ trigger_alert_type="anomaly",
612
+ status="pending",
613
+ )
614
+ self.session.add(event)
457
615
 
458
616
  if not monitor:
459
- event["status"] = "skipped"
460
- event["skipped_reason"] = "No drift monitor configured for this source"
461
- _auto_trigger_events.append(event)
462
- return event
617
+ event.status = "skipped"
618
+ event.skipped_reason = "No drift monitor configured for this source"
619
+ await self.session.flush()
620
+ return self._event_to_dict(event)
463
621
 
464
622
  try:
465
623
  # Run the drift monitor
466
- event["status"] = "running"
624
+ event.status = "running"
625
+ await self.session.flush()
626
+
467
627
  drift_service = DriftMonitorService(self.session)
468
628
  comparison = await drift_service.run_monitor(monitor.id)
469
629
 
470
630
  if comparison:
471
- event["status"] = "completed"
472
- event["result_id"] = comparison.id
631
+ event.status = "completed"
632
+ event.result_id = comparison.id
473
633
 
474
634
  # Check for correlation
475
635
  if comparison.has_drift:
@@ -477,39 +637,33 @@ class CrossAlertService:
477
637
  detection.source_id, time_window_hours=1
478
638
  )
479
639
  if correlations:
480
- event["correlation_found"] = True
481
- event["correlation_id"] = correlations[0]["id"]
640
+ event.correlation_found = True
641
+ event.correlation_id = correlations[0]["id"]
482
642
  else:
483
- event["status"] = "failed"
484
- event["error_message"] = "Drift monitor run failed"
643
+ event.status = "failed"
644
+ event.error_message = "Drift monitor run failed"
485
645
 
486
646
  except Exception as e:
487
- event["status"] = "failed"
488
- event["error_message"] = str(e)
647
+ event.status = "failed"
648
+ event.error_message = str(e)
489
649
  logger.error(f"Auto-trigger drift check failed: {e}")
490
650
 
491
651
  # Update last trigger time
492
- self._update_config(
493
- detection.source_id,
494
- {"last_anomaly_trigger_at": datetime.utcnow().isoformat()},
495
- )
652
+ await self._update_last_trigger_time(detection.source_id, "anomaly")
653
+ await self.session.flush()
496
654
 
497
- _auto_trigger_events.append(event)
498
- return event
655
+ return self._event_to_dict(event)
499
656
 
500
657
  async def auto_trigger_anomaly_on_drift(
501
658
  self,
502
659
  monitor_id: str,
503
660
  ) -> dict[str, Any] | None:
504
- """Auto-trigger anomaly check when drift is detected.
505
-
506
- Args:
507
- monitor_id: Drift monitor ID.
508
-
509
- Returns:
510
- Trigger event result or None if skipped.
511
- """
512
- from truthound_dashboard.db.models import DriftMonitor, DriftAlert
661
+ """Auto-trigger anomaly check when drift is detected."""
662
+ from truthound_dashboard.db.models import (
663
+ CrossAlertTriggerEvent,
664
+ DriftAlert,
665
+ DriftMonitor,
666
+ )
513
667
  from truthound_dashboard.core.anomaly import AnomalyDetectionService
514
668
 
515
669
  # Get monitor
@@ -536,9 +690,9 @@ class CrossAlertService:
536
690
  return None
537
691
 
538
692
  # Get config
539
- config = self.get_config(source_id)
693
+ config = await self.get_config(source_id)
540
694
  if not config.get("enabled") or not config.get("trigger_anomaly_on_drift"):
541
- return self._create_skip_event(
695
+ return await self._create_skip_event(
542
696
  source_id,
543
697
  "drift_to_anomaly",
544
698
  alert.id,
@@ -555,7 +709,7 @@ class CrossAlertService:
555
709
  cols_count = len(alert.drifted_columns_json or [])
556
710
 
557
711
  if drift_pct < drift_threshold and cols_count < cols_threshold:
558
- return self._create_skip_event(
712
+ return await self._create_skip_event(
559
713
  source_id,
560
714
  "drift_to_anomaly",
561
715
  alert.id,
@@ -565,13 +719,12 @@ class CrossAlertService:
565
719
 
566
720
  # Check cooldown
567
721
  cooldown = config.get("cooldown_seconds", 300)
568
- last_trigger = config.get("last_drift_trigger_at")
569
- if last_trigger:
570
- if isinstance(last_trigger, str):
571
- last_trigger = datetime.fromisoformat(last_trigger)
722
+ last_trigger_str = config.get("last_drift_trigger_at")
723
+ if last_trigger_str:
724
+ last_trigger = datetime.fromisoformat(last_trigger_str)
572
725
  elapsed = (datetime.utcnow() - last_trigger).total_seconds()
573
726
  if elapsed < cooldown:
574
- return self._create_skip_event(
727
+ return await self._create_skip_event(
575
728
  source_id,
576
729
  "drift_to_anomaly",
577
730
  alert.id,
@@ -579,25 +732,23 @@ class CrossAlertService:
579
732
  f"Cooldown active ({int(cooldown - elapsed)}s remaining)",
580
733
  )
581
734
 
582
- event = {
583
- "id": str(uuid.uuid4()),
584
- "source_id": source_id,
585
- "trigger_type": "drift_to_anomaly",
586
- "trigger_alert_id": alert.id,
587
- "trigger_alert_type": "drift",
588
- "result_id": None,
589
- "correlation_found": False,
590
- "correlation_id": None,
591
- "status": "pending",
592
- "error_message": None,
593
- "skipped_reason": None,
594
- "created_at": datetime.utcnow().isoformat(),
595
- "updated_at": datetime.utcnow().isoformat(),
596
- }
735
+ # Create event record
736
+ event_id = str(uuid.uuid4())
737
+ event = CrossAlertTriggerEvent(
738
+ id=event_id,
739
+ source_id=source_id,
740
+ trigger_type="drift_to_anomaly",
741
+ trigger_alert_id=alert.id,
742
+ trigger_alert_type="drift",
743
+ status="pending",
744
+ )
745
+ self.session.add(event)
597
746
 
598
747
  try:
599
748
  # Run anomaly detection
600
- event["status"] = "running"
749
+ event.status = "running"
750
+ await self.session.flush()
751
+
601
752
  anomaly_service = AnomalyDetectionService(self.session)
602
753
 
603
754
  detection = await anomaly_service.create_detection(
@@ -607,8 +758,8 @@ class CrossAlertService:
607
758
  )
608
759
  detection = await anomaly_service.run_detection(detection.id)
609
760
 
610
- event["status"] = "completed"
611
- event["result_id"] = detection.id
761
+ event.status = "completed"
762
+ event.result_id = detection.id
612
763
 
613
764
  # Check for correlation
614
765
  if detection.anomaly_count and detection.anomaly_count > 0:
@@ -616,24 +767,21 @@ class CrossAlertService:
616
767
  source_id, time_window_hours=1
617
768
  )
618
769
  if correlations:
619
- event["correlation_found"] = True
620
- event["correlation_id"] = correlations[0]["id"]
770
+ event.correlation_found = True
771
+ event.correlation_id = correlations[0]["id"]
621
772
 
622
773
  except Exception as e:
623
- event["status"] = "failed"
624
- event["error_message"] = str(e)
774
+ event.status = "failed"
775
+ event.error_message = str(e)
625
776
  logger.error(f"Auto-trigger anomaly check failed: {e}")
626
777
 
627
778
  # Update last trigger time
628
- self._update_config(
629
- source_id,
630
- {"last_drift_trigger_at": datetime.utcnow().isoformat()},
631
- )
779
+ await self._update_last_trigger_time(source_id, "drift")
780
+ await self.session.flush()
632
781
 
633
- _auto_trigger_events.append(event)
634
- return event
782
+ return self._event_to_dict(event)
635
783
 
636
- def _create_skip_event(
784
+ async def _create_skip_event(
637
785
  self,
638
786
  source_id: str,
639
787
  trigger_type: str,
@@ -641,82 +789,41 @@ class CrossAlertService:
641
789
  alert_type: str,
642
790
  reason: str,
643
791
  ) -> dict[str, Any]:
644
- """Create a skipped trigger event."""
645
- event = {
646
- "id": str(uuid.uuid4()),
647
- "source_id": source_id,
648
- "trigger_type": trigger_type,
649
- "trigger_alert_id": alert_id,
650
- "trigger_alert_type": alert_type,
651
- "result_id": None,
652
- "correlation_found": False,
653
- "correlation_id": None,
654
- "status": "skipped",
655
- "error_message": None,
656
- "skipped_reason": reason,
657
- "created_at": datetime.utcnow().isoformat(),
658
- "updated_at": datetime.utcnow().isoformat(),
659
- }
660
- _auto_trigger_events.append(event)
661
- return event
662
-
663
- # =========================================================================
664
- # Configuration Management
665
- # =========================================================================
666
-
667
- def get_config(self, source_id: str | None = None) -> dict[str, Any]:
668
- """Get auto-trigger configuration.
669
-
670
- Args:
671
- source_id: Source ID for source-specific config, None for global.
672
-
673
- Returns:
674
- Configuration dictionary.
675
- """
676
- if source_id and source_id in _source_configs:
677
- # Merge source config with global defaults
678
- config = _global_config.copy()
679
- config.update(_source_configs[source_id])
680
- return config
681
- return _global_config.copy()
682
-
683
- def update_config(
684
- self,
685
- source_id: str | None = None,
686
- **kwargs,
687
- ) -> dict[str, Any]:
688
- """Update auto-trigger configuration.
792
+ """Create a skipped trigger event and save to DB."""
793
+ from truthound_dashboard.db.models import CrossAlertTriggerEvent
794
+
795
+ event = CrossAlertTriggerEvent(
796
+ source_id=source_id,
797
+ trigger_type=trigger_type,
798
+ trigger_alert_id=alert_id,
799
+ trigger_alert_type=alert_type,
800
+ status="skipped",
801
+ skipped_reason=reason,
802
+ )
803
+ self.session.add(event)
804
+ await self.session.flush()
689
805
 
690
- Args:
691
- source_id: Source ID for source-specific config, None for global.
692
- **kwargs: Configuration fields to update.
806
+ return self._event_to_dict(event)
693
807
 
694
- Returns:
695
- Updated configuration.
696
- """
697
- return self._update_config(source_id, kwargs)
698
-
699
- def _update_config(
700
- self,
701
- source_id: str | None,
702
- updates: dict[str, Any],
703
- ) -> dict[str, Any]:
704
- """Internal method to update config."""
705
- if source_id:
706
- if source_id not in _source_configs:
707
- _source_configs[source_id] = {}
708
- for key, value in updates.items():
709
- if value is not None:
710
- _source_configs[source_id][key] = value
711
- return self.get_config(source_id)
712
- else:
713
- for key, value in updates.items():
714
- if value is not None:
715
- _global_config[key] = value
716
- return _global_config.copy()
808
+ def _event_to_dict(self, event: "CrossAlertTriggerEvent") -> dict[str, Any]:
809
+ """Convert CrossAlertTriggerEvent model to dictionary."""
810
+ return {
811
+ "id": event.id,
812
+ "source_id": event.source_id,
813
+ "trigger_type": event.trigger_type,
814
+ "trigger_alert_id": event.trigger_alert_id,
815
+ "trigger_alert_type": event.trigger_alert_type,
816
+ "result_id": event.result_id,
817
+ "correlation_found": event.correlation_found,
818
+ "correlation_id": event.correlation_id,
819
+ "status": event.status,
820
+ "error_message": event.error_message,
821
+ "skipped_reason": event.skipped_reason,
822
+ "created_at": event.created_at.isoformat() if event.created_at else None,
823
+ }
717
824
 
718
825
  # =========================================================================
719
- # Query Operations
826
+ # Query Operations (DB-backed)
720
827
  # =========================================================================
721
828
 
722
829
  async def get_correlations(
@@ -726,7 +833,7 @@ class CrossAlertService:
726
833
  limit: int = 50,
727
834
  offset: int = 0,
728
835
  ) -> tuple[list[dict[str, Any]], int]:
729
- """Get correlation records.
836
+ """Get correlation records from database.
730
837
 
731
838
  Args:
732
839
  source_id: Filter by source ID.
@@ -736,14 +843,63 @@ class CrossAlertService:
736
843
  Returns:
737
844
  Tuple of (correlations, total_count).
738
845
  """
846
+ from truthound_dashboard.db.models import CrossAlertCorrelation, Source
847
+
848
+ # Build query
849
+ query = select(CrossAlertCorrelation)
850
+ count_query = select(func.count(CrossAlertCorrelation.id))
851
+
739
852
  if source_id:
740
- filtered = [c for c in _correlations if c.get("source_id") == source_id]
741
- else:
742
- filtered = _correlations.copy()
853
+ query = query.where(CrossAlertCorrelation.source_id == source_id)
854
+ count_query = count_query.where(
855
+ CrossAlertCorrelation.source_id == source_id
856
+ )
743
857
 
744
- total = len(filtered)
745
- paginated = filtered[offset : offset + limit]
746
- return paginated, total
858
+ # Get total count
859
+ count_result = await self.session.execute(count_query)
860
+ total = count_result.scalar() or 0
861
+
862
+ # Get paginated results
863
+ query = (
864
+ query.order_by(CrossAlertCorrelation.created_at.desc())
865
+ .offset(offset)
866
+ .limit(limit)
867
+ )
868
+ result = await self.session.execute(query)
869
+ correlations = result.scalars().all()
870
+
871
+ # Convert to dicts with source names
872
+ items = []
873
+ for corr in correlations:
874
+ # Get source name
875
+ source_result = await self.session.execute(
876
+ select(Source).where(Source.id == corr.source_id)
877
+ )
878
+ source = source_result.scalar_one_or_none()
879
+ source_name = source.name if source else None
880
+
881
+ items.append({
882
+ "id": corr.id,
883
+ "source_id": corr.source_id,
884
+ "source_name": source_name,
885
+ "correlation_strength": corr.correlation_strength,
886
+ "confidence_score": corr.confidence_score,
887
+ "time_delta_seconds": corr.time_delta_seconds,
888
+ "anomaly_alert": corr.anomaly_data or {
889
+ "alert_id": corr.anomaly_alert_id,
890
+ "alert_type": "anomaly",
891
+ },
892
+ "drift_alert": corr.drift_data or {
893
+ "alert_id": corr.drift_alert_id,
894
+ "alert_type": "drift",
895
+ },
896
+ "common_columns": corr.common_columns,
897
+ "suggested_action": corr.suggested_action,
898
+ "notes": corr.notes,
899
+ "created_at": corr.created_at.isoformat() if corr.created_at else None,
900
+ })
901
+
902
+ return items, total
747
903
 
748
904
  async def get_auto_trigger_events(
749
905
  self,
@@ -752,7 +908,7 @@ class CrossAlertService:
752
908
  limit: int = 50,
753
909
  offset: int = 0,
754
910
  ) -> tuple[list[dict[str, Any]], int]:
755
- """Get auto-trigger event records.
911
+ """Get auto-trigger event records from database.
756
912
 
757
913
  Args:
758
914
  source_id: Filter by source ID.
@@ -762,76 +918,140 @@ class CrossAlertService:
762
918
  Returns:
763
919
  Tuple of (events, total_count).
764
920
  """
921
+ from truthound_dashboard.db.models import CrossAlertTriggerEvent
922
+
923
+ # Build query
924
+ query = select(CrossAlertTriggerEvent)
925
+ count_query = select(func.count(CrossAlertTriggerEvent.id))
926
+
765
927
  if source_id:
766
- filtered = [
767
- e for e in _auto_trigger_events if e.get("source_id") == source_id
768
- ]
769
- else:
770
- filtered = _auto_trigger_events.copy()
928
+ query = query.where(CrossAlertTriggerEvent.source_id == source_id)
929
+ count_query = count_query.where(
930
+ CrossAlertTriggerEvent.source_id == source_id
931
+ )
771
932
 
772
- # Sort by created_at desc
773
- filtered.sort(key=lambda x: x.get("created_at", ""), reverse=True)
933
+ # Get total count
934
+ count_result = await self.session.execute(count_query)
935
+ total = count_result.scalar() or 0
774
936
 
775
- total = len(filtered)
776
- paginated = filtered[offset : offset + limit]
777
- return paginated, total
937
+ # Get paginated results
938
+ query = (
939
+ query.order_by(CrossAlertTriggerEvent.created_at.desc())
940
+ .offset(offset)
941
+ .limit(limit)
942
+ )
943
+ result = await self.session.execute(query)
944
+ events = result.scalars().all()
945
+
946
+ items = [self._event_to_dict(event) for event in events]
947
+ return items, total
778
948
 
779
949
  async def get_summary(self) -> dict[str, Any]:
780
- """Get cross-alert summary statistics.
950
+ """Get cross-alert summary statistics from database.
781
951
 
782
952
  Returns:
783
953
  Summary dictionary.
784
954
  """
955
+ from truthound_dashboard.db.models import (
956
+ CrossAlertConfig,
957
+ CrossAlertCorrelation,
958
+ CrossAlertTriggerEvent,
959
+ Source,
960
+ )
961
+
785
962
  now = datetime.utcnow()
786
963
  last_24h = now - timedelta(hours=24)
787
964
 
788
965
  # Count correlations by strength
789
- strong = sum(1 for c in _correlations if c.get("correlation_strength") == "strong")
790
- moderate = sum(1 for c in _correlations if c.get("correlation_strength") == "moderate")
791
- weak = sum(1 for c in _correlations if c.get("correlation_strength") == "weak")
966
+ strong_result = await self.session.execute(
967
+ select(func.count(CrossAlertCorrelation.id)).where(
968
+ CrossAlertCorrelation.correlation_strength == "strong"
969
+ )
970
+ )
971
+ strong = strong_result.scalar() or 0
972
+
973
+ moderate_result = await self.session.execute(
974
+ select(func.count(CrossAlertCorrelation.id)).where(
975
+ CrossAlertCorrelation.correlation_strength == "moderate"
976
+ )
977
+ )
978
+ moderate = moderate_result.scalar() or 0
979
+
980
+ weak_result = await self.session.execute(
981
+ select(func.count(CrossAlertCorrelation.id)).where(
982
+ CrossAlertCorrelation.correlation_strength == "weak"
983
+ )
984
+ )
985
+ weak = weak_result.scalar() or 0
986
+
987
+ total_correlations = strong + moderate + weak
792
988
 
793
989
  # Recent activity
794
- recent_correlations = sum(
795
- 1 for c in _correlations
796
- if c.get("created_at") and datetime.fromisoformat(c["created_at"]) >= last_24h
990
+ recent_corr_result = await self.session.execute(
991
+ select(func.count(CrossAlertCorrelation.id)).where(
992
+ CrossAlertCorrelation.created_at >= last_24h
993
+ )
797
994
  )
798
- recent_triggers = sum(
799
- 1 for e in _auto_trigger_events
800
- if e.get("created_at") and datetime.fromisoformat(e["created_at"]) >= last_24h
995
+ recent_correlations = recent_corr_result.scalar() or 0
996
+
997
+ recent_trigger_result = await self.session.execute(
998
+ select(func.count(CrossAlertTriggerEvent.id)).where(
999
+ CrossAlertTriggerEvent.created_at >= last_24h
1000
+ )
801
1001
  )
1002
+ recent_triggers = recent_trigger_result.scalar() or 0
802
1003
 
803
1004
  # Trigger counts by type
804
- anomaly_to_drift = sum(
805
- 1 for e in _auto_trigger_events
806
- if e.get("trigger_type") == "anomaly_to_drift"
1005
+ anomaly_to_drift_result = await self.session.execute(
1006
+ select(func.count(CrossAlertTriggerEvent.id)).where(
1007
+ CrossAlertTriggerEvent.trigger_type == "anomaly_to_drift"
1008
+ )
807
1009
  )
808
- drift_to_anomaly = sum(
809
- 1 for e in _auto_trigger_events
810
- if e.get("trigger_type") == "drift_to_anomaly"
1010
+ anomaly_to_drift = anomaly_to_drift_result.scalar() or 0
1011
+
1012
+ drift_to_anomaly_result = await self.session.execute(
1013
+ select(func.count(CrossAlertTriggerEvent.id)).where(
1014
+ CrossAlertTriggerEvent.trigger_type == "drift_to_anomaly"
1015
+ )
811
1016
  )
1017
+ drift_to_anomaly = drift_to_anomaly_result.scalar() or 0
812
1018
 
813
1019
  # Top affected sources
814
- source_counts: dict[str, int] = {}
815
- for c in _correlations:
816
- sid = c.get("source_id")
817
- if sid:
818
- source_counts[sid] = source_counts.get(sid, 0) + 1
819
-
820
- top_sources = [
821
- {"source_id": sid, "source_name": c.get("source_name"), "count": cnt}
822
- for sid, cnt in sorted(source_counts.items(), key=lambda x: x[1], reverse=True)[:5]
823
- for c in _correlations if c.get("source_id") == sid
824
- ][:5]
1020
+ top_sources_result = await self.session.execute(
1021
+ select(
1022
+ CrossAlertCorrelation.source_id,
1023
+ func.count(CrossAlertCorrelation.id).label("count"),
1024
+ )
1025
+ .group_by(CrossAlertCorrelation.source_id)
1026
+ .order_by(func.count(CrossAlertCorrelation.id).desc())
1027
+ .limit(5)
1028
+ )
1029
+ top_sources_raw = top_sources_result.all()
1030
+
1031
+ top_sources = []
1032
+ for source_id, count in top_sources_raw:
1033
+ source_result = await self.session.execute(
1034
+ select(Source).where(Source.id == source_id)
1035
+ )
1036
+ source = source_result.scalar_one_or_none()
1037
+ top_sources.append({
1038
+ "source_id": source_id,
1039
+ "source_name": source.name if source else None,
1040
+ "count": count,
1041
+ })
1042
+
1043
+ # Get global config for enabled status
1044
+ global_config = await self.get_config(None)
825
1045
 
826
1046
  return {
827
- "total_correlations": len(_correlations),
1047
+ "total_correlations": total_correlations,
828
1048
  "strong_correlations": strong,
829
1049
  "moderate_correlations": moderate,
830
1050
  "weak_correlations": weak,
831
1051
  "recent_correlations_24h": recent_correlations,
832
1052
  "recent_auto_triggers_24h": recent_triggers,
833
1053
  "top_affected_sources": top_sources,
834
- "auto_trigger_enabled": _global_config.get("enabled", True),
1054
+ "auto_trigger_enabled": global_config.get("enabled", True),
835
1055
  "anomaly_to_drift_triggers": anomaly_to_drift,
836
1056
  "drift_to_anomaly_triggers": drift_to_anomaly,
837
1057
  }