truthound-dashboard 1.3.0__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.0.dist-info → truthound_dashboard-1.4.0.dist-info}/METADATA +142 -18
  162. truthound_dashboard-1.4.0.dist-info/RECORD +239 -0
  163. truthound_dashboard/static/assets/index-BCA8H1hO.js +0 -574
  164. truthound_dashboard/static/assets/index-BNsSQ2fN.css +0 -1
  165. truthound_dashboard/static/assets/unmerged_dictionaries-CsJWCRx9.js +0 -1
  166. truthound_dashboard-1.3.0.dist-info/RECORD +0 -110
  167. {truthound_dashboard-1.3.0.dist-info → truthound_dashboard-1.4.0.dist-info}/WHEEL +0 -0
  168. {truthound_dashboard-1.3.0.dist-info → truthound_dashboard-1.4.0.dist-info}/entry_points.txt +0 -0
  169. {truthound_dashboard-1.3.0.dist-info → truthound_dashboard-1.4.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,540 @@
1
+ """Drift monitoring API endpoints.
2
+
3
+ This module provides REST API endpoints for drift monitoring management.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from typing import Annotated
9
+
10
+ from fastapi import APIRouter, Depends, HTTPException, Query
11
+
12
+ from truthound_dashboard.core.drift_monitor import DriftMonitorService
13
+ from truthound_dashboard.schemas.drift_monitor import (
14
+ DriftMonitorCreate,
15
+ DriftMonitorUpdate,
16
+ DriftMonitorResponse,
17
+ DriftMonitorListResponse,
18
+ DriftAlertResponse,
19
+ DriftAlertListResponse,
20
+ DriftAlertUpdate,
21
+ DriftMonitorSummary,
22
+ DriftTrendResponse,
23
+ DriftPreviewRequest,
24
+ DriftPreviewResponse,
25
+ SamplingConfig,
26
+ SampledComparisonRequest,
27
+ )
28
+ from .deps import SessionDep
29
+
30
+ router = APIRouter()
31
+
32
+
33
+ # Dependency
34
+ async def get_drift_monitor_service(session: SessionDep) -> DriftMonitorService:
35
+ """Get drift monitor service dependency."""
36
+ return DriftMonitorService(session)
37
+
38
+
39
+ DriftMonitorServiceDep = Annotated[DriftMonitorService, Depends(get_drift_monitor_service)]
40
+
41
+
42
+ # Monitor Endpoints
43
+
44
+
45
+ @router.post(
46
+ "/drift/monitors",
47
+ response_model=dict,
48
+ status_code=201,
49
+ summary="Create drift monitor",
50
+ description="Create a new drift monitor for automatic drift detection.",
51
+ )
52
+ async def create_monitor(
53
+ request: DriftMonitorCreate,
54
+ service: DriftMonitorServiceDep,
55
+ ) -> dict:
56
+ """Create a new drift monitor."""
57
+ monitor = await service.create_monitor(
58
+ name=request.name,
59
+ baseline_source_id=request.baseline_source_id,
60
+ current_source_id=request.current_source_id,
61
+ cron_expression=request.cron_expression,
62
+ method=request.method,
63
+ threshold=request.threshold,
64
+ columns=request.columns,
65
+ alert_on_drift=request.alert_on_drift,
66
+ alert_threshold_critical=request.alert_threshold_critical,
67
+ alert_threshold_high=request.alert_threshold_high,
68
+ notification_channel_ids=request.notification_channel_ids,
69
+ )
70
+
71
+ return {
72
+ "success": True,
73
+ "data": _monitor_to_dict(monitor),
74
+ }
75
+
76
+
77
+ @router.get(
78
+ "/drift/monitors",
79
+ response_model=DriftMonitorListResponse,
80
+ summary="List drift monitors",
81
+ description="List all drift monitors with optional filtering.",
82
+ )
83
+ async def list_monitors(
84
+ service: DriftMonitorServiceDep,
85
+ status: str | None = Query(None, description="Filter by status"),
86
+ limit: int = Query(50, ge=1, le=100),
87
+ offset: int = Query(0, ge=0),
88
+ ) -> DriftMonitorListResponse:
89
+ """List drift monitors."""
90
+ monitors, total = await service.list_monitors(
91
+ status=status,
92
+ limit=limit,
93
+ offset=offset,
94
+ )
95
+
96
+ return DriftMonitorListResponse(
97
+ success=True,
98
+ data=[DriftMonitorResponse(**_monitor_to_dict(m)) for m in monitors],
99
+ total=total,
100
+ offset=offset,
101
+ limit=limit,
102
+ )
103
+
104
+
105
+ @router.get(
106
+ "/drift/monitors/summary",
107
+ response_model=dict,
108
+ summary="Get monitors summary",
109
+ description="Get summary statistics for all drift monitors.",
110
+ )
111
+ async def get_monitors_summary(
112
+ service: DriftMonitorServiceDep,
113
+ ) -> dict:
114
+ """Get summary of all drift monitors."""
115
+ summary = await service.get_summary()
116
+ return {"success": True, "data": summary}
117
+
118
+
119
+ @router.post(
120
+ "/drift/preview",
121
+ response_model=DriftPreviewResponse,
122
+ summary="Preview drift comparison",
123
+ description="Preview drift comparison results without creating a monitor or saving results.",
124
+ )
125
+ async def preview_drift(
126
+ request: DriftPreviewRequest,
127
+ service: DriftMonitorServiceDep,
128
+ ) -> DriftPreviewResponse:
129
+ """Preview drift comparison without persisting results.
130
+
131
+ This endpoint allows users to see drift comparison results before
132
+ creating a monitor. The results are not saved to the database.
133
+ """
134
+ try:
135
+ preview_result = await service.preview_drift(
136
+ baseline_source_id=request.baseline_source_id,
137
+ current_source_id=request.current_source_id,
138
+ columns=request.columns,
139
+ method=request.method,
140
+ threshold=request.threshold,
141
+ )
142
+ return DriftPreviewResponse(
143
+ success=True,
144
+ data=preview_result,
145
+ )
146
+ except ValueError as e:
147
+ raise HTTPException(status_code=404, detail=str(e))
148
+ except Exception as e:
149
+ raise HTTPException(status_code=500, detail=str(e))
150
+
151
+
152
+ @router.get(
153
+ "/drift/monitors/{monitor_id}",
154
+ response_model=dict,
155
+ summary="Get drift monitor",
156
+ description="Get a drift monitor by ID.",
157
+ )
158
+ async def get_monitor(
159
+ monitor_id: str,
160
+ service: DriftMonitorServiceDep,
161
+ ) -> dict:
162
+ """Get a drift monitor by ID."""
163
+ monitor = await service.get_monitor(monitor_id)
164
+ if not monitor:
165
+ raise HTTPException(status_code=404, detail="Monitor not found")
166
+
167
+ return {"success": True, "data": _monitor_to_dict(monitor)}
168
+
169
+
170
+ @router.put(
171
+ "/drift/monitors/{monitor_id}",
172
+ response_model=dict,
173
+ summary="Update drift monitor",
174
+ description="Update a drift monitor configuration.",
175
+ )
176
+ async def update_monitor(
177
+ monitor_id: str,
178
+ request: DriftMonitorUpdate,
179
+ service: DriftMonitorServiceDep,
180
+ ) -> dict:
181
+ """Update a drift monitor."""
182
+ update_data = request.model_dump(exclude_unset=True)
183
+ monitor = await service.update_monitor(monitor_id, **update_data)
184
+
185
+ if not monitor:
186
+ raise HTTPException(status_code=404, detail="Monitor not found")
187
+
188
+ return {"success": True, "data": _monitor_to_dict(monitor)}
189
+
190
+
191
+ @router.delete(
192
+ "/drift/monitors/{monitor_id}",
193
+ response_model=dict,
194
+ summary="Delete drift monitor",
195
+ description="Delete a drift monitor.",
196
+ )
197
+ async def delete_monitor(
198
+ monitor_id: str,
199
+ service: DriftMonitorServiceDep,
200
+ ) -> dict:
201
+ """Delete a drift monitor."""
202
+ deleted = await service.delete_monitor(monitor_id)
203
+ if not deleted:
204
+ raise HTTPException(status_code=404, detail="Monitor not found")
205
+
206
+ return {"success": True, "message": "Monitor deleted"}
207
+
208
+
209
+ @router.post(
210
+ "/drift/monitors/{monitor_id}/run",
211
+ response_model=dict,
212
+ summary="Run drift monitor",
213
+ description="Manually trigger a drift monitoring run.",
214
+ )
215
+ async def run_monitor(
216
+ monitor_id: str,
217
+ service: DriftMonitorServiceDep,
218
+ ) -> dict:
219
+ """Manually run a drift monitor."""
220
+ comparison = await service.run_monitor(monitor_id)
221
+ if not comparison:
222
+ raise HTTPException(status_code=400, detail="Monitor run failed")
223
+
224
+ return {
225
+ "success": True,
226
+ "data": {
227
+ "comparison_id": comparison.id,
228
+ "has_drift": comparison.has_drift,
229
+ "drift_percentage": comparison.drift_percentage,
230
+ "drifted_columns": comparison.drifted_columns,
231
+ },
232
+ }
233
+
234
+
235
+ @router.get(
236
+ "/drift/monitors/{monitor_id}/trend",
237
+ response_model=dict,
238
+ summary="Get drift trend",
239
+ description="Get drift trend data for a monitor over time.",
240
+ )
241
+ async def get_monitor_trend(
242
+ monitor_id: str,
243
+ service: DriftMonitorServiceDep,
244
+ days: int = Query(30, ge=1, le=365),
245
+ ) -> dict:
246
+ """Get drift trend for a monitor."""
247
+ trend = await service.get_trend(monitor_id, days=days)
248
+ if not trend:
249
+ raise HTTPException(status_code=404, detail="Monitor not found")
250
+
251
+ return {"success": True, "data": trend}
252
+
253
+
254
+ @router.get(
255
+ "/drift/monitors/{monitor_id}/runs/{run_id}/root-cause",
256
+ response_model=dict,
257
+ summary="Analyze drift root cause",
258
+ description="Analyze root causes of drift for a specific comparison run.",
259
+ )
260
+ async def get_root_cause_analysis(
261
+ monitor_id: str,
262
+ run_id: str,
263
+ service: DriftMonitorServiceDep,
264
+ ) -> dict:
265
+ """Get root cause analysis for a drift run.
266
+
267
+ Analyzes a drift comparison to identify why drift is occurring,
268
+ including statistical distribution changes, new/missing categories,
269
+ outlier introduction, data volume changes, and temporal patterns.
270
+ """
271
+ analysis = await service.analyze_root_cause(run_id=run_id, monitor_id=monitor_id)
272
+ if not analysis:
273
+ raise HTTPException(status_code=404, detail="Drift run not found")
274
+
275
+ return {"success": True, "data": analysis}
276
+
277
+
278
+ @router.get(
279
+ "/drift/comparisons/{run_id}/root-cause",
280
+ response_model=dict,
281
+ summary="Analyze drift root cause (standalone)",
282
+ description="Analyze root causes for a drift comparison without a monitor.",
283
+ )
284
+ async def get_comparison_root_cause_analysis(
285
+ run_id: str,
286
+ service: DriftMonitorServiceDep,
287
+ ) -> dict:
288
+ """Get root cause analysis for a standalone drift comparison.
289
+
290
+ Similar to the monitor-based endpoint but for one-off comparisons.
291
+ """
292
+ analysis = await service.analyze_root_cause(run_id=run_id)
293
+ if not analysis:
294
+ raise HTTPException(status_code=404, detail="Drift comparison not found")
295
+
296
+ return {"success": True, "data": analysis}
297
+
298
+
299
+ # Alert Endpoints
300
+
301
+
302
+ @router.get(
303
+ "/drift/alerts",
304
+ response_model=DriftAlertListResponse,
305
+ summary="List drift alerts",
306
+ description="List drift alerts with optional filtering.",
307
+ )
308
+ async def list_alerts(
309
+ service: DriftMonitorServiceDep,
310
+ monitor_id: str | None = Query(None, description="Filter by monitor"),
311
+ status: str | None = Query(None, description="Filter by status"),
312
+ severity: str | None = Query(None, description="Filter by severity"),
313
+ limit: int = Query(50, ge=1, le=100),
314
+ offset: int = Query(0, ge=0),
315
+ ) -> DriftAlertListResponse:
316
+ """List drift alerts."""
317
+ alerts, total = await service.list_alerts(
318
+ monitor_id=monitor_id,
319
+ status=status,
320
+ severity=severity,
321
+ limit=limit,
322
+ offset=offset,
323
+ )
324
+
325
+ return DriftAlertListResponse(
326
+ success=True,
327
+ data=[DriftAlertResponse(**_alert_to_dict(a)) for a in alerts],
328
+ total=total,
329
+ offset=offset,
330
+ limit=limit,
331
+ )
332
+
333
+
334
+ @router.get(
335
+ "/drift/alerts/{alert_id}",
336
+ response_model=dict,
337
+ summary="Get drift alert",
338
+ description="Get a drift alert by ID.",
339
+ )
340
+ async def get_alert(
341
+ alert_id: str,
342
+ service: DriftMonitorServiceDep,
343
+ ) -> dict:
344
+ """Get a drift alert by ID."""
345
+ alert = await service.get_alert(alert_id)
346
+ if not alert:
347
+ raise HTTPException(status_code=404, detail="Alert not found")
348
+
349
+ return {"success": True, "data": _alert_to_dict(alert)}
350
+
351
+
352
+ @router.put(
353
+ "/drift/alerts/{alert_id}",
354
+ response_model=dict,
355
+ summary="Update drift alert",
356
+ description="Update a drift alert status or notes.",
357
+ )
358
+ async def update_alert(
359
+ alert_id: str,
360
+ request: DriftAlertUpdate,
361
+ service: DriftMonitorServiceDep,
362
+ ) -> dict:
363
+ """Update a drift alert."""
364
+ alert = await service.update_alert(
365
+ alert_id,
366
+ status=request.status,
367
+ notes=request.notes,
368
+ )
369
+
370
+ if not alert:
371
+ raise HTTPException(status_code=404, detail="Alert not found")
372
+
373
+ return {"success": True, "data": _alert_to_dict(alert)}
374
+
375
+
376
+ # Large-Scale Dataset Optimization Endpoints
377
+
378
+
379
+ @router.post(
380
+ "/drift/monitors/{monitor_id}/run-sampled",
381
+ response_model=dict,
382
+ summary="Run sampled drift comparison",
383
+ description="Run drift comparison with sampling for large datasets (100M+ rows).",
384
+ )
385
+ async def run_sampled_comparison(
386
+ monitor_id: str,
387
+ service: DriftMonitorServiceDep,
388
+ sample_size: int | None = Query(None, description="Custom sample size (auto-estimated if null)"),
389
+ sampling_method: str = Query("random", description="Sampling method"),
390
+ confidence_level: float = Query(0.95, ge=0.80, le=0.99, description="Target confidence level"),
391
+ early_stop_threshold: float = Query(0.5, ge=0.1, le=1.0, description="Early stop threshold"),
392
+ max_workers: int = Query(4, ge=1, le=16, description="Max parallel workers"),
393
+ ) -> dict:
394
+ """Run a sampled drift comparison for large datasets.
395
+
396
+ Optimized for 100M+ row datasets with:
397
+ - Statistical sampling to reduce data volume
398
+ - Chunked processing for memory efficiency
399
+ - Parallel column comparisons
400
+ - Early stopping when drift is obvious
401
+ """
402
+ try:
403
+ result = await service.run_sampled_comparison(
404
+ monitor_id=monitor_id,
405
+ sample_size=sample_size,
406
+ sampling_method=sampling_method,
407
+ confidence_level=confidence_level,
408
+ early_stop_threshold=early_stop_threshold,
409
+ max_workers=max_workers,
410
+ )
411
+ return {"success": True, "data": result}
412
+ except ValueError as e:
413
+ raise HTTPException(status_code=404, detail=str(e))
414
+ except Exception as e:
415
+ raise HTTPException(status_code=500, detail=str(e))
416
+
417
+
418
+ @router.get(
419
+ "/drift/estimate-sample-size",
420
+ response_model=dict,
421
+ summary="Estimate optimal sample size",
422
+ description="Estimate optimal sample size for drift comparison between two sources.",
423
+ )
424
+ async def estimate_sample_size(
425
+ service: DriftMonitorServiceDep,
426
+ baseline_source_id: str = Query(..., description="Baseline source ID"),
427
+ current_source_id: str = Query(..., description="Current source ID"),
428
+ confidence_level: float = Query(0.95, ge=0.80, le=0.99, description="Target confidence level"),
429
+ margin_of_error: float = Query(0.03, ge=0.01, le=0.10, description="Acceptable margin of error"),
430
+ ) -> dict:
431
+ """Estimate optimal sample size for a drift comparison.
432
+
433
+ Returns recommended sample size based on dataset sizes and
434
+ desired confidence level. Also provides performance estimates
435
+ and sampling method recommendations.
436
+ """
437
+ try:
438
+ estimate = await service.estimate_comparison_size(
439
+ baseline_source_id=baseline_source_id,
440
+ current_source_id=current_source_id,
441
+ confidence_level=confidence_level,
442
+ margin_of_error=margin_of_error,
443
+ )
444
+ return {"success": True, "data": estimate}
445
+ except ValueError as e:
446
+ raise HTTPException(status_code=404, detail=str(e))
447
+ except Exception as e:
448
+ raise HTTPException(status_code=500, detail=str(e))
449
+
450
+
451
+ @router.get(
452
+ "/drift/jobs/{job_id}/progress",
453
+ response_model=dict,
454
+ summary="Get job progress",
455
+ description="Get progress for an active sampled comparison job.",
456
+ )
457
+ async def get_job_progress(
458
+ job_id: str,
459
+ service: DriftMonitorServiceDep,
460
+ ) -> dict:
461
+ """Get progress for an active comparison job.
462
+
463
+ Returns current progress including:
464
+ - Chunks processed
465
+ - Rows processed
466
+ - Elapsed and estimated remaining time
467
+ - Interim drift detection results
468
+ """
469
+ progress = await service.get_job_progress(job_id)
470
+ if not progress:
471
+ raise HTTPException(status_code=404, detail="Job not found or completed")
472
+
473
+ return {"success": True, "data": progress}
474
+
475
+
476
+ @router.post(
477
+ "/drift/jobs/{job_id}/cancel",
478
+ response_model=dict,
479
+ summary="Cancel comparison job",
480
+ description="Cancel an active sampled comparison job.",
481
+ )
482
+ async def cancel_job(
483
+ job_id: str,
484
+ service: DriftMonitorServiceDep,
485
+ ) -> dict:
486
+ """Cancel an active comparison job."""
487
+ cancelled = await service.cancel_job(job_id)
488
+ if not cancelled:
489
+ raise HTTPException(status_code=404, detail="Job not found or already completed")
490
+
491
+ return {"success": True, "message": "Job cancelled"}
492
+
493
+
494
+ # Helper functions
495
+
496
+
497
+ def _monitor_to_dict(monitor) -> dict:
498
+ """Convert monitor model to dictionary."""
499
+ return {
500
+ "id": monitor.id,
501
+ "name": monitor.name,
502
+ "baseline_source_id": monitor.baseline_source_id,
503
+ "current_source_id": monitor.current_source_id,
504
+ "cron_expression": monitor.cron_expression,
505
+ "method": monitor.method,
506
+ "threshold": monitor.threshold,
507
+ "columns": monitor.columns_json,
508
+ "alert_on_drift": monitor.alert_on_drift,
509
+ "alert_threshold_critical": monitor.alert_threshold_critical,
510
+ "alert_threshold_high": monitor.alert_threshold_high,
511
+ "notification_channel_ids": monitor.notification_channel_ids_json,
512
+ "status": monitor.status,
513
+ "last_run_at": monitor.last_run_at.isoformat() if monitor.last_run_at else None,
514
+ "last_drift_detected": monitor.last_drift_detected,
515
+ "total_runs": monitor.total_runs,
516
+ "drift_detected_count": monitor.drift_detected_count,
517
+ "consecutive_drift_count": monitor.consecutive_drift_count,
518
+ "created_at": monitor.created_at.isoformat() if monitor.created_at else None,
519
+ "updated_at": monitor.updated_at.isoformat() if monitor.updated_at else None,
520
+ }
521
+
522
+
523
+ def _alert_to_dict(alert) -> dict:
524
+ """Convert alert model to dictionary."""
525
+ return {
526
+ "id": alert.id,
527
+ "monitor_id": alert.monitor_id,
528
+ "comparison_id": alert.comparison_id,
529
+ "severity": alert.severity,
530
+ "drift_percentage": alert.drift_percentage,
531
+ "drifted_columns": alert.drifted_columns_json or [],
532
+ "message": alert.message,
533
+ "status": alert.status,
534
+ "acknowledged_at": alert.acknowledged_at.isoformat() if alert.acknowledged_at else None,
535
+ "acknowledged_by": alert.acknowledged_by,
536
+ "resolved_at": alert.resolved_at.isoformat() if alert.resolved_at else None,
537
+ "notes": alert.notes,
538
+ "created_at": alert.created_at.isoformat() if alert.created_at else None,
539
+ "updated_at": alert.updated_at.isoformat() if alert.updated_at else None,
540
+ }