truthound-dashboard 1.4.3__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.
- truthound_dashboard/api/alerts.py +75 -86
- truthound_dashboard/api/anomaly.py +7 -13
- truthound_dashboard/api/cross_alerts.py +38 -52
- truthound_dashboard/api/drift.py +49 -59
- truthound_dashboard/api/drift_monitor.py +234 -79
- truthound_dashboard/api/enterprise_sampling.py +498 -0
- truthound_dashboard/api/history.py +57 -5
- truthound_dashboard/api/lineage.py +3 -48
- truthound_dashboard/api/maintenance.py +104 -49
- truthound_dashboard/api/mask.py +1 -2
- truthound_dashboard/api/middleware.py +2 -1
- truthound_dashboard/api/model_monitoring.py +435 -311
- truthound_dashboard/api/notifications.py +227 -191
- truthound_dashboard/api/notifications_advanced.py +21 -20
- truthound_dashboard/api/observability.py +586 -0
- truthound_dashboard/api/plugins.py +2 -433
- truthound_dashboard/api/profile.py +199 -37
- truthound_dashboard/api/quality_reporter.py +701 -0
- truthound_dashboard/api/reports.py +7 -16
- truthound_dashboard/api/router.py +66 -0
- truthound_dashboard/api/rule_suggestions.py +5 -5
- truthound_dashboard/api/scan.py +17 -19
- truthound_dashboard/api/schedules.py +85 -50
- truthound_dashboard/api/schema_evolution.py +6 -6
- truthound_dashboard/api/schema_watcher.py +667 -0
- truthound_dashboard/api/sources.py +98 -27
- truthound_dashboard/api/tiering.py +1323 -0
- truthound_dashboard/api/triggers.py +14 -11
- truthound_dashboard/api/validations.py +12 -11
- truthound_dashboard/api/versioning.py +1 -6
- truthound_dashboard/core/__init__.py +129 -3
- truthound_dashboard/core/actions/__init__.py +62 -0
- truthound_dashboard/core/actions/custom.py +426 -0
- truthound_dashboard/core/actions/notifications.py +910 -0
- truthound_dashboard/core/actions/storage.py +472 -0
- truthound_dashboard/core/actions/webhook.py +281 -0
- truthound_dashboard/core/anomaly.py +262 -67
- truthound_dashboard/core/anomaly_explainer.py +4 -3
- truthound_dashboard/core/backends/__init__.py +67 -0
- truthound_dashboard/core/backends/base.py +299 -0
- truthound_dashboard/core/backends/errors.py +191 -0
- truthound_dashboard/core/backends/factory.py +423 -0
- truthound_dashboard/core/backends/mock_backend.py +451 -0
- truthound_dashboard/core/backends/truthound_backend.py +718 -0
- truthound_dashboard/core/checkpoint/__init__.py +87 -0
- truthound_dashboard/core/checkpoint/adapters.py +814 -0
- truthound_dashboard/core/checkpoint/checkpoint.py +491 -0
- truthound_dashboard/core/checkpoint/runner.py +270 -0
- truthound_dashboard/core/connections.py +437 -10
- truthound_dashboard/core/converters/__init__.py +14 -0
- truthound_dashboard/core/converters/truthound.py +620 -0
- truthound_dashboard/core/cross_alerts.py +540 -320
- truthound_dashboard/core/datasource_factory.py +1672 -0
- truthound_dashboard/core/drift_monitor.py +216 -20
- truthound_dashboard/core/enterprise_sampling.py +1291 -0
- truthound_dashboard/core/interfaces/__init__.py +225 -0
- truthound_dashboard/core/interfaces/actions.py +652 -0
- truthound_dashboard/core/interfaces/base.py +247 -0
- truthound_dashboard/core/interfaces/checkpoint.py +676 -0
- truthound_dashboard/core/interfaces/protocols.py +664 -0
- truthound_dashboard/core/interfaces/reporters.py +650 -0
- truthound_dashboard/core/interfaces/routing.py +646 -0
- truthound_dashboard/core/interfaces/triggers.py +619 -0
- truthound_dashboard/core/lineage.py +407 -71
- truthound_dashboard/core/model_monitoring.py +431 -3
- truthound_dashboard/core/notifications/base.py +4 -0
- truthound_dashboard/core/notifications/channels.py +501 -1203
- truthound_dashboard/core/notifications/deduplication/__init__.py +81 -115
- truthound_dashboard/core/notifications/deduplication/service.py +131 -348
- truthound_dashboard/core/notifications/dispatcher.py +202 -11
- truthound_dashboard/core/notifications/escalation/__init__.py +119 -106
- truthound_dashboard/core/notifications/escalation/engine.py +168 -358
- truthound_dashboard/core/notifications/routing/__init__.py +88 -128
- truthound_dashboard/core/notifications/routing/engine.py +90 -317
- truthound_dashboard/core/notifications/stats_aggregator.py +246 -1
- truthound_dashboard/core/notifications/throttling/__init__.py +67 -50
- truthound_dashboard/core/notifications/throttling/builder.py +117 -255
- truthound_dashboard/core/notifications/truthound_adapter.py +842 -0
- truthound_dashboard/core/phase5/collaboration.py +1 -1
- truthound_dashboard/core/plugins/lifecycle/__init__.py +0 -13
- truthound_dashboard/core/quality_reporter.py +1359 -0
- truthound_dashboard/core/report_history.py +0 -6
- truthound_dashboard/core/reporters/__init__.py +175 -14
- truthound_dashboard/core/reporters/adapters.py +943 -0
- truthound_dashboard/core/reporters/base.py +0 -3
- truthound_dashboard/core/reporters/builtin/__init__.py +18 -0
- truthound_dashboard/core/reporters/builtin/csv_reporter.py +111 -0
- truthound_dashboard/core/reporters/builtin/html_reporter.py +270 -0
- truthound_dashboard/core/reporters/builtin/json_reporter.py +127 -0
- truthound_dashboard/core/reporters/compat.py +266 -0
- truthound_dashboard/core/reporters/csv_reporter.py +2 -35
- truthound_dashboard/core/reporters/factory.py +526 -0
- truthound_dashboard/core/reporters/interfaces.py +745 -0
- truthound_dashboard/core/reporters/registry.py +1 -10
- truthound_dashboard/core/scheduler.py +165 -0
- truthound_dashboard/core/schema_evolution.py +3 -3
- truthound_dashboard/core/schema_watcher.py +1528 -0
- truthound_dashboard/core/services.py +595 -76
- truthound_dashboard/core/store_manager.py +810 -0
- truthound_dashboard/core/streaming_anomaly.py +169 -4
- truthound_dashboard/core/tiering.py +1309 -0
- truthound_dashboard/core/triggers/evaluators.py +178 -8
- truthound_dashboard/core/truthound_adapter.py +2620 -197
- truthound_dashboard/core/unified_alerts.py +23 -20
- truthound_dashboard/db/__init__.py +8 -0
- truthound_dashboard/db/database.py +8 -2
- truthound_dashboard/db/models.py +944 -25
- truthound_dashboard/db/repository.py +2 -0
- truthound_dashboard/main.py +11 -0
- truthound_dashboard/schemas/__init__.py +177 -16
- truthound_dashboard/schemas/base.py +44 -23
- truthound_dashboard/schemas/collaboration.py +19 -6
- truthound_dashboard/schemas/cross_alerts.py +19 -3
- truthound_dashboard/schemas/drift.py +61 -55
- truthound_dashboard/schemas/drift_monitor.py +67 -23
- truthound_dashboard/schemas/enterprise_sampling.py +653 -0
- truthound_dashboard/schemas/lineage.py +0 -33
- truthound_dashboard/schemas/mask.py +10 -8
- truthound_dashboard/schemas/model_monitoring.py +89 -10
- truthound_dashboard/schemas/notifications_advanced.py +13 -0
- truthound_dashboard/schemas/observability.py +453 -0
- truthound_dashboard/schemas/plugins.py +0 -280
- truthound_dashboard/schemas/profile.py +154 -247
- truthound_dashboard/schemas/quality_reporter.py +403 -0
- truthound_dashboard/schemas/reports.py +2 -2
- truthound_dashboard/schemas/rule_suggestion.py +8 -1
- truthound_dashboard/schemas/scan.py +4 -24
- truthound_dashboard/schemas/schedule.py +11 -3
- truthound_dashboard/schemas/schema_watcher.py +727 -0
- truthound_dashboard/schemas/source.py +17 -2
- truthound_dashboard/schemas/tiering.py +822 -0
- truthound_dashboard/schemas/triggers.py +16 -0
- truthound_dashboard/schemas/unified_alerts.py +7 -0
- truthound_dashboard/schemas/validation.py +0 -13
- truthound_dashboard/schemas/validators/base.py +41 -21
- truthound_dashboard/schemas/validators/business_rule_validators.py +244 -0
- truthound_dashboard/schemas/validators/localization_validators.py +273 -0
- truthound_dashboard/schemas/validators/ml_feature_validators.py +308 -0
- truthound_dashboard/schemas/validators/profiling_validators.py +275 -0
- truthound_dashboard/schemas/validators/referential_validators.py +312 -0
- truthound_dashboard/schemas/validators/registry.py +93 -8
- truthound_dashboard/schemas/validators/timeseries_validators.py +389 -0
- truthound_dashboard/schemas/versioning.py +1 -6
- truthound_dashboard/static/index.html +2 -2
- truthound_dashboard-1.5.0.dist-info/METADATA +309 -0
- {truthound_dashboard-1.4.3.dist-info → truthound_dashboard-1.5.0.dist-info}/RECORD +149 -148
- truthound_dashboard/core/plugins/hooks/__init__.py +0 -63
- truthound_dashboard/core/plugins/hooks/decorators.py +0 -367
- truthound_dashboard/core/plugins/hooks/manager.py +0 -403
- truthound_dashboard/core/plugins/hooks/protocols.py +0 -265
- truthound_dashboard/core/plugins/lifecycle/hot_reload.py +0 -584
- truthound_dashboard/core/reporters/junit_reporter.py +0 -233
- truthound_dashboard/core/reporters/markdown_reporter.py +0 -207
- truthound_dashboard/core/reporters/pdf_reporter.py +0 -209
- truthound_dashboard/static/assets/_baseUniq-BcrSP13d.js +0 -1
- truthound_dashboard/static/assets/arc-DlYjKwIL.js +0 -1
- truthound_dashboard/static/assets/architectureDiagram-VXUJARFQ-Bb2drbQM.js +0 -36
- truthound_dashboard/static/assets/blockDiagram-VD42YOAC-BlsPG1CH.js +0 -122
- truthound_dashboard/static/assets/c4Diagram-YG6GDRKO-B9JdUoaC.js +0 -10
- truthound_dashboard/static/assets/channel-Q6mHF1Hd.js +0 -1
- truthound_dashboard/static/assets/chunk-4BX2VUAB-DmyoPVuJ.js +0 -1
- truthound_dashboard/static/assets/chunk-55IACEB6-Bcz6Siv8.js +0 -1
- truthound_dashboard/static/assets/chunk-B4BG7PRW-Br3G5Rum.js +0 -165
- truthound_dashboard/static/assets/chunk-DI55MBZ5-DuM9c23u.js +0 -220
- truthound_dashboard/static/assets/chunk-FMBD7UC4-DNU-5mvT.js +0 -15
- truthound_dashboard/static/assets/chunk-QN33PNHL-Im2yNcmS.js +0 -1
- truthound_dashboard/static/assets/chunk-QZHKN3VN-kZr8XFm1.js +0 -1
- truthound_dashboard/static/assets/chunk-TZMSLE5B-Q__360q_.js +0 -1
- truthound_dashboard/static/assets/classDiagram-2ON5EDUG-vtixxUyK.js +0 -1
- truthound_dashboard/static/assets/classDiagram-v2-WZHVMYZB-vtixxUyK.js +0 -1
- truthound_dashboard/static/assets/clone-BOt2LwD0.js +0 -1
- truthound_dashboard/static/assets/cose-bilkent-S5V4N54A-CBDw6iac.js +0 -1
- truthound_dashboard/static/assets/dagre-6UL2VRFP-XdKqmmY9.js +0 -4
- truthound_dashboard/static/assets/diagram-PSM6KHXK-DAZ8nx9V.js +0 -24
- truthound_dashboard/static/assets/diagram-QEK2KX5R-BRvDTbGD.js +0 -43
- truthound_dashboard/static/assets/diagram-S2PKOQOG-bQcczUkl.js +0 -24
- truthound_dashboard/static/assets/erDiagram-Q2GNP2WA-DPje7VMN.js +0 -60
- truthound_dashboard/static/assets/flowDiagram-NV44I4VS-B7BVtFVS.js +0 -162
- truthound_dashboard/static/assets/ganttDiagram-JELNMOA3-D6WKSS7U.js +0 -267
- truthound_dashboard/static/assets/gitGraphDiagram-NY62KEGX-D3vtVd3y.js +0 -65
- truthound_dashboard/static/assets/graph-BKgNKZVp.js +0 -1
- truthound_dashboard/static/assets/index-C6JSrkHo.css +0 -1
- truthound_dashboard/static/assets/index-DkU82VsU.js +0 -1800
- truthound_dashboard/static/assets/infoDiagram-WHAUD3N6-DnNCT429.js +0 -2
- truthound_dashboard/static/assets/journeyDiagram-XKPGCS4Q-DGiMozqS.js +0 -139
- truthound_dashboard/static/assets/kanban-definition-3W4ZIXB7-BV2gUgli.js +0 -89
- truthound_dashboard/static/assets/katex-Cu_Erd72.js +0 -261
- truthound_dashboard/static/assets/layout-DI2MfQ5G.js +0 -1
- truthound_dashboard/static/assets/min-DYdgXVcT.js +0 -1
- truthound_dashboard/static/assets/mindmap-definition-VGOIOE7T-C7x4ruxz.js +0 -68
- truthound_dashboard/static/assets/pieDiagram-ADFJNKIX-CAJaAB9f.js +0 -30
- truthound_dashboard/static/assets/quadrantDiagram-AYHSOK5B-DeqwDI46.js +0 -7
- truthound_dashboard/static/assets/requirementDiagram-UZGBJVZJ-e3XDpZIM.js +0 -64
- truthound_dashboard/static/assets/sankeyDiagram-TZEHDZUN-CNnAv5Ux.js +0 -10
- truthound_dashboard/static/assets/sequenceDiagram-WL72ISMW-Dsne-Of3.js +0 -145
- truthound_dashboard/static/assets/stateDiagram-FKZM4ZOC-Ee0sQXyb.js +0 -1
- truthound_dashboard/static/assets/stateDiagram-v2-4FDKWEC3-B26KqW_W.js +0 -1
- truthound_dashboard/static/assets/timeline-definition-IT6M3QCI-DZYi2yl3.js +0 -61
- truthound_dashboard/static/assets/treemap-KMMF4GRG-CY3f8In2.js +0 -128
- truthound_dashboard/static/assets/unmerged_dictionaries-Dd7xcPWG.js +0 -1
- truthound_dashboard/static/assets/xychartDiagram-PRI3JC2R-CS7fydZZ.js +0 -7
- truthound_dashboard-1.4.3.dist-info/METADATA +0 -505
- {truthound_dashboard-1.4.3.dist-info → truthound_dashboard-1.5.0.dist-info}/WHEEL +0 -0
- {truthound_dashboard-1.4.3.dist-info → truthound_dashboard-1.5.0.dist-info}/entry_points.txt +0 -0
- {truthound_dashboard-1.4.3.dist-info → truthound_dashboard-1.5.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,842 @@
|
|
|
1
|
+
"""Truthound library integration adapter for notifications.
|
|
2
|
+
|
|
3
|
+
This module provides an adapter that bridges the dashboard's notification
|
|
4
|
+
system with the truthound library's checkpoint features:
|
|
5
|
+
- Routing (ActionRouter)
|
|
6
|
+
- Deduplication (NotificationDeduplicator)
|
|
7
|
+
- Throttling (NotificationThrottler)
|
|
8
|
+
- Escalation (EscalationEngine)
|
|
9
|
+
|
|
10
|
+
The adapter loads configurations from the database and constructs
|
|
11
|
+
truthound library objects to process notifications.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import logging
|
|
17
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from datetime import datetime
|
|
20
|
+
from functools import lru_cache
|
|
21
|
+
from typing import TYPE_CHECKING, Any
|
|
22
|
+
|
|
23
|
+
from sqlalchemy import select
|
|
24
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
25
|
+
|
|
26
|
+
# Truthound library imports
|
|
27
|
+
from truthound.checkpoint.deduplication import (
|
|
28
|
+
DeduplicationConfig as TruthoundDeduplicationConfig,
|
|
29
|
+
DeduplicationPolicy,
|
|
30
|
+
InMemoryDeduplicationStore,
|
|
31
|
+
NotificationDeduplicator,
|
|
32
|
+
NotificationFingerprint,
|
|
33
|
+
TimeWindow,
|
|
34
|
+
)
|
|
35
|
+
from truthound.checkpoint.escalation import (
|
|
36
|
+
EscalationEngine,
|
|
37
|
+
EscalationEngineConfig,
|
|
38
|
+
EscalationLevel,
|
|
39
|
+
EscalationPolicy,
|
|
40
|
+
EscalationTarget,
|
|
41
|
+
EscalationTrigger,
|
|
42
|
+
TargetType,
|
|
43
|
+
)
|
|
44
|
+
from truthound.checkpoint.routing import ActionRouter, AllOf, AnyOf, NotRule, Route
|
|
45
|
+
from truthound.checkpoint.routing.base import RouteContext, RouteMode, RoutePriority
|
|
46
|
+
from truthound.checkpoint.routing.rules import (
|
|
47
|
+
AlwaysRule,
|
|
48
|
+
DataAssetRule,
|
|
49
|
+
ErrorRule,
|
|
50
|
+
IssueCountRule,
|
|
51
|
+
MetadataRule,
|
|
52
|
+
NeverRule,
|
|
53
|
+
PassRateRule,
|
|
54
|
+
SeverityRule,
|
|
55
|
+
StatusRule,
|
|
56
|
+
TagRule,
|
|
57
|
+
TimeWindowRule,
|
|
58
|
+
)
|
|
59
|
+
from truthound.checkpoint.throttling import (
|
|
60
|
+
NotificationThrottler,
|
|
61
|
+
RateLimit,
|
|
62
|
+
RateLimitScope,
|
|
63
|
+
ThrottlerBuilder,
|
|
64
|
+
ThrottlingConfig as TruthoundThrottlingConfig,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
if TYPE_CHECKING:
|
|
68
|
+
from truthound_dashboard.db.models import (
|
|
69
|
+
DeduplicationConfig,
|
|
70
|
+
EscalationIncidentModel,
|
|
71
|
+
EscalationPolicyModel,
|
|
72
|
+
RoutingRuleModel,
|
|
73
|
+
ThrottlingConfig,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
from .base import NotificationEvent
|
|
77
|
+
from .events import ValidationFailedEvent, ScheduleFailedEvent, DriftDetectedEvent
|
|
78
|
+
|
|
79
|
+
logger = logging.getLogger(__name__)
|
|
80
|
+
|
|
81
|
+
# Thread pool for running synchronous truthound operations
|
|
82
|
+
_executor = ThreadPoolExecutor(max_workers=4, thread_name_prefix="truthound_notif_")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass
|
|
86
|
+
class TruthoundStats:
|
|
87
|
+
"""Aggregated stats from truthound library components."""
|
|
88
|
+
|
|
89
|
+
routing: dict[str, Any] = field(default_factory=dict)
|
|
90
|
+
deduplication: dict[str, Any] = field(default_factory=dict)
|
|
91
|
+
throttling: dict[str, Any] = field(default_factory=dict)
|
|
92
|
+
escalation: dict[str, Any] = field(default_factory=dict)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class TruthoundNotificationAdapter:
|
|
96
|
+
"""Adapter for integrating truthound library notification features.
|
|
97
|
+
|
|
98
|
+
This adapter:
|
|
99
|
+
1. Loads routing rules from DB and constructs truthound's ActionRouter
|
|
100
|
+
2. Loads deduplication config from DB and constructs NotificationDeduplicator
|
|
101
|
+
3. Loads throttling config from DB and constructs NotificationThrottler
|
|
102
|
+
4. Loads escalation policies from DB and constructs EscalationEngine
|
|
103
|
+
|
|
104
|
+
Usage:
|
|
105
|
+
adapter = TruthoundNotificationAdapter(session)
|
|
106
|
+
await adapter.initialize()
|
|
107
|
+
|
|
108
|
+
# Check if notification should be sent
|
|
109
|
+
if await adapter.should_send_notification(event, channel_id):
|
|
110
|
+
# Send notification
|
|
111
|
+
await adapter.mark_notification_sent(event, channel_id)
|
|
112
|
+
"""
|
|
113
|
+
|
|
114
|
+
def __init__(self, session: AsyncSession) -> None:
|
|
115
|
+
"""Initialize the adapter.
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
session: Database session for loading configurations.
|
|
119
|
+
"""
|
|
120
|
+
self.session = session
|
|
121
|
+
self._router: ActionRouter | None = None
|
|
122
|
+
self._deduplicator: NotificationDeduplicator | None = None
|
|
123
|
+
self._throttler: NotificationThrottler | None = None
|
|
124
|
+
self._escalation_engine: EscalationEngine | None = None
|
|
125
|
+
self._initialized = False
|
|
126
|
+
|
|
127
|
+
async def initialize(self) -> None:
|
|
128
|
+
"""Initialize all truthound components from database configs."""
|
|
129
|
+
if self._initialized:
|
|
130
|
+
return
|
|
131
|
+
|
|
132
|
+
try:
|
|
133
|
+
await self._build_router()
|
|
134
|
+
await self._build_deduplicator()
|
|
135
|
+
await self._build_throttler()
|
|
136
|
+
await self._build_escalation_engine()
|
|
137
|
+
self._initialized = True
|
|
138
|
+
logger.info("TruthoundNotificationAdapter initialized successfully")
|
|
139
|
+
except Exception as e:
|
|
140
|
+
logger.error(f"Failed to initialize TruthoundNotificationAdapter: {e}")
|
|
141
|
+
raise
|
|
142
|
+
|
|
143
|
+
# =========================================================================
|
|
144
|
+
# Router (Routing Rules)
|
|
145
|
+
# =========================================================================
|
|
146
|
+
|
|
147
|
+
async def _build_router(self) -> None:
|
|
148
|
+
"""Build ActionRouter from database routing rules."""
|
|
149
|
+
from truthound_dashboard.db.models import RoutingRuleModel
|
|
150
|
+
|
|
151
|
+
result = await self.session.execute(
|
|
152
|
+
select(RoutingRuleModel)
|
|
153
|
+
.where(RoutingRuleModel.is_active == True)
|
|
154
|
+
.order_by(RoutingRuleModel.priority.desc())
|
|
155
|
+
)
|
|
156
|
+
rules = result.scalars().all()
|
|
157
|
+
|
|
158
|
+
self._router = ActionRouter(mode=RouteMode.ALL_MATCHES)
|
|
159
|
+
|
|
160
|
+
for rule_model in rules:
|
|
161
|
+
try:
|
|
162
|
+
truthound_rule = self._build_rule_from_config(rule_model.rule_config)
|
|
163
|
+
route = Route(
|
|
164
|
+
name=rule_model.name,
|
|
165
|
+
rule=truthound_rule,
|
|
166
|
+
actions=rule_model.actions, # Channel IDs
|
|
167
|
+
priority=rule_model.priority,
|
|
168
|
+
)
|
|
169
|
+
self._router.add_route(route)
|
|
170
|
+
logger.debug(f"Added route: {rule_model.name}")
|
|
171
|
+
except Exception as e:
|
|
172
|
+
logger.warning(f"Failed to build route '{rule_model.name}': {e}")
|
|
173
|
+
|
|
174
|
+
logger.info(f"Built router with {len(rules)} routes")
|
|
175
|
+
|
|
176
|
+
def _build_rule_from_config(self, config: dict[str, Any]) -> Any:
|
|
177
|
+
"""Convert rule_config JSON to truthound Rule object.
|
|
178
|
+
|
|
179
|
+
Args:
|
|
180
|
+
config: Rule configuration dictionary.
|
|
181
|
+
|
|
182
|
+
Returns:
|
|
183
|
+
Truthound Rule object.
|
|
184
|
+
|
|
185
|
+
Raises:
|
|
186
|
+
ValueError: If rule type is unknown.
|
|
187
|
+
"""
|
|
188
|
+
rule_type = config.get("type", "").lower()
|
|
189
|
+
|
|
190
|
+
# Basic rules
|
|
191
|
+
if rule_type == "always":
|
|
192
|
+
return AlwaysRule()
|
|
193
|
+
elif rule_type == "never":
|
|
194
|
+
return NeverRule()
|
|
195
|
+
|
|
196
|
+
# Severity rule
|
|
197
|
+
elif rule_type == "severity":
|
|
198
|
+
return SeverityRule(
|
|
199
|
+
min_severity=config.get("min_severity", "low"),
|
|
200
|
+
max_severity=config.get("max_severity"),
|
|
201
|
+
min_count=config.get("min_count"),
|
|
202
|
+
exact_count=config.get("exact_count"),
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
# Issue count rule
|
|
206
|
+
elif rule_type == "issue_count":
|
|
207
|
+
return IssueCountRule(
|
|
208
|
+
min_issues=config.get("min_issues"),
|
|
209
|
+
max_issues=config.get("max_issues"),
|
|
210
|
+
count_type=config.get("count_type", "total"),
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
# Status rule
|
|
214
|
+
elif rule_type == "status":
|
|
215
|
+
return StatusRule(
|
|
216
|
+
statuses=config.get("statuses", []),
|
|
217
|
+
negate=config.get("negate", False),
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
# Tag rule
|
|
221
|
+
elif rule_type == "tag":
|
|
222
|
+
return TagRule(
|
|
223
|
+
tags=config.get("tags", {}),
|
|
224
|
+
match_all=config.get("match_all", True),
|
|
225
|
+
negate=config.get("negate", False),
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
# Data asset rule
|
|
229
|
+
elif rule_type == "data_asset":
|
|
230
|
+
return DataAssetRule(
|
|
231
|
+
pattern=config.get("pattern", "*"),
|
|
232
|
+
is_regex=config.get("is_regex", False),
|
|
233
|
+
case_sensitive=config.get("case_sensitive", True),
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
# Metadata rule
|
|
237
|
+
elif rule_type == "metadata":
|
|
238
|
+
return MetadataRule(
|
|
239
|
+
key_path=config.get("key_path", ""),
|
|
240
|
+
expected_value=config.get("expected_value"),
|
|
241
|
+
comparator=config.get("comparator", "eq"),
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
# Time window rule
|
|
245
|
+
elif rule_type == "time_window":
|
|
246
|
+
return TimeWindowRule(
|
|
247
|
+
start_time=config.get("start_time"),
|
|
248
|
+
end_time=config.get("end_time"),
|
|
249
|
+
days_of_week=config.get("days_of_week"),
|
|
250
|
+
timezone=config.get("timezone", "UTC"),
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
# Pass rate rule
|
|
254
|
+
elif rule_type == "pass_rate":
|
|
255
|
+
return PassRateRule(
|
|
256
|
+
min_rate=config.get("min_rate"),
|
|
257
|
+
max_rate=config.get("max_rate"),
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
# Error rule
|
|
261
|
+
elif rule_type == "error":
|
|
262
|
+
return ErrorRule(
|
|
263
|
+
pattern=config.get("pattern"),
|
|
264
|
+
negate=config.get("negate", False),
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
# Combinators
|
|
268
|
+
elif rule_type == "all_of":
|
|
269
|
+
sub_rules = [
|
|
270
|
+
self._build_rule_from_config(r)
|
|
271
|
+
for r in config.get("rules", [])
|
|
272
|
+
]
|
|
273
|
+
return AllOf(sub_rules)
|
|
274
|
+
|
|
275
|
+
elif rule_type == "any_of":
|
|
276
|
+
sub_rules = [
|
|
277
|
+
self._build_rule_from_config(r)
|
|
278
|
+
for r in config.get("rules", [])
|
|
279
|
+
]
|
|
280
|
+
return AnyOf(sub_rules)
|
|
281
|
+
|
|
282
|
+
elif rule_type == "not":
|
|
283
|
+
inner_rule = self._build_rule_from_config(config.get("rule", {}))
|
|
284
|
+
return NotRule(inner_rule)
|
|
285
|
+
|
|
286
|
+
else:
|
|
287
|
+
raise ValueError(f"Unknown rule type: {rule_type}")
|
|
288
|
+
|
|
289
|
+
async def match_routes(self, event: NotificationEvent) -> list[str]:
|
|
290
|
+
"""Match event against routing rules and return channel IDs.
|
|
291
|
+
|
|
292
|
+
Args:
|
|
293
|
+
event: Notification event to match.
|
|
294
|
+
|
|
295
|
+
Returns:
|
|
296
|
+
List of channel IDs from matching routes.
|
|
297
|
+
"""
|
|
298
|
+
if not self._router:
|
|
299
|
+
await self._build_router()
|
|
300
|
+
|
|
301
|
+
# Build RouteContext from event
|
|
302
|
+
context = self._build_route_context(event)
|
|
303
|
+
|
|
304
|
+
# Match routes
|
|
305
|
+
matched_channels: set[str] = set()
|
|
306
|
+
for route in self._router.routes:
|
|
307
|
+
try:
|
|
308
|
+
if route.rule.matches(context):
|
|
309
|
+
matched_channels.update(route.actions)
|
|
310
|
+
logger.debug(f"Route '{route.name}' matched, channels: {route.actions}")
|
|
311
|
+
except Exception as e:
|
|
312
|
+
logger.warning(f"Error matching route '{route.name}': {e}")
|
|
313
|
+
|
|
314
|
+
return list(matched_channels)
|
|
315
|
+
|
|
316
|
+
def _build_route_context(self, event: NotificationEvent) -> RouteContext:
|
|
317
|
+
"""Build RouteContext from NotificationEvent."""
|
|
318
|
+
# Extract data from different event types
|
|
319
|
+
total_issues = 0
|
|
320
|
+
critical_issues = 0
|
|
321
|
+
high_issues = 0
|
|
322
|
+
medium_issues = 0
|
|
323
|
+
low_issues = 0
|
|
324
|
+
info_issues = 0
|
|
325
|
+
status = "unknown"
|
|
326
|
+
data_asset = ""
|
|
327
|
+
tags: dict[str, str] = {}
|
|
328
|
+
metadata: dict[str, Any] = {}
|
|
329
|
+
|
|
330
|
+
if isinstance(event, (ValidationFailedEvent, ScheduleFailedEvent)):
|
|
331
|
+
total_issues = getattr(event, "total_issues", 0)
|
|
332
|
+
if getattr(event, "has_critical", False):
|
|
333
|
+
critical_issues = 1
|
|
334
|
+
if getattr(event, "has_high", False):
|
|
335
|
+
high_issues = 1
|
|
336
|
+
status = "failure"
|
|
337
|
+
data_asset = getattr(event, "source_name", "")
|
|
338
|
+
|
|
339
|
+
elif isinstance(event, DriftDetectedEvent):
|
|
340
|
+
status = "drift_detected"
|
|
341
|
+
data_asset = getattr(event, "source_name", "")
|
|
342
|
+
metadata["drift_percentage"] = getattr(event, "drift_percentage", 0)
|
|
343
|
+
|
|
344
|
+
return RouteContext(
|
|
345
|
+
checkpoint_name=event.event_type,
|
|
346
|
+
run_id=getattr(event, "validation_id", str(id(event))),
|
|
347
|
+
status=status,
|
|
348
|
+
data_asset=data_asset,
|
|
349
|
+
run_time=event.timestamp,
|
|
350
|
+
total_issues=total_issues,
|
|
351
|
+
critical_issues=critical_issues,
|
|
352
|
+
high_issues=high_issues,
|
|
353
|
+
medium_issues=medium_issues,
|
|
354
|
+
low_issues=low_issues,
|
|
355
|
+
info_issues=info_issues,
|
|
356
|
+
tags=tags,
|
|
357
|
+
metadata=metadata,
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
# =========================================================================
|
|
361
|
+
# Deduplication
|
|
362
|
+
# =========================================================================
|
|
363
|
+
|
|
364
|
+
async def _build_deduplicator(self) -> None:
|
|
365
|
+
"""Build NotificationDeduplicator from database config."""
|
|
366
|
+
from truthound_dashboard.db.models import DeduplicationConfig
|
|
367
|
+
|
|
368
|
+
result = await self.session.execute(
|
|
369
|
+
select(DeduplicationConfig).where(DeduplicationConfig.is_active == True)
|
|
370
|
+
)
|
|
371
|
+
config = result.scalar_one_or_none()
|
|
372
|
+
|
|
373
|
+
if config:
|
|
374
|
+
# Map dashboard policy to truthound policy
|
|
375
|
+
policy_map = {
|
|
376
|
+
"basic": DeduplicationPolicy.BASIC,
|
|
377
|
+
"severity": DeduplicationPolicy.SEVERITY,
|
|
378
|
+
"issue_based": DeduplicationPolicy.ISSUE_BASED,
|
|
379
|
+
"strict": DeduplicationPolicy.STRICT,
|
|
380
|
+
"none": DeduplicationPolicy.NONE,
|
|
381
|
+
}
|
|
382
|
+
policy = policy_map.get(config.policy, DeduplicationPolicy.BASIC)
|
|
383
|
+
|
|
384
|
+
truthound_config = TruthoundDeduplicationConfig(
|
|
385
|
+
enabled=True,
|
|
386
|
+
policy=policy,
|
|
387
|
+
default_window=TimeWindow(seconds=config.window_seconds),
|
|
388
|
+
)
|
|
389
|
+
|
|
390
|
+
self._deduplicator = NotificationDeduplicator(
|
|
391
|
+
store=InMemoryDeduplicationStore(),
|
|
392
|
+
config=truthound_config,
|
|
393
|
+
)
|
|
394
|
+
logger.info(
|
|
395
|
+
f"Built deduplicator with policy={config.policy}, "
|
|
396
|
+
f"window={config.window_seconds}s"
|
|
397
|
+
)
|
|
398
|
+
else:
|
|
399
|
+
# Default deduplicator
|
|
400
|
+
self._deduplicator = NotificationDeduplicator(
|
|
401
|
+
store=InMemoryDeduplicationStore(),
|
|
402
|
+
config=TruthoundDeduplicationConfig(
|
|
403
|
+
enabled=True,
|
|
404
|
+
policy=DeduplicationPolicy.BASIC,
|
|
405
|
+
default_window=TimeWindow(minutes=5),
|
|
406
|
+
),
|
|
407
|
+
)
|
|
408
|
+
logger.info("Built default deduplicator (no active config found)")
|
|
409
|
+
|
|
410
|
+
async def is_duplicate(
|
|
411
|
+
self,
|
|
412
|
+
event: NotificationEvent,
|
|
413
|
+
channel_id: str,
|
|
414
|
+
) -> bool:
|
|
415
|
+
"""Check if notification is a duplicate.
|
|
416
|
+
|
|
417
|
+
Args:
|
|
418
|
+
event: Notification event.
|
|
419
|
+
channel_id: Target channel ID.
|
|
420
|
+
|
|
421
|
+
Returns:
|
|
422
|
+
True if duplicate (should be suppressed), False otherwise.
|
|
423
|
+
"""
|
|
424
|
+
if not self._deduplicator:
|
|
425
|
+
await self._build_deduplicator()
|
|
426
|
+
|
|
427
|
+
# Generate fingerprint
|
|
428
|
+
fingerprint = NotificationFingerprint.generate(
|
|
429
|
+
checkpoint_name=event.event_type,
|
|
430
|
+
action_type=channel_id,
|
|
431
|
+
severity=self._get_event_severity(event),
|
|
432
|
+
data_asset=getattr(event, "source_name", ""),
|
|
433
|
+
)
|
|
434
|
+
|
|
435
|
+
# Check using the deduplicator's is_duplicate method
|
|
436
|
+
return self._deduplicator.is_duplicate_fingerprint(fingerprint)
|
|
437
|
+
|
|
438
|
+
async def mark_notification_sent(
|
|
439
|
+
self,
|
|
440
|
+
event: NotificationEvent,
|
|
441
|
+
channel_id: str,
|
|
442
|
+
) -> None:
|
|
443
|
+
"""Mark notification as sent for deduplication tracking.
|
|
444
|
+
|
|
445
|
+
Args:
|
|
446
|
+
event: Notification event.
|
|
447
|
+
channel_id: Target channel ID.
|
|
448
|
+
"""
|
|
449
|
+
if not self._deduplicator:
|
|
450
|
+
return
|
|
451
|
+
|
|
452
|
+
fingerprint = NotificationFingerprint.generate(
|
|
453
|
+
checkpoint_name=event.event_type,
|
|
454
|
+
action_type=channel_id,
|
|
455
|
+
severity=self._get_event_severity(event),
|
|
456
|
+
data_asset=getattr(event, "source_name", ""),
|
|
457
|
+
)
|
|
458
|
+
self._deduplicator.mark_sent(fingerprint)
|
|
459
|
+
|
|
460
|
+
def _get_event_severity(self, event: NotificationEvent) -> str:
|
|
461
|
+
"""Extract severity from event."""
|
|
462
|
+
if hasattr(event, "has_critical") and event.has_critical:
|
|
463
|
+
return "critical"
|
|
464
|
+
elif hasattr(event, "has_high") and event.has_high:
|
|
465
|
+
return "high"
|
|
466
|
+
return "medium"
|
|
467
|
+
|
|
468
|
+
# =========================================================================
|
|
469
|
+
# Throttling
|
|
470
|
+
# =========================================================================
|
|
471
|
+
|
|
472
|
+
async def _build_throttler(self) -> None:
|
|
473
|
+
"""Build NotificationThrottler from database config."""
|
|
474
|
+
from truthound_dashboard.db.models import ThrottlingConfig
|
|
475
|
+
|
|
476
|
+
result = await self.session.execute(
|
|
477
|
+
select(ThrottlingConfig).where(ThrottlingConfig.is_active == True)
|
|
478
|
+
)
|
|
479
|
+
configs = result.scalars().all()
|
|
480
|
+
|
|
481
|
+
builder = ThrottlerBuilder()
|
|
482
|
+
|
|
483
|
+
# Apply first active global config (channel_id is None)
|
|
484
|
+
global_config = next((c for c in configs if c.channel_id is None), None)
|
|
485
|
+
if global_config:
|
|
486
|
+
if global_config.per_minute:
|
|
487
|
+
builder.with_per_minute_limit(global_config.per_minute)
|
|
488
|
+
if global_config.per_hour:
|
|
489
|
+
builder.with_per_hour_limit(global_config.per_hour)
|
|
490
|
+
if global_config.per_day:
|
|
491
|
+
builder.with_per_day_limit(global_config.per_day)
|
|
492
|
+
builder.with_burst_allowance(global_config.burst_allowance)
|
|
493
|
+
logger.info(
|
|
494
|
+
f"Built throttler with per_minute={global_config.per_minute}, "
|
|
495
|
+
f"per_hour={global_config.per_hour}, per_day={global_config.per_day}"
|
|
496
|
+
)
|
|
497
|
+
else:
|
|
498
|
+
# Default throttling
|
|
499
|
+
builder.with_per_minute_limit(10)
|
|
500
|
+
builder.with_per_hour_limit(100)
|
|
501
|
+
builder.with_per_day_limit(500)
|
|
502
|
+
logger.info("Built default throttler (no active config found)")
|
|
503
|
+
|
|
504
|
+
builder.with_algorithm("token_bucket")
|
|
505
|
+
builder.with_scope(RateLimitScope.PER_ACTION)
|
|
506
|
+
|
|
507
|
+
self._throttler = builder.build()
|
|
508
|
+
|
|
509
|
+
async def is_throttled(self, channel_id: str) -> bool:
|
|
510
|
+
"""Check if channel is throttled.
|
|
511
|
+
|
|
512
|
+
Args:
|
|
513
|
+
channel_id: Target channel ID.
|
|
514
|
+
|
|
515
|
+
Returns:
|
|
516
|
+
True if throttled (should not send), False otherwise.
|
|
517
|
+
"""
|
|
518
|
+
if not self._throttler:
|
|
519
|
+
await self._build_throttler()
|
|
520
|
+
|
|
521
|
+
result = self._throttler.check(
|
|
522
|
+
action_type=channel_id,
|
|
523
|
+
checkpoint_name="notification",
|
|
524
|
+
)
|
|
525
|
+
return not result.allowed
|
|
526
|
+
|
|
527
|
+
async def acquire_throttle_permit(self, channel_id: str) -> bool:
|
|
528
|
+
"""Acquire a throttle permit (check and consume).
|
|
529
|
+
|
|
530
|
+
Args:
|
|
531
|
+
channel_id: Target channel ID.
|
|
532
|
+
|
|
533
|
+
Returns:
|
|
534
|
+
True if permit acquired, False if throttled.
|
|
535
|
+
"""
|
|
536
|
+
if not self._throttler:
|
|
537
|
+
await self._build_throttler()
|
|
538
|
+
|
|
539
|
+
result = self._throttler.acquire(
|
|
540
|
+
action_type=channel_id,
|
|
541
|
+
checkpoint_name="notification",
|
|
542
|
+
)
|
|
543
|
+
return result.allowed
|
|
544
|
+
|
|
545
|
+
# =========================================================================
|
|
546
|
+
# Escalation
|
|
547
|
+
# =========================================================================
|
|
548
|
+
|
|
549
|
+
async def _build_escalation_engine(self) -> None:
|
|
550
|
+
"""Build EscalationEngine from database policies."""
|
|
551
|
+
from truthound_dashboard.db.models import EscalationPolicyModel
|
|
552
|
+
|
|
553
|
+
result = await self.session.execute(
|
|
554
|
+
select(EscalationPolicyModel).where(EscalationPolicyModel.is_active == True)
|
|
555
|
+
)
|
|
556
|
+
policies = result.scalars().all()
|
|
557
|
+
|
|
558
|
+
config = EscalationEngineConfig(
|
|
559
|
+
store_type="memory",
|
|
560
|
+
metrics_enabled=True,
|
|
561
|
+
)
|
|
562
|
+
self._escalation_engine = EscalationEngine(config)
|
|
563
|
+
|
|
564
|
+
for policy_model in policies:
|
|
565
|
+
try:
|
|
566
|
+
truthound_policy = self._build_escalation_policy(policy_model)
|
|
567
|
+
self._escalation_engine.register_policy(truthound_policy)
|
|
568
|
+
logger.debug(f"Registered escalation policy: {policy_model.name}")
|
|
569
|
+
except Exception as e:
|
|
570
|
+
logger.warning(
|
|
571
|
+
f"Failed to build escalation policy '{policy_model.name}': {e}"
|
|
572
|
+
)
|
|
573
|
+
|
|
574
|
+
# Set notification handler (we'll just log for now, actual sending
|
|
575
|
+
# is handled by dispatcher)
|
|
576
|
+
async def notification_handler(record, level, targets):
|
|
577
|
+
logger.info(
|
|
578
|
+
f"Escalation notification: record={record.id}, "
|
|
579
|
+
f"level={level.level}, targets={[t.name for t in targets]}"
|
|
580
|
+
)
|
|
581
|
+
return True
|
|
582
|
+
|
|
583
|
+
self._escalation_engine.set_notification_handler(notification_handler)
|
|
584
|
+
logger.info(f"Built escalation engine with {len(policies)} policies")
|
|
585
|
+
|
|
586
|
+
def _build_escalation_policy(
|
|
587
|
+
self, model: "EscalationPolicyModel"
|
|
588
|
+
) -> EscalationPolicy:
|
|
589
|
+
"""Build truthound EscalationPolicy from database model."""
|
|
590
|
+
levels = []
|
|
591
|
+
for level_config in model.levels:
|
|
592
|
+
targets = []
|
|
593
|
+
for target_config in level_config.get("targets", []):
|
|
594
|
+
target_type = TargetType(target_config.get("type", "user"))
|
|
595
|
+
targets.append(
|
|
596
|
+
EscalationTarget(
|
|
597
|
+
type=target_type,
|
|
598
|
+
identifier=target_config.get("identifier", ""),
|
|
599
|
+
name=target_config.get("name", ""),
|
|
600
|
+
metadata=target_config.get("metadata", {}),
|
|
601
|
+
)
|
|
602
|
+
)
|
|
603
|
+
|
|
604
|
+
levels.append(
|
|
605
|
+
EscalationLevel(
|
|
606
|
+
level=level_config.get("level", 1),
|
|
607
|
+
delay_minutes=level_config.get("delay_minutes", 0),
|
|
608
|
+
targets=targets,
|
|
609
|
+
repeat_count=level_config.get("repeat_count", 0),
|
|
610
|
+
repeat_interval_minutes=level_config.get(
|
|
611
|
+
"repeat_interval_minutes", 5
|
|
612
|
+
),
|
|
613
|
+
require_ack=level_config.get("require_ack", True),
|
|
614
|
+
)
|
|
615
|
+
)
|
|
616
|
+
|
|
617
|
+
return EscalationPolicy(
|
|
618
|
+
name=model.name,
|
|
619
|
+
description=model.description or "",
|
|
620
|
+
levels=levels,
|
|
621
|
+
enabled=True,
|
|
622
|
+
triggers=[EscalationTrigger.UNACKNOWLEDGED],
|
|
623
|
+
max_escalations=model.max_escalations,
|
|
624
|
+
)
|
|
625
|
+
|
|
626
|
+
async def trigger_escalation(
|
|
627
|
+
self,
|
|
628
|
+
event: NotificationEvent,
|
|
629
|
+
policy_name: str | None = None,
|
|
630
|
+
) -> str | None:
|
|
631
|
+
"""Trigger escalation for an event.
|
|
632
|
+
|
|
633
|
+
Args:
|
|
634
|
+
event: Notification event.
|
|
635
|
+
policy_name: Optional specific policy name, or auto-select.
|
|
636
|
+
|
|
637
|
+
Returns:
|
|
638
|
+
Escalation record ID if triggered, None otherwise.
|
|
639
|
+
"""
|
|
640
|
+
if not self._escalation_engine:
|
|
641
|
+
await self._build_escalation_engine()
|
|
642
|
+
|
|
643
|
+
# Determine severity to select policy
|
|
644
|
+
severity = self._get_event_severity(event)
|
|
645
|
+
if not policy_name:
|
|
646
|
+
# Auto-select policy based on severity
|
|
647
|
+
policy_name = f"{severity}_alerts"
|
|
648
|
+
|
|
649
|
+
incident_id = f"{event.event_type}-{getattr(event, 'validation_id', id(event))}"
|
|
650
|
+
|
|
651
|
+
try:
|
|
652
|
+
result = await self._escalation_engine.trigger(
|
|
653
|
+
incident_id=incident_id,
|
|
654
|
+
context={
|
|
655
|
+
"event_type": event.event_type,
|
|
656
|
+
"severity": severity,
|
|
657
|
+
"source_name": getattr(event, "source_name", ""),
|
|
658
|
+
"timestamp": event.timestamp.isoformat(),
|
|
659
|
+
},
|
|
660
|
+
policy_name=policy_name,
|
|
661
|
+
)
|
|
662
|
+
if result.success:
|
|
663
|
+
return result.record.id
|
|
664
|
+
except Exception as e:
|
|
665
|
+
logger.warning(f"Failed to trigger escalation: {e}")
|
|
666
|
+
|
|
667
|
+
return None
|
|
668
|
+
|
|
669
|
+
async def acknowledge_escalation(
|
|
670
|
+
self, record_id: str, actor: str
|
|
671
|
+
) -> bool:
|
|
672
|
+
"""Acknowledge an escalation.
|
|
673
|
+
|
|
674
|
+
Args:
|
|
675
|
+
record_id: Escalation record ID.
|
|
676
|
+
actor: Who is acknowledging.
|
|
677
|
+
|
|
678
|
+
Returns:
|
|
679
|
+
True if successful.
|
|
680
|
+
"""
|
|
681
|
+
if not self._escalation_engine:
|
|
682
|
+
return False
|
|
683
|
+
|
|
684
|
+
try:
|
|
685
|
+
result = await self._escalation_engine.acknowledge(
|
|
686
|
+
record_id=record_id,
|
|
687
|
+
acknowledged_by=actor,
|
|
688
|
+
)
|
|
689
|
+
return result.success
|
|
690
|
+
except Exception as e:
|
|
691
|
+
logger.error(f"Failed to acknowledge escalation: {e}")
|
|
692
|
+
return False
|
|
693
|
+
|
|
694
|
+
async def resolve_escalation(
|
|
695
|
+
self, record_id: str, actor: str
|
|
696
|
+
) -> bool:
|
|
697
|
+
"""Resolve an escalation.
|
|
698
|
+
|
|
699
|
+
Args:
|
|
700
|
+
record_id: Escalation record ID.
|
|
701
|
+
actor: Who is resolving.
|
|
702
|
+
|
|
703
|
+
Returns:
|
|
704
|
+
True if successful.
|
|
705
|
+
"""
|
|
706
|
+
if not self._escalation_engine:
|
|
707
|
+
return False
|
|
708
|
+
|
|
709
|
+
try:
|
|
710
|
+
result = await self._escalation_engine.resolve(
|
|
711
|
+
record_id=record_id,
|
|
712
|
+
resolved_by=actor,
|
|
713
|
+
)
|
|
714
|
+
return result.success
|
|
715
|
+
except Exception as e:
|
|
716
|
+
logger.error(f"Failed to resolve escalation: {e}")
|
|
717
|
+
return False
|
|
718
|
+
|
|
719
|
+
# =========================================================================
|
|
720
|
+
# Stats
|
|
721
|
+
# =========================================================================
|
|
722
|
+
|
|
723
|
+
def get_stats(self) -> TruthoundStats:
|
|
724
|
+
"""Get aggregated stats from all truthound components.
|
|
725
|
+
|
|
726
|
+
Returns:
|
|
727
|
+
TruthoundStats with stats from each component.
|
|
728
|
+
"""
|
|
729
|
+
stats = TruthoundStats()
|
|
730
|
+
|
|
731
|
+
# Router stats
|
|
732
|
+
if self._router:
|
|
733
|
+
stats.routing = {
|
|
734
|
+
"total_routes": len(self._router.routes),
|
|
735
|
+
"mode": self._router.mode.value if hasattr(self._router, "mode") else "unknown",
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
# Deduplication stats
|
|
739
|
+
if self._deduplicator:
|
|
740
|
+
try:
|
|
741
|
+
dedup_stats = self._deduplicator.get_stats()
|
|
742
|
+
stats.deduplication = {
|
|
743
|
+
"total_evaluated": getattr(dedup_stats, "total_evaluated", 0),
|
|
744
|
+
"suppressed": getattr(dedup_stats, "suppressed", 0),
|
|
745
|
+
"suppression_ratio": getattr(dedup_stats, "suppression_ratio", 0.0),
|
|
746
|
+
"active_fingerprints": getattr(dedup_stats, "active_fingerprints", 0),
|
|
747
|
+
}
|
|
748
|
+
except Exception as e:
|
|
749
|
+
logger.warning(f"Failed to get deduplication stats: {e}")
|
|
750
|
+
|
|
751
|
+
# Throttling stats
|
|
752
|
+
if self._throttler:
|
|
753
|
+
try:
|
|
754
|
+
throttle_stats = self._throttler.get_stats()
|
|
755
|
+
stats.throttling = {
|
|
756
|
+
"total_checked": getattr(throttle_stats, "total_checked", 0),
|
|
757
|
+
"total_allowed": getattr(throttle_stats, "total_allowed", 0),
|
|
758
|
+
"total_throttled": getattr(throttle_stats, "total_throttled", 0),
|
|
759
|
+
"throttle_rate": getattr(throttle_stats, "throttle_rate", 0.0),
|
|
760
|
+
"allow_rate": getattr(throttle_stats, "allow_rate", 0.0),
|
|
761
|
+
}
|
|
762
|
+
except Exception as e:
|
|
763
|
+
logger.warning(f"Failed to get throttling stats: {e}")
|
|
764
|
+
|
|
765
|
+
# Escalation stats
|
|
766
|
+
if self._escalation_engine:
|
|
767
|
+
try:
|
|
768
|
+
esc_stats = self._escalation_engine.get_stats()
|
|
769
|
+
stats.escalation = {
|
|
770
|
+
"total_escalations": getattr(esc_stats, "total_escalations", 0),
|
|
771
|
+
"active_escalations": getattr(esc_stats, "active_escalations", 0),
|
|
772
|
+
"acknowledged_count": getattr(esc_stats, "acknowledged_count", 0),
|
|
773
|
+
"resolved_count": getattr(esc_stats, "resolved_count", 0),
|
|
774
|
+
"acknowledgment_rate": getattr(esc_stats, "acknowledgment_rate", 0.0),
|
|
775
|
+
"avg_time_to_acknowledge": getattr(
|
|
776
|
+
esc_stats, "avg_time_to_acknowledge_seconds", 0
|
|
777
|
+
),
|
|
778
|
+
}
|
|
779
|
+
except Exception as e:
|
|
780
|
+
logger.warning(f"Failed to get escalation stats: {e}")
|
|
781
|
+
|
|
782
|
+
return stats
|
|
783
|
+
|
|
784
|
+
# =========================================================================
|
|
785
|
+
# Combined Check
|
|
786
|
+
# =========================================================================
|
|
787
|
+
|
|
788
|
+
async def should_send_notification(
|
|
789
|
+
self,
|
|
790
|
+
event: NotificationEvent,
|
|
791
|
+
channel_id: str,
|
|
792
|
+
) -> bool:
|
|
793
|
+
"""Check if notification should be sent (dedup + throttle).
|
|
794
|
+
|
|
795
|
+
This is a convenience method that checks both deduplication
|
|
796
|
+
and throttling in one call.
|
|
797
|
+
|
|
798
|
+
Args:
|
|
799
|
+
event: Notification event.
|
|
800
|
+
channel_id: Target channel ID.
|
|
801
|
+
|
|
802
|
+
Returns:
|
|
803
|
+
True if notification should be sent.
|
|
804
|
+
"""
|
|
805
|
+
# Check deduplication
|
|
806
|
+
if await self.is_duplicate(event, channel_id):
|
|
807
|
+
logger.debug(f"Notification suppressed (duplicate): {event.event_type}")
|
|
808
|
+
return False
|
|
809
|
+
|
|
810
|
+
# Check throttling
|
|
811
|
+
if not await self.acquire_throttle_permit(channel_id):
|
|
812
|
+
logger.debug(f"Notification suppressed (throttled): {channel_id}")
|
|
813
|
+
return False
|
|
814
|
+
|
|
815
|
+
return True
|
|
816
|
+
|
|
817
|
+
async def reload_config(self) -> None:
|
|
818
|
+
"""Reload all configurations from database.
|
|
819
|
+
|
|
820
|
+
Call this after configuration changes to rebuild all components.
|
|
821
|
+
"""
|
|
822
|
+
self._initialized = False
|
|
823
|
+
self._router = None
|
|
824
|
+
self._deduplicator = None
|
|
825
|
+
self._throttler = None
|
|
826
|
+
# Note: We don't reset escalation engine as it may have active incidents
|
|
827
|
+
await self.initialize()
|
|
828
|
+
|
|
829
|
+
|
|
830
|
+
# Factory function
|
|
831
|
+
async def get_truthound_adapter(session: AsyncSession) -> TruthoundNotificationAdapter:
|
|
832
|
+
"""Get an initialized TruthoundNotificationAdapter.
|
|
833
|
+
|
|
834
|
+
Args:
|
|
835
|
+
session: Database session.
|
|
836
|
+
|
|
837
|
+
Returns:
|
|
838
|
+
Initialized adapter.
|
|
839
|
+
"""
|
|
840
|
+
adapter = TruthoundNotificationAdapter(session)
|
|
841
|
+
await adapter.initialize()
|
|
842
|
+
return adapter
|