truthound-dashboard 1.4.4__py3-none-any.whl → 1.5.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 (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 +437 -10
  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 +11 -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.0.dist-info/METADATA +309 -0
  146. {truthound_dashboard-1.4.4.dist-info → truthound_dashboard-1.5.0.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.0.dist-info}/WHEEL +0 -0
  204. {truthound_dashboard-1.4.4.dist-info → truthound_dashboard-1.5.0.dist-info}/entry_points.txt +0 -0
  205. {truthound_dashboard-1.4.4.dist-info → truthound_dashboard-1.5.0.dist-info}/licenses/LICENSE +0 -0
@@ -19,7 +19,7 @@ import uuid
19
19
  from datetime import datetime, timedelta
20
20
  from typing import TYPE_CHECKING, Any
21
21
 
22
- from sqlalchemy import select, func, and_
22
+ from sqlalchemy import select, func, and_, desc
23
23
  from sqlalchemy.ext.asyncio import AsyncSession
24
24
 
25
25
  from .drift_sampling import (
@@ -80,7 +80,7 @@ class DriftMonitorService:
80
80
  Raises:
81
81
  ValueError: If source not found.
82
82
  """
83
- from truthound_dashboard.core.drift import DriftService
83
+ from truthound_dashboard.core.services import DriftService
84
84
  from truthound_dashboard.db.models import Source
85
85
 
86
86
  # Get source details for display
@@ -332,21 +332,40 @@ class DriftMonitorService:
332
332
  logger.info(f"Deleted drift monitor: {monitor_id}")
333
333
  return True
334
334
 
335
- async def run_monitor(self, monitor_id: str) -> "DriftComparison | None":
335
+ async def run_monitor(self, monitor_id: str, force: bool = False) -> "DriftComparison | None":
336
336
  """Execute a drift monitoring run.
337
337
 
338
+ Creates a DriftMonitorRun record to track execution history,
339
+ then performs drift comparison and creates alerts if needed.
340
+
338
341
  Args:
339
342
  monitor_id: Monitor ID.
343
+ force: If True, run even if monitor is paused (for manual runs).
340
344
 
341
345
  Returns:
342
346
  Drift comparison result or None on error.
343
347
  """
344
- from truthound_dashboard.core.drift import DriftService
348
+ from truthound_dashboard.core.services import DriftService
349
+ from truthound_dashboard.db.models import DriftMonitorRun
345
350
 
346
351
  monitor = await self.get_monitor(monitor_id)
347
- if not monitor or monitor.status != "active":
352
+ if not monitor:
353
+ return None
354
+ # Skip status check if force=True (manual run) or if status is active
355
+ if not force and monitor.status != "active":
348
356
  return None
349
357
 
358
+ # Create run record to track execution
359
+ start_time = datetime.utcnow()
360
+ run = DriftMonitorRun(
361
+ id=str(uuid.uuid4()),
362
+ monitor_id=monitor.id,
363
+ status="running",
364
+ created_at=start_time,
365
+ )
366
+ self.session.add(run)
367
+ await self.session.flush() # Get run.id without committing
368
+
350
369
  try:
351
370
  # Create drift service and run comparison
352
371
  drift_service = DriftService(self.session)
@@ -358,45 +377,79 @@ class DriftMonitorService:
358
377
  columns=monitor.columns_json,
359
378
  )
360
379
 
380
+ # Calculate duration
381
+ end_time = datetime.utcnow()
382
+ duration_ms = int((end_time - start_time).total_seconds() * 1000)
383
+
384
+ # Extract drifted column names
385
+ drifted_columns = []
386
+ if comparison.result_json and "columns" in comparison.result_json:
387
+ drifted_columns = [
388
+ col["column"]
389
+ for col in comparison.result_json["columns"]
390
+ if col.get("drifted", False)
391
+ ]
392
+
393
+ # Update run record with results
394
+ run.status = "completed"
395
+ run.has_drift = comparison.has_drift
396
+ run.max_drift_score = comparison.drift_percentage
397
+ run.total_columns = comparison.total_columns
398
+ run.drifted_columns = len(drifted_columns)
399
+ run.column_results = comparison.result_json
400
+ run.duration_ms = duration_ms
401
+ run.completed_at = end_time
402
+
361
403
  # Update monitor stats
362
- monitor.last_run_at = datetime.utcnow()
404
+ monitor.last_run_at = end_time
363
405
  monitor.total_runs += 1
364
- monitor.last_drift_detected = comparison.has_drift
365
406
 
366
407
  if comparison.has_drift:
367
408
  monitor.drift_detected_count += 1
368
409
  monitor.consecutive_drift_count += 1
369
410
 
370
- # Create alert if configured
411
+ # Create alert if configured, linking to this run
371
412
  if monitor.alert_on_drift:
372
- await self._create_drift_alert(monitor, comparison)
413
+ await self._create_drift_alert(monitor, comparison, run.id)
373
414
  else:
374
415
  monitor.consecutive_drift_count = 0
375
416
 
376
417
  await self.session.commit()
377
418
  await self.session.refresh(monitor)
419
+ await self.session.refresh(run)
378
420
 
379
421
  logger.info(
380
- f"Drift monitor {monitor_id} run complete: drift={comparison.has_drift}"
422
+ f"Drift monitor {monitor_id} run complete: "
423
+ f"run_id={run.id}, drift={comparison.has_drift}, duration={duration_ms}ms"
381
424
  )
382
425
  return comparison
383
426
 
384
427
  except Exception as e:
385
- logger.error(f"Drift monitor {monitor_id} run failed: {e}")
428
+ # Update run record with error
429
+ end_time = datetime.utcnow()
430
+ run.status = "failed"
431
+ run.error_message = str(e)
432
+ run.duration_ms = int((end_time - start_time).total_seconds() * 1000)
433
+ run.completed_at = end_time
434
+
386
435
  monitor.status = "error"
387
436
  await self.session.commit()
437
+
438
+ logger.error(f"Drift monitor {monitor_id} run failed: {e}")
388
439
  return None
389
440
 
390
441
  async def _create_drift_alert(
391
442
  self,
392
443
  monitor: "DriftMonitor",
393
444
  comparison: "DriftComparison",
445
+ run_id: str | None = None,
394
446
  ) -> "DriftAlert":
395
447
  """Create a drift alert.
396
448
 
397
449
  Args:
398
450
  monitor: Drift monitor.
399
451
  comparison: Drift comparison result.
452
+ run_id: Optional DriftMonitorRun ID to link to this alert.
400
453
 
401
454
  Returns:
402
455
  Created alert.
@@ -426,21 +479,164 @@ class DriftMonitorService:
426
479
  alert = DriftAlert(
427
480
  id=str(uuid.uuid4()),
428
481
  monitor_id=monitor.id,
429
- comparison_id=comparison.id,
482
+ run_id=run_id, # Link to DriftMonitorRun for execution history tracking
430
483
  severity=severity,
431
- drift_percentage=drift_pct,
432
- drifted_columns_json=drifted_columns,
484
+ drift_score=drift_pct,
485
+ affected_columns=drifted_columns,
433
486
  message=f"Drift detected: {drift_pct:.1f}% of columns drifted ({len(drifted_columns)} columns)",
434
- status="open",
487
+ status="active",
435
488
  )
436
489
 
437
490
  self.session.add(alert)
438
- await self.session.commit()
439
- await self.session.refresh(alert)
491
+ # Don't commit here - let the caller manage the transaction
492
+ await self.session.flush()
440
493
 
441
- logger.info(f"Created drift alert: {alert.id} (severity={severity})")
494
+ logger.info(f"Created drift alert: {alert.id} (severity={severity}, run_id={run_id})")
442
495
  return alert
443
496
 
497
+ # Run History Management
498
+
499
+ async def list_runs(
500
+ self,
501
+ monitor_id: str,
502
+ status: str | None = None,
503
+ limit: int = 50,
504
+ offset: int = 0,
505
+ ) -> tuple[list["DriftMonitorRun"], int]:
506
+ """List drift monitor runs.
507
+
508
+ Args:
509
+ monitor_id: Monitor ID to list runs for.
510
+ status: Filter by status (running, completed, failed).
511
+ limit: Maximum number of runs.
512
+ offset: Number to skip.
513
+
514
+ Returns:
515
+ Tuple of (runs, total_count).
516
+ """
517
+ from truthound_dashboard.db.models import DriftMonitorRun
518
+
519
+ query = select(DriftMonitorRun).where(DriftMonitorRun.monitor_id == monitor_id)
520
+ count_query = select(func.count(DriftMonitorRun.id)).where(
521
+ DriftMonitorRun.monitor_id == monitor_id
522
+ )
523
+
524
+ if status:
525
+ query = query.where(DriftMonitorRun.status == status)
526
+ count_query = count_query.where(DriftMonitorRun.status == status)
527
+
528
+ # Order by created_at descending (newest first)
529
+ query = query.order_by(desc(DriftMonitorRun.created_at))
530
+ query = query.offset(offset).limit(limit)
531
+
532
+ result = await self.session.execute(query)
533
+ runs = list(result.scalars().all())
534
+
535
+ count_result = await self.session.execute(count_query)
536
+ total = count_result.scalar() or 0
537
+
538
+ return runs, total
539
+
540
+ async def get_run(self, run_id: str) -> "DriftMonitorRun | None":
541
+ """Get a specific run by ID.
542
+
543
+ Args:
544
+ run_id: Run ID.
545
+
546
+ Returns:
547
+ Run or None if not found.
548
+ """
549
+ from truthound_dashboard.db.models import DriftMonitorRun
550
+
551
+ result = await self.session.execute(
552
+ select(DriftMonitorRun).where(DriftMonitorRun.id == run_id)
553
+ )
554
+ return result.scalar_one_or_none()
555
+
556
+ async def get_latest_run(self, monitor_id: str) -> "DriftMonitorRun | None":
557
+ """Get the latest run for a monitor.
558
+
559
+ Args:
560
+ monitor_id: Monitor ID.
561
+
562
+ Returns:
563
+ Latest run or None if no runs exist.
564
+ """
565
+ from truthound_dashboard.db.models import DriftMonitorRun
566
+
567
+ result = await self.session.execute(
568
+ select(DriftMonitorRun)
569
+ .where(DriftMonitorRun.monitor_id == monitor_id)
570
+ .order_by(desc(DriftMonitorRun.created_at))
571
+ .limit(1)
572
+ )
573
+ return result.scalar_one_or_none()
574
+
575
+ async def get_run_statistics(self, monitor_id: str) -> dict:
576
+ """Get run statistics for a monitor.
577
+
578
+ Args:
579
+ monitor_id: Monitor ID.
580
+
581
+ Returns:
582
+ Dictionary with run statistics.
583
+ """
584
+ from truthound_dashboard.db.models import DriftMonitorRun
585
+
586
+ # Total runs
587
+ total_result = await self.session.execute(
588
+ select(func.count(DriftMonitorRun.id)).where(
589
+ DriftMonitorRun.monitor_id == monitor_id
590
+ )
591
+ )
592
+ total_runs = total_result.scalar() or 0
593
+
594
+ # Completed runs
595
+ completed_result = await self.session.execute(
596
+ select(func.count(DriftMonitorRun.id)).where(
597
+ DriftMonitorRun.monitor_id == monitor_id,
598
+ DriftMonitorRun.status == "completed",
599
+ )
600
+ )
601
+ completed_runs = completed_result.scalar() or 0
602
+
603
+ # Failed runs
604
+ failed_result = await self.session.execute(
605
+ select(func.count(DriftMonitorRun.id)).where(
606
+ DriftMonitorRun.monitor_id == monitor_id,
607
+ DriftMonitorRun.status == "failed",
608
+ )
609
+ )
610
+ failed_runs = failed_result.scalar() or 0
611
+
612
+ # Runs with drift
613
+ drift_result = await self.session.execute(
614
+ select(func.count(DriftMonitorRun.id)).where(
615
+ DriftMonitorRun.monitor_id == monitor_id,
616
+ DriftMonitorRun.has_drift == True, # noqa: E712
617
+ )
618
+ )
619
+ runs_with_drift = drift_result.scalar() or 0
620
+
621
+ # Average duration
622
+ avg_duration_result = await self.session.execute(
623
+ select(func.avg(DriftMonitorRun.duration_ms)).where(
624
+ DriftMonitorRun.monitor_id == monitor_id,
625
+ DriftMonitorRun.status == "completed",
626
+ )
627
+ )
628
+ avg_duration_ms = avg_duration_result.scalar() or 0
629
+
630
+ return {
631
+ "total_runs": total_runs,
632
+ "completed_runs": completed_runs,
633
+ "failed_runs": failed_runs,
634
+ "runs_with_drift": runs_with_drift,
635
+ "success_rate": (completed_runs / total_runs * 100) if total_runs > 0 else 0,
636
+ "drift_rate": (runs_with_drift / completed_runs * 100) if completed_runs > 0 else 0,
637
+ "avg_duration_ms": int(avg_duration_ms),
638
+ }
639
+
444
640
  # Alert Management
445
641
 
446
642
  async def list_alerts(
@@ -1153,7 +1349,7 @@ class DriftMonitorService:
1153
1349
 
1154
1350
  # Run the comparison with sampling
1155
1351
  # In a real implementation, this would call truthound.compare with sampling
1156
- from truthound_dashboard.core.drift import DriftService
1352
+ from truthound_dashboard.core.services import DriftService
1157
1353
 
1158
1354
  drift_service = DriftService(self.session)
1159
1355
 
@@ -1228,7 +1424,7 @@ class DriftMonitorService:
1228
1424
  monitor.total_runs += 1
1229
1425
 
1230
1426
  has_drift = len(all_drifted_columns) > 0
1231
- monitor.last_drift_detected = has_drift
1427
+ # last_drift_detected is computed from latest_run, no need to set
1232
1428
 
1233
1429
  if has_drift:
1234
1430
  monitor.drift_detected_count += 1