truthound-dashboard 1.3.1__py3-none-any.whl → 1.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (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.0.dist-info}/METADATA +142 -22
  162. truthound_dashboard-1.4.0.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.0.dist-info}/WHEEL +0 -0
  168. {truthound_dashboard-1.3.1.dist-info → truthound_dashboard-1.4.0.dist-info}/entry_points.txt +0 -0
  169. {truthound_dashboard-1.3.1.dist-info → truthound_dashboard-1.4.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,837 @@
1
+ """Cross-alert correlation service.
2
+
3
+ This module provides services for cross-feature integration between
4
+ Anomaly Detection and Drift Monitoring alerts.
5
+
6
+ When anomaly rates spike, it automatically checks for drift and vice versa.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+ import uuid
13
+ from datetime import datetime, timedelta
14
+ from typing import TYPE_CHECKING, Any
15
+
16
+ from sqlalchemy import select, func, and_, or_
17
+ from sqlalchemy.ext.asyncio import AsyncSession
18
+
19
+ if TYPE_CHECKING:
20
+ from truthound_dashboard.db.models import (
21
+ AnomalyDetection,
22
+ DriftAlert,
23
+ DriftComparison,
24
+ Source,
25
+ )
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+
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,
46
+ }
47
+
48
+ _source_configs: dict[str, dict[str, Any]] = {}
49
+ _correlations: list[dict[str, Any]] = []
50
+ _auto_trigger_events: list[dict[str, Any]] = []
51
+
52
+
53
+ class CrossAlertService:
54
+ """Service for cross-alert correlation between anomaly and drift detection.
55
+
56
+ Provides functionality for:
57
+ - Finding correlated alerts between anomaly and drift detection
58
+ - Auto-triggering drift checks when anomalies spike
59
+ - Auto-triggering anomaly checks when drift is detected
60
+ - Managing auto-trigger configuration
61
+ """
62
+
63
+ def __init__(self, session: AsyncSession) -> None:
64
+ """Initialize service.
65
+
66
+ Args:
67
+ session: Database session.
68
+ """
69
+ self.session = session
70
+
71
+ # =========================================================================
72
+ # Correlation Analysis
73
+ # =========================================================================
74
+
75
+ async def correlate_anomaly_drift(
76
+ self,
77
+ source_id: str,
78
+ *,
79
+ time_window_hours: int = 24,
80
+ limit: int = 50,
81
+ ) -> list[dict[str, Any]]:
82
+ """Find correlated anomaly and drift alerts for a source.
83
+
84
+ Args:
85
+ source_id: Data source ID.
86
+ time_window_hours: Time window to look for correlations.
87
+ limit: Maximum correlations to return.
88
+
89
+ Returns:
90
+ List of correlation dictionaries.
91
+ """
92
+ from truthound_dashboard.db.models import (
93
+ AnomalyDetection,
94
+ DriftAlert,
95
+ DriftComparison,
96
+ Source,
97
+ )
98
+
99
+ # Get source info
100
+ result = await self.session.execute(
101
+ select(Source).where(Source.id == source_id)
102
+ )
103
+ source = result.scalar_one_or_none()
104
+ source_name = source.name if source else None
105
+
106
+ # Time window
107
+ since = datetime.utcnow() - timedelta(hours=time_window_hours)
108
+
109
+ # Get recent anomaly detections
110
+ anomaly_result = await self.session.execute(
111
+ select(AnomalyDetection)
112
+ .where(
113
+ and_(
114
+ AnomalyDetection.source_id == source_id,
115
+ AnomalyDetection.created_at >= since,
116
+ AnomalyDetection.status == "success",
117
+ )
118
+ )
119
+ .order_by(AnomalyDetection.created_at.desc())
120
+ .limit(100)
121
+ )
122
+ anomaly_detections = list(anomaly_result.scalars().all())
123
+
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,
130
+ )
131
+ )
132
+ .order_by(DriftAlert.created_at.desc())
133
+ .limit(100)
134
+ )
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)
143
+ )
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))
150
+
151
+ # Find correlations
152
+ correlations = []
153
+ for detection in anomaly_detections:
154
+ if detection.anomaly_rate is None or detection.anomaly_rate < 0.01:
155
+ continue
156
+
157
+ for alert, comparison in related_drift_alerts:
158
+ # Check time proximity
159
+ time_delta = abs(
160
+ (detection.created_at - alert.created_at).total_seconds()
161
+ )
162
+
163
+ # Only correlate if within 2 hours of each other
164
+ if time_delta > 7200:
165
+ continue
166
+
167
+ # Calculate correlation strength
168
+ strength = self._calculate_correlation_strength(
169
+ detection, alert, time_delta
170
+ )
171
+
172
+ if strength == "none":
173
+ continue
174
+
175
+ # Find common columns
176
+ anomaly_cols = detection.columns_analyzed or []
177
+ drift_cols = alert.drifted_columns_json or []
178
+ common_cols = list(set(anomaly_cols) & set(drift_cols))
179
+
180
+ correlation = {
181
+ "id": str(uuid.uuid4()),
182
+ "source_id": source_id,
183
+ "source_name": source_name,
184
+ "correlation_strength": strength,
185
+ "confidence_score": self._calculate_confidence(
186
+ detection, alert, common_cols, time_delta
187
+ ),
188
+ "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
+ },
215
+ "common_columns": common_cols,
216
+ "suggested_action": self._suggest_action(strength, common_cols),
217
+ "notes": None,
218
+ "created_at": datetime.utcnow().isoformat(),
219
+ "updated_at": datetime.utcnow().isoformat(),
220
+ }
221
+ correlations.append(correlation)
222
+
223
+ # Sort by confidence score and limit
224
+ correlations.sort(key=lambda x: x["confidence_score"], reverse=True)
225
+ return correlations[:limit]
226
+
227
+ def _calculate_correlation_strength(
228
+ self,
229
+ detection: "AnomalyDetection",
230
+ alert: "DriftAlert",
231
+ time_delta: float,
232
+ ) -> 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
+ """
243
+ score = 0
244
+
245
+ # Time proximity (closer = stronger)
246
+ if time_delta < 600: # 10 minutes
247
+ score += 3
248
+ elif time_delta < 1800: # 30 minutes
249
+ score += 2
250
+ elif time_delta < 3600: # 1 hour
251
+ score += 1
252
+
253
+ # Anomaly severity
254
+ rate = detection.anomaly_rate or 0
255
+ if rate > 0.2:
256
+ score += 3
257
+ elif rate > 0.1:
258
+ score += 2
259
+ elif rate > 0.05:
260
+ score += 1
261
+
262
+ # Drift severity
263
+ if alert.severity == "critical":
264
+ score += 3
265
+ elif alert.severity == "high":
266
+ score += 2
267
+ elif alert.severity == "medium":
268
+ score += 1
269
+
270
+ # Drift percentage
271
+ drift_pct = alert.drift_percentage or 0
272
+ if drift_pct > 30:
273
+ score += 2
274
+ elif drift_pct > 15:
275
+ score += 1
276
+
277
+ # Determine strength
278
+ if score >= 8:
279
+ return "strong"
280
+ elif score >= 5:
281
+ return "moderate"
282
+ elif score >= 2:
283
+ return "weak"
284
+ return "none"
285
+
286
+ def _calculate_confidence(
287
+ self,
288
+ detection: "AnomalyDetection",
289
+ alert: "DriftAlert",
290
+ common_cols: list[str],
291
+ time_delta: float,
292
+ ) -> 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
+ """
304
+ confidence = 0.5 # Base confidence
305
+
306
+ # Time proximity bonus
307
+ if time_delta < 300:
308
+ confidence += 0.2
309
+ elif time_delta < 1800:
310
+ confidence += 0.1
311
+
312
+ # Common columns bonus
313
+ if len(common_cols) > 3:
314
+ confidence += 0.2
315
+ elif len(common_cols) > 0:
316
+ confidence += 0.1
317
+
318
+ # Severity bonus
319
+ rate = detection.anomaly_rate or 0
320
+ if rate > 0.15 and alert.severity in ("critical", "high"):
321
+ confidence += 0.15
322
+
323
+ return min(confidence, 1.0)
324
+
325
+ def _anomaly_severity(self, rate: float | None) -> str:
326
+ """Determine anomaly severity from rate."""
327
+ if rate is None:
328
+ return "low"
329
+ if rate > 0.2:
330
+ return "critical"
331
+ if rate > 0.1:
332
+ return "high"
333
+ if rate > 0.05:
334
+ return "medium"
335
+ return "low"
336
+
337
+ 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
+ """
347
+ if strength == "strong":
348
+ if common_cols:
349
+ cols = ", ".join(common_cols[:3])
350
+ return f"Investigate upstream changes affecting columns: {cols}"
351
+ return "Investigate upstream data pipeline for recent changes"
352
+ elif strength == "moderate":
353
+ return "Review data quality and consider updating baseline"
354
+ else:
355
+ return "Monitor for recurring patterns"
356
+
357
+ # =========================================================================
358
+ # Auto-Trigger Operations
359
+ # =========================================================================
360
+
361
+ async def auto_trigger_drift_on_anomaly(
362
+ self,
363
+ detection_id: str,
364
+ ) -> 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
374
+ from truthound_dashboard.core.drift_monitor import DriftMonitorService
375
+
376
+ # Get detection
377
+ result = await self.session.execute(
378
+ select(AnomalyDetection).where(AnomalyDetection.id == detection_id)
379
+ )
380
+ detection = result.scalar_one_or_none()
381
+
382
+ if not detection:
383
+ return None
384
+
385
+ # Get config
386
+ config = self.get_config(detection.source_id)
387
+ if not config.get("enabled") or not config.get("trigger_drift_on_anomaly"):
388
+ return self._create_skip_event(
389
+ detection.source_id,
390
+ "anomaly_to_drift",
391
+ detection_id,
392
+ "anomaly",
393
+ "Auto-trigger disabled",
394
+ )
395
+
396
+ # Check thresholds
397
+ thresholds = config.get("thresholds", {})
398
+ rate_threshold = thresholds.get("anomaly_rate_threshold", 0.1)
399
+ count_threshold = thresholds.get("anomaly_count_threshold", 10)
400
+
401
+ rate = detection.anomaly_rate or 0
402
+ count = detection.anomaly_count or 0
403
+
404
+ if rate < rate_threshold and count < count_threshold:
405
+ return self._create_skip_event(
406
+ detection.source_id,
407
+ "anomaly_to_drift",
408
+ detection_id,
409
+ "anomaly",
410
+ f"Below thresholds (rate: {rate:.2f} < {rate_threshold}, count: {count} < {count_threshold})",
411
+ )
412
+
413
+ # Check cooldown
414
+ 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)
419
+ elapsed = (datetime.utcnow() - last_trigger).total_seconds()
420
+ if elapsed < cooldown:
421
+ return self._create_skip_event(
422
+ detection.source_id,
423
+ "anomaly_to_drift",
424
+ detection_id,
425
+ "anomaly",
426
+ f"Cooldown active ({int(cooldown - elapsed)}s remaining)",
427
+ )
428
+
429
+ # Find a drift monitor for this source
430
+ monitor_result = await self.session.execute(
431
+ select(DriftMonitor)
432
+ .where(
433
+ or_(
434
+ DriftMonitor.baseline_source_id == detection.source_id,
435
+ DriftMonitor.current_source_id == detection.source_id,
436
+ )
437
+ )
438
+ .limit(1)
439
+ )
440
+ monitor = monitor_result.scalar_one_or_none()
441
+
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
+ }
457
+
458
+ 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
463
+
464
+ try:
465
+ # Run the drift monitor
466
+ event["status"] = "running"
467
+ drift_service = DriftMonitorService(self.session)
468
+ comparison = await drift_service.run_monitor(monitor.id)
469
+
470
+ if comparison:
471
+ event["status"] = "completed"
472
+ event["result_id"] = comparison.id
473
+
474
+ # Check for correlation
475
+ if comparison.has_drift:
476
+ correlations = await self.correlate_anomaly_drift(
477
+ detection.source_id, time_window_hours=1
478
+ )
479
+ if correlations:
480
+ event["correlation_found"] = True
481
+ event["correlation_id"] = correlations[0]["id"]
482
+ else:
483
+ event["status"] = "failed"
484
+ event["error_message"] = "Drift monitor run failed"
485
+
486
+ except Exception as e:
487
+ event["status"] = "failed"
488
+ event["error_message"] = str(e)
489
+ logger.error(f"Auto-trigger drift check failed: {e}")
490
+
491
+ # Update last trigger time
492
+ self._update_config(
493
+ detection.source_id,
494
+ {"last_anomaly_trigger_at": datetime.utcnow().isoformat()},
495
+ )
496
+
497
+ _auto_trigger_events.append(event)
498
+ return event
499
+
500
+ async def auto_trigger_anomaly_on_drift(
501
+ self,
502
+ monitor_id: str,
503
+ ) -> 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
513
+ from truthound_dashboard.core.anomaly import AnomalyDetectionService
514
+
515
+ # Get monitor
516
+ result = await self.session.execute(
517
+ select(DriftMonitor).where(DriftMonitor.id == monitor_id)
518
+ )
519
+ monitor = result.scalar_one_or_none()
520
+
521
+ if not monitor:
522
+ return None
523
+
524
+ source_id = monitor.current_source_id
525
+
526
+ # Get latest alert for this monitor
527
+ alert_result = await self.session.execute(
528
+ select(DriftAlert)
529
+ .where(DriftAlert.monitor_id == monitor_id)
530
+ .order_by(DriftAlert.created_at.desc())
531
+ .limit(1)
532
+ )
533
+ alert = alert_result.scalar_one_or_none()
534
+
535
+ if not alert:
536
+ return None
537
+
538
+ # Get config
539
+ config = self.get_config(source_id)
540
+ if not config.get("enabled") or not config.get("trigger_anomaly_on_drift"):
541
+ return self._create_skip_event(
542
+ source_id,
543
+ "drift_to_anomaly",
544
+ alert.id,
545
+ "drift",
546
+ "Auto-trigger disabled",
547
+ )
548
+
549
+ # Check thresholds
550
+ thresholds = config.get("thresholds", {})
551
+ drift_threshold = thresholds.get("drift_percentage_threshold", 10.0)
552
+ cols_threshold = thresholds.get("drift_columns_threshold", 2)
553
+
554
+ drift_pct = alert.drift_percentage or 0
555
+ cols_count = len(alert.drifted_columns_json or [])
556
+
557
+ if drift_pct < drift_threshold and cols_count < cols_threshold:
558
+ return self._create_skip_event(
559
+ source_id,
560
+ "drift_to_anomaly",
561
+ alert.id,
562
+ "drift",
563
+ f"Below thresholds (drift: {drift_pct:.1f}% < {drift_threshold}%, cols: {cols_count} < {cols_threshold})",
564
+ )
565
+
566
+ # Check cooldown
567
+ 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)
572
+ elapsed = (datetime.utcnow() - last_trigger).total_seconds()
573
+ if elapsed < cooldown:
574
+ return self._create_skip_event(
575
+ source_id,
576
+ "drift_to_anomaly",
577
+ alert.id,
578
+ "drift",
579
+ f"Cooldown active ({int(cooldown - elapsed)}s remaining)",
580
+ )
581
+
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
+ }
597
+
598
+ try:
599
+ # Run anomaly detection
600
+ event["status"] = "running"
601
+ anomaly_service = AnomalyDetectionService(self.session)
602
+
603
+ detection = await anomaly_service.create_detection(
604
+ source_id=source_id,
605
+ algorithm="isolation_forest",
606
+ columns=alert.drifted_columns_json,
607
+ )
608
+ detection = await anomaly_service.run_detection(detection.id)
609
+
610
+ event["status"] = "completed"
611
+ event["result_id"] = detection.id
612
+
613
+ # Check for correlation
614
+ if detection.anomaly_count and detection.anomaly_count > 0:
615
+ correlations = await self.correlate_anomaly_drift(
616
+ source_id, time_window_hours=1
617
+ )
618
+ if correlations:
619
+ event["correlation_found"] = True
620
+ event["correlation_id"] = correlations[0]["id"]
621
+
622
+ except Exception as e:
623
+ event["status"] = "failed"
624
+ event["error_message"] = str(e)
625
+ logger.error(f"Auto-trigger anomaly check failed: {e}")
626
+
627
+ # Update last trigger time
628
+ self._update_config(
629
+ source_id,
630
+ {"last_drift_trigger_at": datetime.utcnow().isoformat()},
631
+ )
632
+
633
+ _auto_trigger_events.append(event)
634
+ return event
635
+
636
+ def _create_skip_event(
637
+ self,
638
+ source_id: str,
639
+ trigger_type: str,
640
+ alert_id: str,
641
+ alert_type: str,
642
+ reason: str,
643
+ ) -> 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.
689
+
690
+ Args:
691
+ source_id: Source ID for source-specific config, None for global.
692
+ **kwargs: Configuration fields to update.
693
+
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()
717
+
718
+ # =========================================================================
719
+ # Query Operations
720
+ # =========================================================================
721
+
722
+ async def get_correlations(
723
+ self,
724
+ source_id: str | None = None,
725
+ *,
726
+ limit: int = 50,
727
+ offset: int = 0,
728
+ ) -> tuple[list[dict[str, Any]], int]:
729
+ """Get correlation records.
730
+
731
+ Args:
732
+ source_id: Filter by source ID.
733
+ limit: Maximum to return.
734
+ offset: Number to skip.
735
+
736
+ Returns:
737
+ Tuple of (correlations, total_count).
738
+ """
739
+ if source_id:
740
+ filtered = [c for c in _correlations if c.get("source_id") == source_id]
741
+ else:
742
+ filtered = _correlations.copy()
743
+
744
+ total = len(filtered)
745
+ paginated = filtered[offset : offset + limit]
746
+ return paginated, total
747
+
748
+ async def get_auto_trigger_events(
749
+ self,
750
+ source_id: str | None = None,
751
+ *,
752
+ limit: int = 50,
753
+ offset: int = 0,
754
+ ) -> tuple[list[dict[str, Any]], int]:
755
+ """Get auto-trigger event records.
756
+
757
+ Args:
758
+ source_id: Filter by source ID.
759
+ limit: Maximum to return.
760
+ offset: Number to skip.
761
+
762
+ Returns:
763
+ Tuple of (events, total_count).
764
+ """
765
+ 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()
771
+
772
+ # Sort by created_at desc
773
+ filtered.sort(key=lambda x: x.get("created_at", ""), reverse=True)
774
+
775
+ total = len(filtered)
776
+ paginated = filtered[offset : offset + limit]
777
+ return paginated, total
778
+
779
+ async def get_summary(self) -> dict[str, Any]:
780
+ """Get cross-alert summary statistics.
781
+
782
+ Returns:
783
+ Summary dictionary.
784
+ """
785
+ now = datetime.utcnow()
786
+ last_24h = now - timedelta(hours=24)
787
+
788
+ # 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")
792
+
793
+ # 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
797
+ )
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
801
+ )
802
+
803
+ # 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"
807
+ )
808
+ drift_to_anomaly = sum(
809
+ 1 for e in _auto_trigger_events
810
+ if e.get("trigger_type") == "drift_to_anomaly"
811
+ )
812
+
813
+ # 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]
825
+
826
+ return {
827
+ "total_correlations": len(_correlations),
828
+ "strong_correlations": strong,
829
+ "moderate_correlations": moderate,
830
+ "weak_correlations": weak,
831
+ "recent_correlations_24h": recent_correlations,
832
+ "recent_auto_triggers_24h": recent_triggers,
833
+ "top_affected_sources": top_sources,
834
+ "auto_trigger_enabled": _global_config.get("enabled", True),
835
+ "anomaly_to_drift_triggers": anomaly_to_drift,
836
+ "drift_to_anomaly_triggers": drift_to_anomaly,
837
+ }